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

41 KiB
Raw Blame History

28 M3 收官:E2E 烟囱测试报告(T3-21)

  • 执行人:Frontend Developer
  • 日期:2026-09-10
  • 环境:patbond-flutter (dev 分支) + patbond-apidocker compose 六容器编排,代码零改动
  • 测试脚本:patbond-flutter/test_e2e_m3_manual.dartcommit 0e87413,已推送 origin/dev
  • 参照模式:iteration-2/28 号 M2 收官报告(格式与取证标准沿用)
  • 冻结契约:patbond-doc/docs/api/openapi.yaml v1.3.0community / 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 format144 files, 0 changed/ flutter analyze No issues/ flutter test502 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/eventsplatform=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 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 容器健康状态(六容器)

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…6973ce8sha256sum 实测值硬编码,不引入 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→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 发布

[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 全不给时 按数组序落 0isCover 全 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 非 nullassetId 命中场景 2 的 assetisCover=trueurl 现签非空
视角字段 B 视角 likedByMe=falsebookmarkedByMe=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> → 200344 字节)
  ✓ 预签名 GET 取回 200
  ✓ 取回 344 字节与上传逐字节一致(内容往返无损)
  GET <同一对象但去掉签名> → 403
  ✓ 桶保持私有:无签名直访被存储侧拒绝(403)

契约验证:读取一律预签名 GET内容往返逐字节无损;桶保持私有——去掉签名 query 后同一对象 403(契约「无签名直访被拒」原文兑现)。

3.6 场景 6:【验收②】B 重复点赞不重复计数

[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 含该帖;取消后不含

[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、无 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 不带 updatedAtM3 无评论编辑)。

3.9 场景 9:关注与 follow-stats;自关注 42204;自取关 200 no-op

[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 为实时 COUNTA/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 卷内有前几波留下的既有帖),而用三重强断言:

  1. 零重复limit=7 逐页翻到底共 65 条,去重后仍 65 条
  2. 零遗漏:本轮 25 帖 + 主贴的 26 个 id 在全量结果中各恰好出现一次
  3. 粒度不变性:同一 Feed 分别以 limit=710 页)与 limit=100(1 页)全量翻完, 两份有序 id 列表逐位相等——若 keyset 游标在页边界丢行或重行,两种粒度必然分叉

另有末页不变式随行断言:hasMore=falsenextCursor 恒为 nullhasMore=truenextCursor 必非 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 逐条 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

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=androiduser_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:幂等重放

[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

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 为 deletedB 自删生效)、c2 为 visibleA 越权删除未生效) → 「仅评论作者可删」拍板语义库层互证
  • post_media 恒有唯一 is_cover=t 行;media.assetsreadysha256 已照存
  • 软删的内部记账:被软删的已发布帖 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/feedA 的帖在首位,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=710 页)与 limit=1001 页)两次全量翻页,有序 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 必带,提交比对通过后 +10→1
防枚举 草稿 vs 随机 UUID vs 已删帖,跨 5 条路径响应体逐字节一致
媒体两步上传 requiredHeaders 键集、SigV4 query 签名、TTL、objectKey 服务端生成、私有桶无签名 403
埋点逐条结果 accepted / rejected + reason 枚举;白名单外键剥离;字典外整条拒
FeedCard 裁剪 必填 11 键在、裁剪 6 类字段缺席、AuthorSummary 不露 bio/username

7. 观察项(非契约偏差,供 M4 拍板)

以下两项不构成契约偏差(响应形态、状态码、错误码均合规),但属实现与文档措辞的 落差 / 口径未定型,显式登记以免被「0 偏差」掩盖:

观察项 1widthPx/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 不回填」

观察项 2eventVersion 口径未定型(客户端恒发 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 dev0e87413 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 条待拍板;真机四项挂起待补验)