Files
patbond-doc/docs/development/iterations/iteration-3/28-e2e-smoke-report.md
T
lixi 3ebe562ab7
CI / docs-build (push) Successful in 38s
docs: M3 收官——E2E 报告与收官总结入档,验收 PASSED
- 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>
2026-09-10 15:13:18 +08:00

758 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 条待拍板;真机四项挂起待补验)