docs: M3 收官——E2E 报告与收官总结入档,验收 PASSED
CI / docs-build (push) Successful in 38s

- 28 E2E 烟囱:14/14 场景、契约偏差 0、M3 四条验收标准逐条取证
  (两次不同 limit 全量翻页有序 id 逐位相等、删除前后差集验证、psql 库层互证)
- 29 收官总结:终态对照开工基线(测试 191/272→334/502、契约 18→31 路径冻结、
  ADR 001~021、四容器→六容器 + MinIO)、遗留清单与 M4 方向输入
- iteration-3/index.md 进展看板补建;feature-checklist 新增 M3 第 10~12 节
- 另登记 2 项 E2E 观察项:widthPx/heightPx 恒 null、eventVersion 口径未定型

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 15:13:18 +08:00
parent f5457c2f4c
commit 3ebe562ab7
5 changed files with 945 additions and 2 deletions
+51 -2
View File
@@ -2,7 +2,7 @@
> 目的:直观呈现哪些功能**已完成且有自动化测试**、哪些**部分完成**、哪些**尚未开始**,方便针对性验证与回归。 > 目的:直观呈现哪些功能**已完成且有自动化测试**、哪些**部分完成**、哪些**尚未开始**,方便针对性验证与回归。
> 维护约定:每波工单合入后由执行人更新本清单;状态以 `dev` 分支 + 门禁全绿为准。 > 维护约定:每波工单合入后由执行人更新本清单;状态以 `dev` 分支 + 门禁全绿为准。
> 最后更新:2026-09-08M2 第三波收口patbond-api `64c9b72` 191 测试、patbond-flutter `ba50332` 272 测试,均门禁全绿;三次 compose 实测无契约偏差;M2 条目见第 7~9 节) > 最后更新:2026-09-10M3 收官patbond-api `8089c06` 334 测试、patbond-flutter `0e87413` 502 测试,均门禁全绿;E2E 烟囱 14/14 契约偏差 0M2 条目见第 7~9 节,M3 见第 10~12 节)
图例:✅ 已完成且已测试 | 🟡 部分完成/有已知限制 | ⬜ 未开始 图例:✅ 已完成且已测试 | 🟡 部分完成/有已知限制 | ⬜ 未开始
@@ -169,5 +169,54 @@ curl -s -i http://127.0.0.1:8082/internal/users/by-username/demo_user | head -1
| 事件字典 v2 白名单(pet 域 3 + health_record 域 7 | ✅ | 后端白名单 + 边界测试(api@64c9b72);page_viewed 正稿核对零修正 | | 事件字典 v2 白名单(pet 域 3 + health_record 域 7 | ✅ | 后端白名单 + 边界测试(api@64c9b72);page_viewed 正稿核对零修正 |
| 客户端挂接:pet 域 3 事件 + health_record 域 6 事件 + pet_form 等页名 | ✅ | 强类型封装(pet_analytics/health_record_analytics);deleted 留待删除端点 | | 客户端挂接:pet 域 3 事件 + health_record 域 6 事件 + pet_form 等页名 | ✅ | 强类型封装(pet_analytics/health_record_analytics);deleted 留待删除端点 |
| Android 真机落库验证 + SessionTracker 30min 手测 | 🟡 | 桌面端全链路已通(platform 枚举拒绝属契约内);真机验证按方案 A 挂起待设备 | | Android 真机落库验证 + SessionTracker 30min 手测 | 🟡 | 桌面端全链路已通(platform 枚举拒绝属契约内);真机验证按方案 A 挂起待设备 |
| 队列完善:30s 定时冲刷、退避/429、anonymousId 持久化 | | 报告 15 §4 遗留;429 依赖后端限流实现(报告 09 出入清单) | | 队列完善:30s 定时冲刷、退避、anonymousId 持久化 | | **M3 第一波交付**iteration-3/10flutter@4d40c38);429 Retry-After 精细分支仍待后端限流 |
---
# M3 社区(第三迭代)
## 10. 社区域后端(patbond-community :8084 + media in user
| 功能 | 状态 | 自动化测试 | 说明 |
| --- | --- | --- | --- |
| Flyway V5 community 8 表 + pg_trgm 扩展 | ✅ | 迁移验证 8 例(干净库 V1→V5 | 剪 2 条跨 schema FK`posts.generation_job_id`M4 补回)、`posts.region_id`M5 补回),裸列与索引保留 |
| patbond-community 独立模块(ADR-017)挂 pom + compose | ✅ | 骨架 7 例(含鉴权 5) | 骨架期即接 RS256 校验(无 token/畸形/错签/过期均 401+40101);只读写 community schema |
| **media 两步上传闭环**ADR-016/017 | ✅ | 12 例(MinIO Testcontainer 全链路 + 六类失败) | 创建 upload 签预签名 PUT10min)→ confirm ready → 私有桶预签名 GET1h);post_image / jpeg·png·webp / 10 MiBObjectStorage 适配层隔离供应商 |
| 帖子生命周期 5 端点(草稿/编辑/发布/软删/详情/我的列表) | ✅ | 25 例(含真双线程并发 PATCH 恰一胜) | 发布走 PATCH draft→publishedIdempotency-Key 必带 + request_hash40905);防枚举 404/40403 逐字节一致(hidden 对作者亦不露) |
| 公共 Feed 游标分页 + FeedCard | ✅ | 分页专项 8 + 卡片定型 5 | `(published_at,id)` 游标对齐 `ix_posts_feed`contentPreview 200 码点截断;三计数走冗余列写侧同事务维护 |
| 作者公开资料链路(D3-9 方案 B) | ✅ | 内部端点 8 + 作者链路 7 + Feign 线路 3 | user 增 `/internal/users/profiles`(≤50 批量,不入公网契约)→ community Feign + 60s 缓存;**user 故障时作者退 id-only、Feed 照常 200** |
| 单层评论(列表/创建/删除) | ✅ | 11 例 | **仅评论作者可删**(用户拍板,帖主不可删他人评论);40404/40406 |
| 点赞/收藏 PUT+DELETE 幂等 + 我的收藏 | ✅ | 真并发(4 线程 PUT 恰计 1 行 1 计数)+ 对账专项 | 响应回权威终态 `{liked,likeCount}`;互动面 = 帖子公开面(作者本人草稿亦 404) |
| 关注 PUT/DELETE + follow-stats | ✅ | 3 线程并发 follow 恰 1 行 | 自关注 42204(仅 PUT);自取关 200 幂等 no-op |
| 契约冻结 v1.3.0 + 快照矩阵 173 格 | ✅ | community 64 格 + media 8 格 + mutation 自证 | 43/43 操作零漂移;四模块字节级快照,升版须同步否则 CI 红;修 `nullable+allOf` 校验盲区 |
| uploading 超时未确认 asset 清理任务 | ⬜ | — | 方案在 iteration-3/13,定时任务另排 |
| 429 限流 | ⬜ | — | 承自 iteration-2/09 出入清单;连带客户端 Retry-After 分支挂起 |
| 话题 / 关注列表 / 作者主页 | ⬜ | — | ADR-018 裁剪出 M3topics 表已建) |
## 11. 社区域客户端(patbond-flutter
| 功能 | 状态 | 说明 |
| --- | --- | --- |
| community 数据层(契约 19 操作全覆盖,T3-12) | ✅ | 9 新错误码类型化;未知枚举抛 FormatException 暴露漂移;CursorPage 上移 core 供两域复用 |
| **ToggleSync 乐观更新状态机** | ✅ | 乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫 + 服务端权威终态收敛;竞态时序 controller 级单测 |
| MediaUploader 六态编排(T3-13) | ✅ | 选图→压缩→预签名直传→confirm;29 项专项(降质阶梯/凭据过期重取/多图并发保序/孤儿防护) |
| 首页 Feed 真实数据 + 四态 + 尾部三态(T3-14) | ✅ | 下拉刷新 + 触底游标翻页;PostCard 三形态/PostMediaGrid/LikeButton/FeedSkeleton 入 core**SignedNetworkImage 剥离签名参数做缓存 key** |
| 帖子详情整页替换 + 评论区(T3-15) | ✅ | 真九宫格 + InteractiveViewer 大图;仅本人评论渲染删除入口;40403 返回 Feed 并刷新;关注双态钮 |
| 互动接线 + 三层视觉抑制(T3-16) | ✅ | 240ms 弹性激活 / 失败零动画跳变 + SnackBar / 对账静默替换;Feed 与详情共享 ToggleSync 同帧一致;减弱动态降级 |
| 发布页 PostComposePageT3-17 | ✅ | 两步发布 createPost(draft)→PATCH published(「发布失败但草稿已保存」为事实);gating 双保险;失败三语义(40905/42203/网络同键重放);草稿两路径 + 进页恢复 |
| 社区 demo 数据消亡 | ✅ | `AppState.posts/publishPost/updatePost` 及持久化整体退役;create 页仅余 M4 的 AI 生成模拟 |
| 完整草稿列表 / 自动保存 | 🟡 | 最小实现(进页恢复最新一条);完整管理留待(报告 26 §7) |
| 大图「下滑关闭」手势 | 🟡 | InteractiveViewer 手势冲突,待 photo_view 复评 |
| `widthPx/heightPx` 真实宽高比 | 🟡 | 服务端恒 null(E2E 观察项 1),单图帖一律回落 4:3 |
## 12. 埋点体系(M3 演进)
| 功能 | 状态 | 说明 |
| --- | --- | --- |
| 队列三项加固(30s 定时冲刷 / 指数退避 / anonymousId 持久化) | ✅ | 13 号规范四触发点补齐;退避 30s→5min 只挡定时冲刷;anonymousId 跨冷启动稳定(A/B 前置 #4 |
| 事件字典 v3 白名单(community 域 19 + experiment_exposed | ✅ | EventDictionary 22→42**7 个被否决事件锁死 unknown**(含逐卡曝光 post_impression |
| 客户端挂接 21 事件 | ✅ | feed 2(聚合 feed_viewed:≥50% 可见 ≥500ms、段内去重、离开结算)+ 互动 8 + 媒体三段 3 + 发布漏斗 5 + page_viewed 页名增量 |
| 逐卡 Feed 曝光 | ⬜ | ADR-020 否决(量级测算 7~14 个月击穿分区阈值 + 无背压);留 backlog 待 M4+ 服务端下发日志 |
| Android 真机验证(M2 两项 + M3 四项) | 🟡 | 步骤全部备齐在[真机验证清单](device-verification.md);桌面 `platform=linux` 整批 400 属契约内,落库只能真机验 |
| `eventVersion` 口径定型 | ⬜ | 契约描述可两读、服务端不校验、客户端硬编码 1(E2E 观察项 2) |
@@ -0,0 +1,757 @@
# 28 M3 收官:E2E 烟囱测试报告(T3-21)
- 执行人:Frontend Developer
- 日期:2026-09-10
- 环境:patbond-flutter (dev 分支) + patbond-apidocker compose 六容器编排,**代码零改动**)
- 测试脚本:`patbond-flutter/test_e2e_m3_manual.dart`commit `0e87413`,已推送 origin/dev
- 参照模式:iteration-2/28 号 M2 收官报告(格式与取证标准沿用)
- 冻结契约:`patbond-doc/docs/api/openapi.yaml` **v1.3.0**community / media 域为准)
---
## 0. 执行概要
### 测试目标
M3 第四波收官(工单 T3-21):在 compose 真实后端上跑通社区完整链路烟囱并收集证据——
注册两账号 → 两步上传直传 MinIO → 草稿发布 → 另一客户端 Feed 可见 → 预签名 GET 字节往返 →
点赞/收藏/评论/关注全互动面 → 游标分页全量翻页 → 软删出 Feed → 防枚举 → v3 埋点落库 →
幂等重放,**并对 M3 四条验收标准逐条取证**。
### 测试结果
**✓ 14/14 场景全部通过**(首次运行一次通过;同环境复跑再次 14/14,第二轮 Feed 全量
91 条 / 13 页,证明分页断言不依赖固定数据规模)
- Docker Compose **六容器**健康运行(postgres + minio + auth:8081 + user:8082 +
pet:8083 + community:8084
- 契约一致性:HTTP 状态码、业务错误码、信封结构、字段形态、分页语义、幂等语义与
冻结契约 v1.3.0 完全一致——**契约偏差数:0 个**
- Flutter 门禁三命令全绿:`dart format`144 files, 0 changed/ `flutter analyze`
No issues/ `flutter test`**502 passed**, 2 skipped
- 数据库证据齐备:`community` 五表 + `media.assets` + `platform.product_events`
psql 逐项查证一致;Feed 谓词行数与脚本全量翻页条数**交叉核对相等**(65 = 65)
### M3 四条验收标准
| # | 验收标准 | 结论 |
| --- | --- | --- |
| ① | 发布后可在另一客户端看到 | ✓ 通过(场景 4) |
| ② | 重复点赞不重复计数 | ✓ 通过(场景 6) |
| ③ | 分页不丢失不重复 | ✓ 通过(场景 10) |
| ④ | 删除或隐藏内容不可继续出现在公共 Feed | ✓ 通过(场景 11) |
逐条证据见 §5。
### ⚠️ 真机四项挂起(显著标注:**真机待补验,本报告不含其证据**)
真机不可用(设备未到位),`docs/development/device-verification.md`「M3 预登记」四项
按既定方案 A 挂起。桌面/脚本侧**不可替代**的原因已逐项记录在清单里:
| # | 挂起项 | 桌面/脚本不可替代的原因 |
| --- | --- | --- |
| 1 | **媒体上传弱网表现** | 蜂窝/弱网限速、飞行模式掐断、HEIC 与拍摄方向、凭据过期挂起 >10min——Linux 桌面无 image_picker / flutter_image_compress 平台实现(用替身) |
| 2 | **乐观更新真机手感** | 240ms 弹性动画帧率、快速连点合并、断网零动画跳变、减弱动态开关,均为真机帧率与手感范畴 |
| 3 | **Feed 图片加载** | 局域网/蜂窝对 MinIO 可达性差异、滚动缓存命中、TTL 过期重取,需真实网络与真机内存缓存 |
| 4 | **社区事件落库(客户端链路)** | Linux 桌面 `platform=linux` 不在契约枚举内,整批 400 被拒(既有预期)——**本报告以脚本直连 `/api/v1/events`platform=android 模拟真机值)替代验证服务端链路**;真机端 AnalyticsClient → 持久化队列 → 冲刷的端上链路待真机补验 |
另:M2 遗留的两项真机挂起(Android 事件落库观察、SessionTracker 30min 会话超时手测)
同样未闭环,仍在清单内。
### 脱敏声明
- 全部 access token 截断至前 20 字符 + `<REDACTED>`
- **全部预签名 URL(PUT 直传与 GET 读取)的签名 query 整体替换为 `<SIGNATURE_REDACTED>`**
仅保留 host + 对象路径
- 密码不出现在任何输出;`.env` 内容、MinIO 根凭据、`PATBOND_INTERNAL_TOKEN` 均未引用
- 幂等键值以 `<KEY-1>` 占位打印
- psql 证据中 user_id / event_id 截断为前 8 位前缀
---
## 1. 后端启动与健康检查
### 1.1 构建与启动(patbond-api 代码零改动)
> 命令中的 `<工作区>` 为你本机存放三仓的父目录(文档不写死本机路径)。
```bash
cd <工作区>/patbond-api
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw -DskipTests package
# BUILD SUCCESSexit 0
docker compose up -d --build
# Container patbond-minio-1 Healthy
# Container patbond-postgres-1 Healthy
# Container patbond-user-1 Started
# Container patbond-auth-1 Started
# Container patbond-community-1 Started
# Container patbond-pet-1 Started
```
### 1.2 容器健康状态(六容器)
```text
NAMES STATUS PORTS
patbond-pet-1 Up 10 minutes 0.0.0.0:8083->8083/tcp
patbond-auth-1 Up 10 minutes 0.0.0.0:8081->8081/tcp
patbond-community-1 Up 10 minutes 0.0.0.0:8084->8084/tcp
patbond-user-1 Up 10 minutes 0.0.0.0:8082->8082/tcp
patbond-postgres-1 Up 10 minutes (healthy) 5432/tcp
patbond-minio-1 Up 10 minutes (healthy) 0.0.0.0:9000->9000/tcp
```
### 1.3 服务就绪验证
```text
docker logs patbond-user-1 | grep Started → Started UserApplication in 12.526 seconds
docker logs patbond-auth-1 | grep Started → Started AuthApplication in 9.371 seconds
docker logs patbond-pet-1 | grep Started → Started PetApplication in 8.645 seconds
docker logs patbond-community-1 | grep Started → Started CommunityApplication in 10.999 seconds
# Flyway(迁移链由 user 服务统一执行)
o.f.core.internal.command.DbValidate : Successfully validated 5 migrations
o.f.core.internal.command.DbMigrate : Current version of schema "public": 5
o.f.core.internal.command.DbMigrate : Schema "public" is up to date.
```
```bash
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:9000/minio/health/live # 200
curl -s http://127.0.0.1:8084/api/v1/feed
# {"code":40101,"message":"token 无效或过期","data":null} ← 无 token 预期 401
curl -s http://127.0.0.1:8082/api/v1/me
# {"code":40101,"message":"token 无效或过期","data":null}
```
---
## 2. 测试脚本
`test_e2e_m3_manual.dart`(纯 dart HttpClient 脚本,无 Flutter 运行时依赖,与 M1 版
`test_e2e_manual.dart`、M2 版 `test_e2e_m2_manual.dart` 并列放**仓库根目录**
**不在 `test/` 目录**、不被 `flutter test` 收集)。
- 随机生成账号 `e2e_m3_a_<timestamp>` / `e2e_m3_b_<timestamp>` 避免冲突
- 固定测试图内嵌为常量:1×1 JPEG,344 字节,
sha256 `32142d9c…6973ce8``sha256sum` 实测值硬编码,不引入 crypto 依赖)
- 分页断言不依赖数据集规模:对同一 Feed 做**两种页大小的全量翻页并逐位比对**
- 运行方式:`docker compose up -d` 后在 patbond-flutter 目录 `dart run test_e2e_m3_manual.dart`
本次取证运行(第一轮)关键标识:
```text
E2E_USER_ID_A=01a08a1c-d609-7804-9d57-79f3eec0294e
E2E_USER_ID_B=01a08a1c-d720-744e-a0da-589efb1bc1bf
E2E_POST_ID=01a08a1c-da21-7f9b-a23f-e26ac5b8a474
E2E_ASSET_ID=01a08a1c-d7a6-7099-a638-f4d8d61eeb67
E2E_DELETED_POST_ID=01a08a1c-df1c-79a9-95bc-af1ce14fe2cd
E2E_DRAFT_POST_ID=01a08a1c-df99-7f81-82d9-aa51a5cbf11a
E2E_SESSION_ID=8857898d-0ce0-4a57-bda6-bccac98b57b6
```
---
## 3. E2E 烟囱测试执行记录(14 场景)
### 3.1 场景 1:注册账号 A、B:8081)
```text
[1/14] 注册账号 A、B:8081
POST /api/v1/auth/register (A) → 200
✓ A 注册成功
userId(A): 01a08a1c-d609-7804-9d57-79f3eec0294e
accessToken(A): eyJhbGciOiJSUzI1NiJ9...<REDACTED>
POST /api/v1/auth/register (B) → 200
✓ B 注册成功
userId(B): 01a08a1c-d720-744e-a0da-589efb1bc1bf
accessToken(B): eyJhbGciOiJSUzI1NiJ9...<REDACTED>
✓ A/B 为两个独立账号(模拟两客户端)
```
两账号即验收标准 ① 的「两个客户端」——A 为发布方,B 为消费方,全程用各自 token。
### 3.2 场景 2:两步上传——createUpload → 预签名 PUT 直传 MinIO → confirm ready
```text
[2/14] A 两步上传图片:createUpload:8082)→ 预签名 PUT → confirm
POST /api/v1/media/uploads → 201
✓ asset 登记成功(201),返回预签名直传凭据
assetId: 01a08a1c-d7a6-7099-a638-f4d8d61eeb67
uploadUrl: http://127.0.0.1:9000/patbond-media/post_image/2026/09/01a08a1c-…eb67?<SIGNATURE_REDACTED>
method: PUT / expiresAt: 2026-09-10T07:09:01.176666130Z
requiredHeaders: {Content-Type: image/jpeg}
✓ requiredHeaders 恒且仅一键 {Content-Type: image/jpeg}
✓ 预签名 URL 携带 SigV4 query 签名族(直传不经应用服务器)
PUT <presigned>344 字节,原样携带 requiredHeaders → 200
✓ 直传 MinIO 成功(存储侧接受签名)
POST /api/v1/media/uploads/{assetId}/complete → 200
✓ uploading→readybyteSize=344widthPx=null heightPx=nullreadyAt 已写,url 现签非空)
asset.url: http://127.0.0.1:9000/patbond-media/post_image/2026/09/01a08a1c-…eb67?<SIGNATURE_REDACTED>
POST .../complete(重复确认)→ 200
✓ 已 ready 重复 complete 幂等 200 同一 asset(现签新 GET URL
```
契约验证:`kind/purpose/mimeType/byteSize` 白名单通过;objectKey 由服务端生成
`post_image/2026/09/<assetId>`,不含任何用户输入);`expiresAt` = 签发 + 10min TTL
直传 URL 直指 MinIO:9000(不经应用服务器);已 ready 重复 complete 幂等 200。
> **观察项**`widthPx/heightPx` 确认为 `null`——契约 schema 标注 `nullable: true`
> 故响应形态合规,但同处描述写「complete 后回填」,实现侧**未做宽高探测**
> `patbond-user/.../media/` 无 ImageIO 类调用)。详见 §7 观察项 1。
### 3.3 场景 3:创建草稿(Idempotency-Key 必带)→ 引用 ready asset → PATCH 发布
```text
[3/14] A 创建草稿(:8084,引用 ready asset)→ PATCH 发布
POST /api/v1/posts (status=draft, Idempotency-Key 已带) → 201
✓ 草稿创建成功(201
postId: 01a08a1c-da21-7f9b-a23f-e26ac5b8a474 / status: draft / version: 0 / publishedAt: null
✓ 草稿态 status=draft 且 publishedAt=null(发布时才恰写一次)
✓ media 挂接 1 图:position=0(按数组序)、isCover 由服务端置真(库内恒有唯一封面)
PATCH /api/v1/posts/{postId} (draft→published, version=0) → 200
✓ 发布成功:status=publishedpublishedAt 已写,version 0→1
publishedAt: 2026-09-10T06:59:02.144899Z
media[0].url: http://127.0.0.1:9000/patbond-media/…?<SIGNATURE_REDACTED>
```
契约验证:两步发布定型(createPost(draft) → PATCH published);`position` 全不给时
按数组序落 0`isCover` 全 false 时服务端将 position 0 行置为封面(库内恒有唯一封面行);
`publishedAt` 发布时恰写一次;`version` 提交比对通过后 +1。
### 3.4 场景 4:【验收①】A 的帖在 B 的 Feed 可见,FeedCard 字段完整
```text
[4/14] 【验收①】B 拉 /api/v1/feed → A 的帖首位可见,FeedCard 字段完整
GET /api/v1/feed?limit=5 (B 的 token) → 200
✓ Feed 返回 200
✓ A 刚发布的帖在 B 的 Feed 首位(published_at DESC
FeedCard: title=M3 烟囱主贴 / mediaCount=1 / like=0 comment=0 bookmark=0
author: userId=01a08a1c-d609-7804-9d57-79f3eec0294e nickname=e2e_m3_a_1789023540425
✓ AuthorSummary 归因 A 且 nickname 键在(空昵称已由服务端回退 username)
✓ AuthorSummary 不露 bio / username
✓ FeedCard 必填齐备:contentPreview 原样透传(<200 码点)、mediaCount=1、
三计数为 0、B 视角 likedByMe/bookmarkedByMe=false、publishedAt 非空
✓ coverImage = 唯一 is_cover 行(assetId 命中,url 现签非空)
✓ FeedCard 裁剪生效:不带 content 全文 / media 整组 / version
```
契约验证(报告 16 定型的 FeedCard 形态逐项):
| 断言面 | 证据 |
| --- | --- |
| 必填 11 键齐备 | id / author / category / contentPreview / mediaCount / 三计数 / likedByMe / bookmarkedByMe / publishedAt 全在且取值正确 |
| **裁剪生效** | `content` / `media` 整组 / `version` / `petId` / `visibility` / created-updated 时间戳对**均不出现** |
| AuthorSummary 隐私 | 含 userId + nickname(空昵称已服务端回退为 username);**不露 bio、不露 username 键** |
| coverImage | 非 null`assetId` 命中场景 2 的 asset`isCover=true``url` 现签非空 |
| 视角字段 | B 视角 `likedByMe=false``bookmarkedByMe=false`(此时 B 尚未互动) |
### 3.5 场景 5:预签名 GET 取回图片字节与上传一致
```text
[5/14] 预签名 GET 取回图片字节 → 与上传字节逐字节比对
GET http://127.0.0.1:9000/patbond-media/post_image/2026/09/01a08a1c-…eb67?<SIGNATURE_REDACTED>
GET <presigned> → 200344 字节)
✓ 预签名 GET 取回 200
✓ 取回 344 字节与上传逐字节一致(内容往返无损)
GET <同一对象但去掉签名> → 403
✓ 桶保持私有:无签名直访被存储侧拒绝(403)
```
契约验证:读取一律预签名 GET;**内容往返逐字节无损**;桶保持私有——去掉签名 query
后同一对象 403(契约「无签名直访被拒」原文兑现)。
### 3.6 场景 6:【验收②】B 重复点赞不重复计数
```text
[6/14] 【验收②】B 连续 3 次 PUT like → likeCount 恰为 1DELETE → 0;再 DELETE 幂等
PUT /like(第 1 次)→ 200 {"liked":true,"likeCount":1}
PUT /like(第 2 次)→ 200 {"liked":true,"likeCount":1}
PUT /like(第 3 次)→ 200 {"liked":true,"likeCount":1}
✓ 三次均返回权威终态 {liked:true, likeCount:1}(非 409
✓ 详情读回 likeCount=13 次 PUT 仅实际插入一次才 +1)
DELETE /like → 200 {"liked":false,"likeCount":0}
✓ 取消点赞返回 {liked:false, likeCount:0}
DELETE /like(重复)→ 200 {"liked":false,"likeCount":0}
✓ 取消不存在的点赞不报错不减计数(DELETE 语义幂等)
✓ 再次点赞恢复 likeCount=1(供后续卡片计数观察)
```
契约验证:主键 (post_id, user_id) 即幂等键;重复 PUT 返回 **200 权威终态而非 409**
仅实际插入才 `like_count` 同事务 +1;DELETE 语义幂等(取消不存在不报错不减计数)。
`community.post_likes` 表最终恰 1 行(§4)。
### 3.7 场景 7B 收藏 + `/me/bookmarks` 含该帖;取消后不含
```text
[7/14] B 收藏 → GET /api/v1/me/bookmarks 含该帖;取消收藏后不含
PUT /bookmark → 200 {"bookmarked":true,"bookmarkCount":1}
✓ 收藏返回权威终态 {bookmarked:true, bookmarkCount:1}
GET /api/v1/me/bookmarks → 200
✓ 收藏列表含该帖(共 1 条,项形态 = FeedCard
✓ 收藏项 bookmarkedByMe/likedByMe 为 B 视角,publishedAt 恒非空
DELETE /bookmark → 200 {"bookmarked":false,"bookmarkCount":0}
✓ 取消收藏返回 {bookmarked:false, bookmarkCount:0}
✓ 取消收藏后列表不含该帖(剩 0 条)
```
契约验证:收藏与点赞同构(PUT/DELETE 语义幂等 + 权威终态);`/me/bookmarks` 项形态
= FeedCard,视角字段为调用者(B)视角,`publishedAt` 恒非空不变式成立。
### 3.8 场景 8:评论——B 可删自己的,A(帖主)删 B 的被拒
```text
[8/14] B 评论 ×2 → A 拉列表可见;B 删自己评论成功;A(帖主)删 B 的评论被拒
POST /comments (B, #1) → 201
✓ 评论作者归因 B、postId 一致、非回复 replyToUser=null、无 updatedAtM3 无编辑)
POST /comments (B, #2, replyToUserId=A) → 201
✓ @ 回复目标解出 AuthorSummary(单层平铺,无 parentCommentId
GET /comments (A 的 token) → 200
✓ A 拉评论列表可见 B 的两条(created_at DESC#2 在前)
✓ commentCount 同事务 +1 累计为 2
DELETE /comments/{c1} (B 删自己的) → 200
✓ B 删自己的评论成功(软删 status→deleted
DELETE /comments/{c2} (A 删 B 的) → 403 / code 40301
✓ A(帖主)删 B 的评论被拒 403/40301(无权限执行该操作)——仅评论作者可删(D3-7 拍板)
✓ 删后列表仅剩 #2(仅 visible 评论),越权目标未被删除
✓ commentCount 同事务 -1 回到 1
```
契约验证:**「仅评论作者可删、帖主不可删他人评论」的 D3-7 拍板语义真链路兑现**——
帖主 A 对可见评论的删除请求得到 403/40301,且被越权目标的评论**未被删除**(删后列表
仍含 c2、`community.comments` 中 c2 保持 `visible`);`comment_count` 同事务 +1/1
双向核对(0→2→1);`replyToUser` 为 AuthorSummary(单层平铺无 parentCommentId);
Comment 不带 `updatedAt`M3 无评论编辑)。
### 3.9 场景 9:关注与 follow-stats;自关注 42204;自取关 200 no-op
```text
[9/14] B 关注 APUT+ follow-stats 计数;自关注 42204;自取关 200 no-op
PUT /users/{A}/follow (B) → 200 {"following":true,"followerCount":1}
PUT /users/{A}/follow(重复)→ 200 {"following":true,"followerCount":1}
✓ 重复关注幂等 200,粉丝数仍为 1(主键 (follower,followee) 即幂等键)
GET /users/{A}/follow-stats (B 视角) → 200
{"followerCount":1,"followingCount":0,"followedByMe":true}
GET /users/{A}/follow-stats (A 查自己) → 200
{"followerCount":1,"followingCount":0,"followedByMe":false}
✓ A 查自己 followedByMe 恒 false(计数一致)
✓ B 的计数:关注 1 / 粉丝 0(实时 COUNT,无冗余计数列)
PUT /users/{B}/follow (B 自关注) → 422 / code 42204
✓ 自关注被拒 422/42204(不能关注自己)——库层 ck_user_follows_self 兜底
DELETE /users/{B}/follow (B 自取关) → 200 {"following":false,"followerCount":0}
✓ 自取关 200 幂等 no-op(关系行不可能存在,权威 false 即事实;42204 只在 PUT
```
契约验证:**42204 只在 PUT、自取关走 DELETE 的 200 幂等 no-op** 这条非对称语义
(报告 17 定型)真链路兑现;`followedByMe` 查自己恒 falsefollowerCount/followingCount
为实时 COUNT,A/B 双向视角互证。
### 3.10 场景 10:【验收③】Feed 游标分页不丢不重
```text
[10/14] 【验收③】A 批量发 25 帖 → 双粒度全量翻页比对
POST /api/v1/posts ×25 (status=published) → 全部 201
✓ 25 帖全部创建成功且 id 互不相同
逐页翻到底(limit=7)→ 10 页,共 65 条
✓ 细粒度翻页零重复(65 条全唯一)
✓ 翻页确实跨多页(10 页 > 1,游标真被使用)
逐页翻到底(limit=100)→ 1 页,共 65 条
✓ 粗粒度翻页零重复(65 条全唯一)
✓ 两种页大小全量结果**逐位一致**(顺序与集合都相同)→ 翻页不丢不重
✓ 25 帖 + 主贴全部恰好出现一次(无遗漏)
✓ 主贴(场景 3 发布)亦在全量结果内
Feed 全量条数(含既有数据):65;本轮新增 26 条
```
**取证方法论**:本场景刻意不采用「断言 Feed 总数等于本轮发帖数」的脆弱写法(compose
卷内有前几波留下的既有帖),而用三重强断言:
1. **零重复**`limit=7` 逐页翻到底共 65 条,去重后仍 65 条
2. **零遗漏**:本轮 25 帖 + 主贴的 26 个 id 在全量结果中**各恰好出现一次**
3. **粒度不变性**:同一 Feed 分别以 `limit=7`10 页)与 `limit=100`1 页)全量翻完,
两份有序 id 列表**逐位相等**——若 keyset 游标在页边界丢行或重行,两种粒度必然分叉
另有末页不变式随行断言:`hasMore=false``nextCursor` 恒为 null`hasMore=true`
`nextCursor` 必非 null(否则脚本立即 fail 而非静默挂死)。
**psql 交叉核对**(脚本无库访问,独立第三方证据):
```text
patbond=# select count(*) as feed_predicate_rows from community.posts
where status='published' and visibility='public' and deleted_at is null;
feed_predicate_rows
---------------------
65 ← 与脚本全量翻页 65 条相等
patbond=# select count(*) as posts_by_A_published from community.posts
where author_user_id='01a08a1c-d609-…294e'
and status='published' and visibility='public' and deleted_at is null;
posts_by_a_published
----------------------
26 ← 25 批量帖 + 1 主贴
```
第二轮复跑同一断言在 91 条 / 13 页规模下再次通过——**断言与数据规模解耦**。
### 3.11 场景 11:【验收④】软删帖不再出现在公共 Feed
```text
[11/14] 【验收④】A 软删一帖 → B 的 Feed 不再含该帖,直接 GET 404/40403
✓ 待删帖创建并发布成功(201)
doomedPostId: 01a08a1c-df1c-79a9-95bc-af1ce14fe2cd
✓ 删除前:该帖在 B 的 Feed 首位(全量 66 条)
DELETE /api/v1/posts/{doomedId} (A 软删) → 200
✓ 软删成功(deleted_at 写入)
B 全量翻 Feed(删除后)→ 65 条
✓ 删除后 B 的 Feed 全量不含该帖,且总数恰少 1(其余帖不受影响)
GET /api/v1/posts/{doomedId} (B 直接访问) → 404 / code 40403
✓ B 直接 GET 已删帖 404/40403(帖子不存在)
✓ 作者 A 自己 GET 已删帖同样 404/40403(响应体与 B 逐字节一致)
✓ 重复删除与删不存在的帖同响应 404/40403(防枚举合并)
✓ 已删帖的互动面同样 404/40403(删除后一切路径关闭)
```
**关键取证强度**:不是「翻第一页没看见」,而是**删除前后各做一次全量翻页**——
66 → 65 条,差集恰为该帖一条,证明「消失」不是被挤到后页而是真正出了谓词,且
其余 65 帖一条不少(删除操作无副作用)。四条读/写路径同时关闭:Feed(不含)、
详情(B 与作者 A 均 404/40403 且响应体逐字节一致)、重复删除(404/40403)、
互动面(PUT like → 404/40403)。
### 3.12 场景 12:防枚举一致性——草稿 vs 随机 UUID
```text
[12/14] 防枚举:B 访问 A 的草稿 与 访问随机 UUID → 响应体逐字节一致
A 的草稿 id: 01a08a1c-df99-7f81-82d9-aa51a5cbf11a
随机 UUID: ddc96d28-ced4-4646-aba9-fa1349585b54
详情 GET /posts/{草稿} → 404 / code 40403 ✓
详情 GET /posts/{随机} → 404 / code 40403 ✓
评论 GET /posts/{草稿}/comments → 404 / code 40403 ✓
评论 GET /posts/{随机}/comments → 404 / code 40403 ✓
✓ 四路响应体完全一致(防枚举):{"code":40403,"message":"帖子不存在","data":null}
✓ 互动面 = 帖子公开面:草稿点赞同一 404/40403 响应体
✓ **作者本人**对自己草稿的互动亦 404/40403(互动面恒为公开面)
✓ 草稿对作者本人详情仍可见(draft 仅作者可见)
✓ /me/posts?status=draft 含该草稿(作者视角)
✓ 草稿不在公共 Feed(谓词只放行 published+public+未删)
```
契约验证:**随机探测 UUID 与真实存在的他人草稿逐字节同响应**,攻击者无法通过响应
差异区分 id 是否命中真实记录;「互动面 = 帖子公开面」定型语义完整——**含作者本人对
自己草稿的互动亦 404/40403**(这是易被实现漏掉的一侧,本次直接取证);同时反证
草稿并未「被藏起来」:作者本人详情可读、`/me/posts?status=draft` 可见。
### 3.13 场景 13:埋点——v3 社区事件上报与落库
```text
[13/14] POST /api/v1/events:8082)上报 v3 社区事件(platform=android 模拟真机值)
eventId: a599a7e0-… (feed_viewed)
eventId: 23bd78e5-… (post_media_upload_succeeded)
eventId: 2a3f4d95-… (post_publish_succeeded)
eventId: 2c4e81fc-… (post_liked)
eventId: 3888d902-… (post_favorited)
eventId: 79ec3981-… (comment_create_succeeded)
eventId: 73dd7633-… (user_followed)
eventId: 96918b41-… (page_viewed)
POST /api/v1/events (8 条) → 202
✓ 8/8 逐条 acceptedaccepted=8, duplicated=0, rejected=0
POST /api/v1/eventspost_liked 混入白名单外 postId)→ 202
✓ 白名单外键剥离后事件仍 accepted(隐私红线 ingest 侧兜底,落库无 postId
POST /api/v1/events(字典外 post_impression)→ 202
✓ 字典外事件整条 rejectedreason=unknown_event_name),批次仍 202
E2E_SESSION_ID=8857898d-0ce0-4a57-bda6-bccac98b57b6
```
**落库查证(docker exec psql**
```text
patbond=# SELECT event_name, event_version, platform,
left(user_id::text,8) AS user_id_prefix,
left(event_id::text,8) AS event_id_prefix, props
FROM platform.product_events
WHERE session_id = '8857898d-0ce0-4a57-bda6-bccac98b57b6'
ORDER BY event_name;
event_name | ev | platform | user_id | event_id | props
-----------------------------+----+----------+----------+----------+--------------------------------------------------
comment_create_succeeded | 3 | android | 01a08a1c | 79ec3981 | {"isReply": true, "durationMs": 720,
| | | | | "textLengthBucket": "lt_200"}
feed_viewed | 3 | android | 01a08a1c | a599a7e0 | {"feedTab": "recommend", "durationMs": 8600,
| | | | | "refreshCount": 1, "loadMoreCount": 3,
| | | | | "impressionCount": 27}
page_viewed | 3 | android | 01a08a1c | 96918b41 | {"pageName": "post_detail", "referrer": "home"}
post_favorited | 3 | android | 01a08a1c | 3888d902 | {"source": "detail"}
post_liked | 3 | android | 01a08a1c | 2c4e81fc | {"source": "feed"}
post_liked | 3 | android | 01a08a1c | 9884703f | {"source": "feed"} ← 混入的 postId 已剥离
post_media_upload_succeeded | 3 | android | 01a08a1c | 23bd78e5 | {"mediaType": "image", "durationMs": 940,
| | | | | "sizeBucket": "lt_512kb"}
post_publish_succeeded | 3 | android | 01a08a1c | 2a3f4d95 | {"fromDraft": true, "durationMs": 1560,
| | | | | "mediaCount": 1, "topicCount": 0,
| | | | | "textLengthBucket": "lt_200"}
user_followed | 3 | android | 01a08a1c | 73dd7633 | {"source": "detail"}
(9 rows)
```
三条链路同时取证:
1. **正常落库**8 条 v3 社区事件(feed / 媒体 / 发布漏斗 / 互动 / 关注 / page_viewed
全部落 `platform.product_events`,props 键集与字典 v3 白名单(报告 22)逐键一致,
`platform=android``user_id` 归因 B
2. **隐私红线 ingest 侧兜底**`post_liked` 混入白名单外 `postId` → 事件 accepted 但
**落库 props 仅 `{"source":"feed"}`postId 已被剥离**(第 6 行,event_id `9884703f`
3. **字典边界锁死**:被否决的逐卡曝光事件 `post_impression` 整条
`rejected / reason=unknown_event_name`,且**未出现在落库结果的 9 行中**
(真机端上链路见 §0 挂起项 4。)
### 3.14 场景 14:幂等重放
```text
[14/14] 幂等重放:同 Idempotency-Key 同 hash 返回原帖;异 hash → 409/40905
POST /api/v1/posts (Idempotency-Key=<KEY-1>, 首发) → 201
postId: 01a08a1c-e056-70b1-b974-53d0c269fe62 / version: 0
POST /api/v1/posts (同 KEY-1, 同 hash, 重发) → 201
✓ 同键同 hash 返回首次结果(id/version 一致),不产生第二帖
✓ 我的草稿列表恰 2 条(私密草稿 + 重放帖),重放帖只出现一次(无重复落库)
POST /api/v1/posts (同 KEY-1, **异 hash**) → 409 / code 40905
✓ 同键异 hash 被拒 409/40905(幂等键已用于不同请求)
POST /api/v1/posts**不带** Idempotency-Key)→ 400 / code 40000
✓ Idempotency-Key 缺失被拒 400/40000(参数校验失败)——该头必带
POST /api/v1/postsB 用同一 KEY-1 值)→ 201
✓ 幂等键按作者隔离:B 用同一键值创建出新帖(id 不同)
```
契约验证:同键重试返回首次创建的资源且**同样 201**(契约原文);`id`/`version` 逐项
一致;**通过 `/me/posts` 反查确认库内未产生第二行**(不只是响应看起来一样);
同键异 hash → 409/40905;该头缺失 → 400/40000;**键按作者隔离**B 用同一键值
创建出独立新帖,跨用户不串号)。
---
## 4. 数据库查询证据(community / media schema
```text
patbond=# select left(id::text,8) as post_id, status, visibility, title,
like_count, comment_count, bookmark_count, version,
(published_at is not null) as pub, (deleted_at is not null) as del
from community.posts where author_user_id='01a08a1c-…294e' and id in (…);
post_id | status | visibility | title | like | cmt | bkm | ver | pub | del
----------+-----------+------------+---------------+------+-----+-----+-----+-----+-----
01a08a1c | published | public | M3 烟囱主贴 | 1 | 1 | 0 | 1 | t | f
01a08a1c | archived | public | M3 待删帖 | 0 | 0 | 0 | 1 | t | t
01a08a1c | draft | public | M3 私密草稿 | 0 | 0 | 0 | 0 | f | f
01a08a1c | draft | public | M3 幂等重放帖 | 0 | 0 | 0 | 0 | f | f
(4 rows)
patbond=# select left(id::text,8) as asset_id, kind, purpose, mime_type, byte_size,
status, (ready_at is not null) as ready, left(object_key,44) as object_key,
(sha256 is not null) as sha256_stored from media.assets where id='01a08a1c-…eb67';
asset_id | kind | purpose | mime_type | byte_size | status | ready | object_key | sha256_stored
----------+-------+------------+------------+-----------+--------+-------+-------------------------------+---------------
01a08a1c | image | post_image | image/jpeg | 344 | ready | t | post_image/2026/09/01a08a1c-… | t
patbond=# select left(post_id::text,8) as post_id, position, is_cover, caption
from community.post_media where post_id='01a08a1c-…a474';
post_id | position | is_cover | caption
----------+----------+----------+------------
01a08a1c | 0 | t | 烟囱测试图
(1 row)
patbond=# select left(post_id::text,8) as post_id, left(user_id::text,8) as user_id,
created_at from community.post_likes where post_id='01a08a1c-…a474';
post_id | user_id | created_at
----------+----------+-------------------------------
01a08a1c | 01a08a1c | 2026-09-10 06:59:02.332316+00
(1 row) ← 3 次 PUT 只留 1 行(验收②库层互证)
patbond=# select left(id::text,8) as comment_id, left(author_user_id::text,8) as author,
status, (reply_to_user_id is not null) as is_reply, left(content,24) as content
from community.comments where post_id='01a08a1c-…a474' order by created_at;
comment_id | author | status | is_reply | content
------------+----------+---------+----------+------------------------------
01a08a1c | 01a08a1c | deleted | f | B 的第一条评论(将被 B 自己删除)
01a08a1c | 01a08a1c | visible | t | B 的第二条评论(@A 回复… ← A 越权删除未生效
(2 rows)
patbond=# select left(follower_user_id::text,8) as follower,
left(followee_user_id::text,8) as followee
from community.user_follows where followee_user_id='01a08a1c-…294e';
follower | followee
----------+----------
01a08a1c | 01a08a1c
(1 row) ← 两次 PUT 只留 1 行(关注幂等库层互证)
patbond=# select count(*) as bookmarks from community.post_bookmarks
where post_id='01a08a1c-…a474';
bookmarks
-----------
0 ← 取消收藏后关系行已移除
```
验证点:
- `post_likes` / `user_follows` 各恰 1 行 → 重复 PUT 的幂等性在**库层**得到印证
(不只是响应终态一致)
- `comments` 中 c1 为 `deleted`B 自删生效)、c2 为 `visible`A 越权删除**未生效**
→ 「仅评论作者可删」拍板语义库层互证
- `post_media` 恒有唯一 `is_cover=t` 行;`media.assets``ready``sha256` 已照存
- **软删的内部记账**:被软删的已发布帖 `deleted_at` 非空且 `status` 被泊为 `archived`
——这是 `ck_posts_publish_state` 约束下的实现内部记账(`PostRepository.softDelete`
注释已写明 D3-7 定型),**任何响应都不会携带 `archived`**(场景 11 的四路 404/40403
即为佐证),故与契约「status 枚举保持两值」不冲突
---
## 5. M3 四条验收标准逐条对照
| # | 验收标准 | 证据 | 结论 |
| --- | --- | --- | --- |
| ① | **发布后可在另一客户端看到** | 场景 3 A 两步发布 → 场景 4 **B 的 token**`/api/v1/feed`A 的帖在首位,FeedCard 11 个必填键逐项断言(含 AuthorSummary 归因 A、coverImage 命中 assetId、mediaCount=1)、裁剪字段确认缺席、B 视角互动布尔为 false;§4 psql 证实 `published`/`deleted_at IS NULL` | ✓ 通过 |
| ② | **重复点赞不重复计数** | 场景 6 连续 3 次 PUT like,**三次响应均为权威终态 `{liked:true,likeCount:1}`200 非 409**;详情读回 likeCount=1DELETE→0;再 DELETE 幂等仍 0;§4 `community.post_likes` 全表恰 1 行 | ✓ 通过 |
| ③ | **分页不丢失不重复** | 场景 10 A 批量发 25 帖,同一 Feed 以 `limit=7`10 页)与 `limit=100`(1 页)**两次全量翻页,有序 id 列表逐位相等**;65 条零重复;本轮 26 个新 id 各恰好出现一次;末页 `nextCursor=null` 不变式;psql `feed_predicate_rows=65` 交叉核对相等;第二轮复跑在 91 条/13 页规模再次通过 | ✓ 通过 |
| ④ | **删除或隐藏内容不可继续出现在公共 Feed** | 场景 11 **删除前后各做一次全量翻页**(66→65,差集恰为该帖,其余一条不少);B 直接 GET 404/40403**作者 A 自查同样 404/40403 且响应体与 B 逐字节一致**;重复删除 404/40403;已删帖互动面 404/40403。隐藏(hidden/archived)态 M3 无端点可产生,其「对作者亦不露」由同一 `isVisible` 谓词与场景 12 的草稿路径共同覆盖 | ✓ 通过 |
> 验收标准 ④ 的「隐藏」半边说明:契约 v1.3.0 明确 **M3 无端点能产生或解除运营态**
> hidden/archived),故烟囱层面无法从 API 造出 hidden 帖。其不可见性由与软删共用的
> 同一可见性谓词保证,后端契约测试已覆盖(报告 15/20 的 40403 矩阵),且本次场景 11
> 证实了被泊为 `archived` 的软删行确实不出 Feed 也不出详情。
---
## 6. 契约偏差声明
**契约偏差数:0 个。**
本次烟囱对照冻结契约 openapi **v1.3.0**(报告 18 冻结)逐场景核验,全部一致、无需修复项:
| 核验面 | 本次实测覆盖 |
| --- | --- |
| HTTP 状态码 | 200 / 201 / 202 / 400 / 403 / 404 / 409 / 422 |
| 业务错误码 | 40000 / 40301 / 40403 / 40905 / 42204 |
| 信封结构 | `{code, message, data}` 全路径一致;VoidEnvelope 的 delete 200 |
| 分页正典形态 | `{items, nextCursor, hasMore}`;末页 `nextCursor` 恒 nullfeed / bookmarks / comments / me-posts 四处一致 |
| 幂等语义 | PUT/DELETE 语义幂等 + 权威终态;Idempotency-Key 必带 / 同键同 hash / 同键异 hash / 按作者隔离;complete 重复确认 |
| 乐观锁 | PATCH `version` 必带,提交比对通过后 +1(0→1) |
| 防枚举 | 草稿 vs 随机 UUID vs 已删帖,跨 5 条路径响应体逐字节一致 |
| 媒体两步上传 | requiredHeaders 键集、SigV4 query 签名、TTL、objectKey 服务端生成、私有桶无签名 403 |
| 埋点逐条结果 | accepted / rejected + reason 枚举;白名单外键剥离;字典外整条拒 |
| FeedCard 裁剪 | 必填 11 键在、裁剪 6 类字段缺席、AuthorSummary 不露 bio/username |
---
## 7. 观察项(非契约偏差,供 M4 拍板)
以下两项**不构成契约偏差**(响应形态、状态码、错误码均合规),但属实现与文档措辞的
落差 / 口径未定型,显式登记以免被「0 偏差」掩盖:
### 观察项 1`widthPx/heightPx` 实测恒为 `null`,契约描述写「complete 后回填」
- **实测**:场景 2 confirm 后 `widthPx=null, heightPx=null`(§3.2
- **契约**`MediaAsset.widthPx` 标注 `nullable: true`**响应形态合规**;但同字段
description 为「complete 后回填,可空」,暗示会回填
- **实现**`patbond-user` 的 media 包无任何图片尺寸探测(无 ImageIO 类调用),
两列永不写值
- **用户可见后果**`post_detail_page.dart` 的单图渲染在 `widthPx/heightPx` 为 null 时
回落固定 `4/3` 宽高比(客户端已正确处理 null,无崩溃),即 **M3 单图帖一律按 4:3 展示,
不呈现真实宽高比**
- **建议**:M4 二选一并同步落文——(a)complete 时做尺寸探测回填;
b)契约 description 改为「预留字段,M3 不回填」
### 观察项 2`eventVersion` 口径未定型(客户端恒发 1,两版 E2E 脚本各发 2 / 3)
- **契约**`TrackedEvent.eventVersion` 描述「事件 schema 版本(字典 v1 全部为 1)」
——可读作「每个事件自身的 schema 版本」,也可读作「事件字典版本」;服务端不校验取值
- **客户端**`lib/analytics/analytics_service.dart:139` 对**所有**事件硬编码
`'eventVersion': 1`
- **脚本**:M2 版脚本发 2、本 M3 版脚本发 3(沿 M2 先例按「字典版本」读法),
故 §3.13 落库的 `event_version=3` **不是客户端真实取值**
- **后果**:若下游分析以 `event_version` 区分字典世代,客户端上报的数据会全部落在 1
- **建议**:M4 明确口径并统一三方(契约描述、客户端、脚本);若采「每事件 schema 版本」
读法,则客户端恒 1 是对的,两版 E2E 脚本应改为 1,本报告的落库证据需按新口径复采
---
## 8. Flutter 门禁验证(三命令随行取证)
### 8.1 格式化检查
```bash
dart format --output=none --set-exit-if-changed lib test
# Formatted 144 files (0 changed) in 0.57 seconds.
# EXIT: 0
```
### 8.2 静态分析
```bash
flutter analyze
# Analyzing patbond-flutter...
# No issues found! (ran in 1.1s)
```
(含根目录三个 E2E 脚本在内全仓 0 issues;三脚本头部 `ignore_for_file: avoid_print`
新脚本刻意不引入 `crypto`/`collection` 包依赖——sha256 用实测常量、列表比较自带
7 行实现——以免触发 `depend_on_referenced_packages`。)
### 8.3 单元/组件测试
```bash
flutter test
# 00:28 +502 ~2: All tests passed!
```
**✓ 502 个测试全部通过**(2 skipped 为既有跳过项;E2E 脚本在仓库根目录,
不被 `flutter test` 收集)。
---
## 9. 环境清理
```bash
cd <工作区>/patbond-api && docker compose down
# Container patbond-community-1 / patbond-pet-1 / patbond-auth-1 /
# patbond-user-1 / patbond-minio-1 / patbond-postgres-1 Removed
# Network patbond_default Removed
```
> 卷(`pgdata` / `minio-data`)保留,故本次数据留在本机 compose 卷内;需要干净环境时
> `docker compose down -v` 清卷(协作规则:测试数据不入库,只在本机卷里)。
---
## 10. 工作仓库状态
- **patbond-flutter dev**`0e87413` `test: M3 E2E 烟囱脚本(T3-21 收官)` 已推送 origin/dev
- **patbond-api****代码零改动**(仅 compose 起停 + 只读 psql 查证)
- **patbond-doc**:本报告(28 号),提交与 mkdocs 导航由 M3 收官文档收口工单统一处理
---
## 11. 遗留清单
1. **真机四项待补验**(见 §0 显著标注):媒体上传弱网表现、乐观更新真机手感、
Feed 图片加载、社区事件落库(客户端链路)——步骤与通过标准已在
`docs/development/device-verification.md`「M3 预登记」备齐,设备到位后按方案 A
补验并在该文件「执行记录(M3)」追加证据。M2 遗留两项(Android 事件落库观察、
SessionTracker 30min 手测)同样未闭环。
2. **观察项两条**(§7):`widthPx/heightPx` 不回填、`eventVersion` 口径未定型,
建议在 M3 收官总结中登记为 M4 待拍板。
3. **本次烟囱未覆盖的契约面**(均有后端契约测试覆盖,非缺口):
- hidden/archived 运营态的产生路径(M3 无端点,见 §5 说明)
- media 的 422/42203(引用非 ready asset)与 42205(对象未上传即确认)失败分支
- 429 Retry-After 分支(后端限流未落地)
- `position` 全给/混合的 400 校验、9 图上限、caption 300 上限等参数边界
- user 服务故障时 AuthorSummary 退 id-only 的降级路径(需注入故障)
4. **E2E 脚本 CI 化**:三版脚本(M1/M2/M3)仍为手动验收工具,建议 M4 纳入 CI 定期
回归(compose 起停 + 脚本执行,失败即红),与前两迭代建议一致。
---
**Frontend Developer**
日期:2026-09-10
验收状态:**PASSED**14/14 场景,契约偏差 0,M3 四条验收标准全部通过;
观察项 2 条待拍板;真机四项挂起待补验)
@@ -0,0 +1,76 @@
# 29 M3 收官总结:社区
**迭代周期**:2026-09-08 ~ 2026-09-10(开工分析 + 四波交付)
**验收结论**:**PASSED**——E2E 烟囱 14/14、契约偏差 0、M3 四条验收标准逐条取证通过;真机四项按既定方案挂起待设备
---
## 0. 终态对照开工基线(07 号基线快照)
| 维度 | 开工基线(2026-09-08) | 收官终态(2026-09-10) |
| --- | --- | --- |
| patbond-api 测试 | 191 | **334**(+143) |
| patbond-flutter 测试 | 272 | **502**(+230) |
| openapi.yaml | v1.2.0,18 路径/24 操作/45 schema | **v1.3.0 冻结**,31 路径/43 操作/72 schema |
| 契约一致性矩阵 | 43 格(pets+auth 部分) | **173 格,43/43 操作,零漂移** |
| Flyway | V1~V4 | V1~V5(community 8 表 + pg_trgm) |
| 后端模块/端口 | common/auth:8081/user:8082/pet:8083 | + **patbond-community:8084** |
| 部署形态 | 四容器 | **六容器**(+MinIO 对象存储) |
| 媒体能力 | 有表无代码 | **两步上传闭环**(预签名直传 + 私有桶签名读) |
| 事件白名单 | 22 事件 | **42 事件**(+19 community 域 + experiment_exposed) |
| ADR | 001~015 | **001~021** |
| 社区功能 | Flutter demo 数据 | 三页全真实后端(home/详情/发布),demo 消亡 |
| 凭证防泄漏 | 无 | **9 规则两层检查三仓在线** |
## 1. 交付主线回顾
- **开工分析**(报告 01~08):8 角色并行;Reality Checker 给出其设立以来**首个 CERTIFIED 无条件放行**(M2 收官声称全部亲验命中、E2E 冷启动复跑 11/11);Evidence 证据链 100%;ADR-016~021 拍板
- **第一波**(09~14):V5 迁移(剪 2 条跨 schema FK) + community 骨架 + **MinIO 媒体闭环** + 埋点队列三项加固 + 凭证防泄漏三仓 + auth 契约测试补齐
- **第二波**(15~20):社区后端纵切 16 端点(帖子/Feed/评论/互动/关注) + **契约冻结 v1.3.0** + 快照同步与矩阵扩展(零漂移)
- **第三波**(21~27):Flutter 五单接入(数据层/媒体上传/Feed/详情互动/发布页) + 字典 v3;社区 demo 三页消亡
- **第四波**(28~29):E2E 烟囱 14 场景收官取证 + 文档收口
## 2. M3 四条验收标准证据索引(28 号报告)
| 标准 | 结论 | 取证强度 |
| --- | --- | --- |
| 发布后另一客户端可见 | ✓ | B token 拉 Feed 首位即 A 帖;FeedCard 11 必填键逐项断言 + 6 类裁剪字段确认缺席;另有 T3-17 双 App 实例真 UI 实测 |
| 重复点赞不重复计数 | ✓ | 3 次 PUT 均返权威终态 `{liked:true,likeCount:1}`;psql `post_likes` 恰 1 行库层互证;后端另有 4 线程真并发测试 |
| 分页不丢不重 | ✓ | 同一 Feed 以 limit=7(10 页)与 limit=100(1 页)两次全量翻页,**有序 id 列表逐位相等**;psql 谓词行数交叉核对 |
| 删除内容不出公共 Feed | ✓ | 删除前后各全量翻页(66→65,差集恰为该帖);B 与作者 A 直读均 404/40403 逐字节一致 |
## 3. 质量机制的兑现
- **契约测试累计抓修 3 处真实问题**:events 的 reason 字段误序列化 null(第一波 auth 矩阵)、校验器对 `nullable + allOf` 静默跳过的盲区(第二波矩阵扩展)、CreatePetRequest.sex 必填漂移(M2 期)
- **compose 实测抓出跨服务接线缺陷**:T3-13 发现 media 端点在 user:8082 而 T3-12 误挂 community:8084(真链路必 404),单测无法覆盖此类装配错误
- **widget 测试抓出两处真 bug**:Feed 尾部失败态被滚动自动重试冲掉、回前台不开新曝光段
- **多源核验**:一个 agent 识破 Monitor 的假 success 事件(时间戳晚于实时时钟),坚持以 Gitea API 多次直查为准
- **量级测算否决设计**:逐卡 Feed 曝光被埋点角色以「7~14 个月击穿分区阈值 + 接收端无限流背压」否决,改聚合 `feed_viewed`,并在后端字典层把 `post_impression` 等 7 事件锁死为 unknown
## 4. 遗留与 M4 建议
**真机挂起四项**(步骤已在 [真机验证清单](../../device-verification.md) 备齐):媒体上传弱网、乐观更新手感、Feed 图片加载、社区事件落库。**时限提醒**:埋点角色建议 2026-09-21(北极星首次出数日)前完成 M2 两项,否则首批读数只能标未验收。
**M3 范围内遗留**:
1. 完整草稿列表与自动保存(26 号 §7);大图「下滑关闭」手势待 photo_view 复评
2. `widthPx/heightPx` 恒 null(28 号观察项 1):契约描述称 confirm 后回填,实现无尺寸探测——单图帖一律回落 4:3,不呈现真实宽高比;补实现或改契约描述二选一
3. `eventVersion` 口径未定型(28 号观察项 2):契约描述可两读、服务端不校验、客户端硬编码 1;需定型为「事件 schema 版本」并写入字典纪律
4. uploading 超时未确认 asset 的清理定时任务(13 号已有方案未实现)
5. 429 限流未实现,连带客户端 Retry-After 精细分支挂起(承自 09 号出入清单)
6. 话题功能(ADR-018 剪出)、关注列表/作者主页(裁剪项)
**跨迭代遗留**(承自 M1/M2,未变化):access token 黑名单、`/internal` 改 mTLS。
**M4 方向输入**(开发计划 M4:AI 创作):
- 模型/风格目录、生成任务创建/查询/取消、Worker 队列消费(租约/重试/幂等键)、输出写媒体表后一键建社区草稿——**媒体链路与社区草稿两端已在 M3 就位**,M4 可直接复用
- V5 已为 `posts.generation_job_id` 留裸列,M4 迁移补回该外键即可打通 AI 产出→社区发布
- create 页的 AI 生成模拟(M3 刻意零改动)是 M4 的替换目标
- A/B 前置 8 项:M3 末 6 项全绿 + 1 项部分绿(feature flag 随社区发布开关落地),M4 可启动首个实验;北极星「7 日回访记录率」复评点为 H7 读数
## 5. 收官提交索引
| 仓库 | 收官 HEAD | 测试 |
| --- | --- | --- |
| patbond-api | dev@8089c06 | 334 |
| patbond-flutter | dev@0e87413 | 502 |
| patbond-doc | 本收口提交 | strict 通过 |
@@ -0,0 +1,58 @@
# 第三迭代进展看板
> 目标:M3 社区——图片媒体上传闭环 + 帖子草稿/发布/删除 + 公共 Feed 游标分页 + 单层评论 + 点赞/收藏幂等 + 关注最小接口 + Flutter 三页替换 demo 与乐观更新回滚,依据[开发实施计划](../../development-plan.md) M3 节。
> 更新日期:2026-09-10(**M3 收官,验收 PASSED**)。本页是团队共享的进度事实来源。
## 当前状态一览
| 状态 | 内容 |
| --- | --- |
| ✅ 第一波 | V5 community 迁移 + patbond-community 骨架 + **MinIO 媒体闭环**(ADR-016/017)+ 埋点队列三项加固 + 凭证防泄漏三仓 + auth 契约测试 |
| ✅ 第二波 | 社区后端 16 端点纵切 + **契约冻结 v1.3.0** + 快照同步与契约矩阵 173 格零漂移 |
| ✅ 第三波 | Flutter 五单接入(数据层/媒体上传/Feed/详情互动/发布页)+ 字典 v3;社区 demo 三页消亡 |
| ✅ 第四波 | E2E 烟囱 **14/14**、契约偏差 **0**、M3 四条验收标准逐条取证(报告 28);收官总结见报告 29 |
| ⚠️ 遗留 | 真机四项挂起(清单已备齐步骤)、widthPx/heightPx 恒 null、eventVersion 口径未定型、uploading 清理任务、429 限流——完整清单见报告 29 §4 |
## 测试与契约演进
| 时点 | patbond-api | patbond-flutter | openapi.yaml |
| --- | --- | --- | --- |
| M3 开工基线 | 191 | 272 | v1.2.0(18 路径) |
| 第一波收口 | 226 | 286 | v1.2.0 |
| 第二波收口 | 325 | 286 | **v1.3.0 冻结**(31 路径/43 操作/72 schema) |
| 第三波收口 | 334 | 502 | v1.3.0(矩阵 173 格零漂移) |
| **收官** | **334** | **502**(+E2E 脚本) | v1.3.0(E2E 逐场景核验偏差 0) |
## 已完成(附提交)
**开工分析(报告 01~08)**:8 角色并行评估;Reality Checker 首个 **CERTIFIED** 无条件放行;对象存储选型经用户拍板定为自托管 MinIO 起步(ADR-016,预留迁云);ADR-016~021 入档(`patbond-doc@d286782`)。
**第一波(报告 09~14)**
- V5 community 8 表 + pg_trgm,剪 2 条跨 schema FK(`posts.generation_job_id`→M4、`posts.region_id`→M5 补回)(`patbond-api@a97814a`)。
- patbond-community:8084 骨架,骨架期即接 RS256 校验(`3c671fc`)。
- **MinIO 媒体闭环**:ObjectStorage 适配层 + 两步上传(预签名 PUT 直传 → confirm ready)+ 私有桶预签名 GET(`10a43f8`)。
- auth 域契约测试补齐,首轮抓修 events `reason` 序列化漂移(`263cd88`)。
- 埋点队列三项:30s 定时冲刷 / 指数退避 / anonymousId 持久化(`patbond-flutter@4d40c38`)。
- 凭证防泄漏 9 规则两层检查三仓落地(`api@8330885`/`flutter@66f983d`/`doc@8e1fe2f`)。
**第二波(报告 15~20)**
- 帖子生命周期 5 端点 + 幂等 + 防枚举 40403(`101ac0f`);Feed + AuthorSummary + `/internal` 批量资料 + Feign 降级(`40bac85`/`99a3c1f`);评论/互动/关注 11 端点 + 真并发幂等 + 计数同事务(`19e8cba`/`7f1dd33`)。
- **契约冻结 v1.3.0**:26 项草案修正照单全收(`patbond-doc@f848476`);四模块快照同步 + community 64 格 + media 8 格矩阵,修 `nullable+allOf` 校验盲区(`0569585`)。
**第三波(报告 21~27)**
- community 数据层 19 操作 + **ToggleSync** 乐观更新状态机(`19bd8c1`);MediaUploader 六态 + 孤儿防护(`1441f01`,并抓修 media 端点错挂服务的缺陷);Feed 四态 + 曝光聚合 + SignedNetworkImage(`8aac8c5`);详情页 + 互动跨页一致 + 三层视觉抑制(`92524da`/`f873acf`);发布页两步发布 + 草稿两路径 + 漏斗埋点(`9892b65`)。
- 字典 v3 白名单 22→42 事件,7 个被否决事件锁死(`api@8089c06`)。
**第四波(报告 28~29)**
- E2E 烟囱脚本 `test_e2e_m3_manual.dart`,14 场景一次通过、复跑再次通过(`flutter@0e87413`)。
## 相关文档
- [后端模块结构与职责](../../../architecture/backend-modules.md)
- [技术决策记录](../../../architecture/decisions.md)(ADR-016~021 为 M3 决策)
- [真机验证清单](../../device-verification.md)(M3 四项步骤已备齐)
- 契约:`docs/api/openapi.yaml` v1.3.0(冻结纪律见报告 18)
+3
View File
@@ -64,6 +64,7 @@ nav:
- 29 M2 收官总结: development/iterations/iteration-2/29-m2-summary.md - 29 M2 收官总结: development/iterations/iteration-2/29-m2-summary.md
- 30 真机补验清单: development/iterations/iteration-2/30-device-verification-checklist.md - 30 真机补验清单: development/iterations/iteration-2/30-device-verification-checklist.md
- 第三迭代: - 第三迭代:
- 进展看板: development/iterations/iteration-3/index.md
- 01 任务分解: development/iterations/iteration-3/01-pm-task-breakdown.md - 01 任务分解: development/iterations/iteration-3/01-pm-task-breakdown.md
- 02 后端技术评估: development/iterations/iteration-3/02-backend-technical-assessment.md - 02 后端技术评估: development/iterations/iteration-3/02-backend-technical-assessment.md
- 03 Flutter 技术评估: development/iterations/iteration-3/03-flutter-technical-assessment.md - 03 Flutter 技术评估: development/iterations/iteration-3/03-flutter-technical-assessment.md
@@ -91,6 +92,8 @@ nav:
- 25 详情页与互动: development/iterations/iteration-3/25-detail-interactions-report.md - 25 详情页与互动: development/iterations/iteration-3/25-detail-interactions-report.md
- 26 发布页与漏斗: development/iterations/iteration-3/26-publish-page-report.md - 26 发布页与漏斗: development/iterations/iteration-3/26-publish-page-report.md
- 27 第三波收口: development/iterations/iteration-3/27-wave3-closure.md - 27 第三波收口: development/iterations/iteration-3/27-wave3-closure.md
- 28 E2E 烟囱收官: development/iterations/iteration-3/28-e2e-smoke-report.md
- 29 M3 收官总结: development/iterations/iteration-3/29-m3-summary.md
- API: - API:
- 契约说明: api/index.md - 契约说明: api/index.md
- 架构: - 架构: