- 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>
41 KiB
28 M3 收官:E2E 烟囱测试报告(T3-21)
- 执行人:Frontend Developer
- 日期:2026-09-10
- 环境:patbond-flutter (dev 分支) + patbond-api(docker compose 六容器编排,代码零改动)
- 测试脚本:
patbond-flutter/test_e2e_m3_manual.dart(commit0e87413,已推送 origin/dev) - 参照模式:iteration-2/28 号 M2 收官报告(格式与取证标准沿用)
- 冻结契约:
patbond-doc/docs/api/openapi.yamlv1.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_eventspsql 逐项查证一致;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 代码零改动)
命令中的
<工作区>为你本机存放三仓的父目录(文档不写死本机路径)。
cd <工作区>/patbond-api
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw -DskipTests package
# BUILD SUCCESS(exit 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 容器健康状态(六容器)
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 服务就绪验证
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.
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
本次取证运行(第一轮)关键标识:
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)
[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
[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→ready(byteSize=344,widthPx=null heightPx=null,readyAt 已写,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 发布
[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=published,publishedAt 已写,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 字段完整
[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 取回图片字节与上传一致
[5/14] 预签名 GET 取回图片字节 → 与上传字节逐字节比对
GET http://127.0.0.1:9000/patbond-media/post_image/2026/09/01a08a1c-…eb67?<SIGNATURE_REDACTED>
GET <presigned> → 200(344 字节)
✓ 预签名 GET 取回 200
✓ 取回 344 字节与上传逐字节一致(内容往返无损)
GET <同一对象但去掉签名> → 403
✓ 桶保持私有:无签名直访被存储侧拒绝(403)
契约验证:读取一律预签名 GET;内容往返逐字节无损;桶保持私有——去掉签名 query 后同一对象 403(契约「无签名直访被拒」原文兑现)。
3.6 场景 6:【验收②】B 重复点赞不重复计数
[6/14] 【验收②】B 连续 3 次 PUT like → likeCount 恰为 1;DELETE → 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=1(3 次 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 场景 7:B 收藏 + /me/bookmarks 含该帖;取消后不含
[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 的被拒
[8/14] B 评论 ×2 → A 拉列表可见;B 删自己评论成功;A(帖主)删 B 的评论被拒
POST /comments (B, #1) → 201
✓ 评论作者归因 B、postId 一致、非回复 replyToUser=null、无 updatedAt(M3 无编辑)
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
[9/14] B 关注 A(PUT)+ 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 查自己恒 false;followerCount/followingCount
为实时 COUNT,A/B 双向视角互证。
3.10 场景 10:【验收③】Feed 游标分页不丢不重
[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 卷内有前几波留下的既有帖),而用三重强断言:
- 零重复:
limit=7逐页翻到底共 65 条,去重后仍 65 条 - 零遗漏:本轮 25 帖 + 主贴的 26 个 id 在全量结果中各恰好出现一次
- 粒度不变性:同一 Feed 分别以
limit=7(10 页)与limit=100(1 页)全量翻完, 两份有序 id 列表逐位相等——若 keyset 游标在页边界丢行或重行,两种粒度必然分叉
另有末页不变式随行断言:hasMore=false 时 nextCursor 恒为 null;hasMore=true 时
nextCursor 必非 null(否则脚本立即 fail 而非静默挂死)。
psql 交叉核对(脚本无库访问,独立第三方证据):
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
[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
[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 社区事件上报与落库
[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 逐条 accepted(accepted=8, duplicated=0, rejected=0)
POST /api/v1/events(post_liked 混入白名单外 postId)→ 202
✓ 白名单外键剥离后事件仍 accepted(隐私红线 ingest 侧兜底,落库无 postId)
POST /api/v1/events(字典外 post_impression)→ 202
✓ 字典外事件整条 rejected(reason=unknown_event_name),批次仍 202
E2E_SESSION_ID=8857898d-0ce0-4a57-bda6-bccac98b57b6
落库查证(docker exec psql):
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)
三条链路同时取证:
- 正常落库:8 条 v3 社区事件(feed / 媒体 / 发布漏斗 / 互动 / 关注 / page_viewed)
全部落
platform.product_events,props 键集与字典 v3 白名单(报告 22)逐键一致,platform=android、user_id归因 B - 隐私红线 ingest 侧兜底:
post_liked混入白名单外postId→ 事件 accepted 但 落库 props 仅{"source":"feed"},postId 已被剥离(第 6 行,event_id9884703f) - 字典边界锁死:被否决的逐卡曝光事件
post_impression整条rejected / reason=unknown_event_name,且未出现在落库结果的 9 行中
(真机端上链路见 §0 挂起项 4。)
3.14 场景 14:幂等重放
[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/posts(B 用同一 KEY-1 值)→ 201
✓ 幂等键按作者隔离:B 用同一键值创建出新帖(id 不同)
契约验证:同键重试返回首次创建的资源且同样 201(契约原文);id/version 逐项
一致;通过 /me/posts 反查确认库内未产生第二行(不只是响应看起来一样);
同键异 hash → 409/40905;该头缺失 → 400/40000;键按作者隔离(B 用同一键值
创建出独立新帖,跨用户不串号)。
4. 数据库查询证据(community / media schema)
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=1;DELETE→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 恒 null;feed / 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 格式化检查
dart format --output=none --set-exit-if-changed lib test
# Formatted 144 files (0 changed) in 0.57 seconds.
# EXIT: 0
8.2 静态分析
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 单元/组件测试
flutter test
# 00:28 +502 ~2: All tests passed!
✓ 502 个测试全部通过(2 skipped 为既有跳过项;E2E 脚本在仓库根目录,
不被 flutter test 收集)。
9. 环境清理
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:
0e87413test: M3 E2E 烟囱脚本(T3-21 收官)已推送 origin/dev - patbond-api:代码零改动(仅 compose 起停 + 只读 psql 查证)
- patbond-doc:本报告(28 号),提交与 mkdocs 导航由 M3 收官文档收口工单统一处理
11. 遗留清单
- 真机四项待补验(见 §0 显著标注):媒体上传弱网表现、乐观更新真机手感、
Feed 图片加载、社区事件落库(客户端链路)——步骤与通过标准已在
docs/development/device-verification.md「M3 预登记」备齐,设备到位后按方案 A 补验并在该文件「执行记录(M3)」追加证据。M2 遗留两项(Android 事件落库观察、 SessionTracker 30min 手测)同样未闭环。 - 观察项两条(§7):
widthPx/heightPx不回填、eventVersion口径未定型, 建议在 M3 收官总结中登记为 M4 待拍板。 - 本次烟囱未覆盖的契约面(均有后端契约测试覆盖,非缺口):
- hidden/archived 运营态的产生路径(M3 无端点,见 §5 说明)
- media 的 422/42203(引用非 ready asset)与 42205(对象未上传即确认)失败分支
- 429 Retry-After 分支(后端限流未落地)
position全给/混合的 400 校验、9 图上限、caption 300 上限等参数边界- user 服务故障时 AuthorSummary 退 id-only 的降级路径(需注入故障)
- E2E 脚本 CI 化:三版脚本(M1/M2/M3)仍为手动验收工具,建议 M4 纳入 CI 定期 回归(compose 起停 + 脚本执行,失败即红),与前两迭代建议一致。
Frontend Developer 日期:2026-09-10 验收状态:PASSED(14/14 场景,契约偏差 0,M3 四条验收标准全部通过; 观察项 2 条待拍板;真机四项挂起待补验)