# 28 M3 收官:E2E 烟囱测试报告(T3-21) - 执行人:Frontend Developer - 日期:2026-09-10 - 环境:patbond-flutter (dev 分支) + patbond-api(docker 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 字符 + `` - **全部预签名 URL(PUT 直传与 GET 读取)的签名 query 整体替换为 ``**, 仅保留 host + 对象路径 - 密码不出现在任何输出;`.env` 内容、MinIO 根凭据、`PATBOND_INTERNAL_TOKEN` 均未引用 - 幂等键值以 `` 占位打印 - 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 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 容器健康状态(六容器) ```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_` / `e2e_m3_b_` 避免冲突 - 固定测试图内嵌为常量: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... POST /api/v1/auth/register (B) → 200 ✓ B 注册成功 userId(B): 01a08a1c-d720-744e-a0da-589efb1bc1bf accessToken(B): eyJhbGciOiJSUzI1NiJ9... ✓ 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? method: PUT / expiresAt: 2026-09-10T07:09:01.176666130Z requiredHeaders: {Content-Type: image/jpeg} ✓ requiredHeaders 恒且仅一键 {Content-Type: image/jpeg} ✓ 预签名 URL 携带 SigV4 query 签名族(直传不经应用服务器) PUT (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? POST .../complete(重复确认)→ 200 ✓ 已 ready 重复 complete 幂等 200 同一 asset(现签新 GET URL) ``` 契约验证:`kind/purpose/mimeType/byteSize` 白名单通过;objectKey 由服务端生成 (`post_image/2026/09/`,不含任何用户输入);`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=published,publishedAt 已写,version 0→1 publishedAt: 2026-09-10T06:59:02.144899Z media[0].url: http://127.0.0.1:9000/patbond-media/…? ``` 契约验证:两步发布定型(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? GET → 200(344 字节) ✓ 预签名 GET 取回 200 ✓ 取回 344 字节与上传逐字节一致(内容往返无损) GET <同一对象但去掉签名> → 403 ✓ 桶保持私有:无签名直访被存储侧拒绝(403) ``` 契约验证:读取一律预签名 GET;**内容往返逐字节无损**;桶保持私有——去掉签名 query 后同一对象 403(契约「无签名直访被拒」原文兑现)。 ### 3.6 场景 6:【验收②】B 重复点赞不重复计数 ```text [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` 含该帖;取消后不含 ```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、无 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 ```text [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 游标分页不丢不重 ```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 逐条 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)**: ```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=, 首发) → 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) ```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=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 格式化检查 ```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 条待拍板;真机四项挂起待补验)