From 3ebe562ab754d7a7be0827f959682af3d548622d Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Thu, 10 Sep 2026 15:13:18 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20M3=20=E6=94=B6=E5=AE=98=E2=80=94?= =?UTF-8?q?=E2=80=94E2E=20=E6=8A=A5=E5=91=8A=E4=B8=8E=E6=94=B6=E5=AE=98?= =?UTF-8?q?=E6=80=BB=E7=BB=93=E5=85=A5=E6=A1=A3=EF=BC=8C=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=20PASSED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docs/development/feature-checklist.md | 53 +- .../iteration-3/28-e2e-smoke-report.md | 757 ++++++++++++++++++ .../iterations/iteration-3/29-m3-summary.md | 76 ++ .../iterations/iteration-3/index.md | 58 ++ mkdocs.yml | 3 + 5 files changed, 945 insertions(+), 2 deletions(-) create mode 100644 docs/development/iterations/iteration-3/28-e2e-smoke-report.md create mode 100644 docs/development/iterations/iteration-3/29-m3-summary.md create mode 100644 docs/development/iterations/iteration-3/index.md diff --git a/docs/development/feature-checklist.md b/docs/development/feature-checklist.md index 8d3132b..920a8c5 100644 --- a/docs/development/feature-checklist.md +++ b/docs/development/feature-checklist.md @@ -2,7 +2,7 @@ > 目的:直观呈现哪些功能**已完成且有自动化测试**、哪些**部分完成**、哪些**尚未开始**,方便针对性验证与回归。 > 维护约定:每波工单合入后由执行人更新本清单;状态以 `dev` 分支 + 门禁全绿为准。 -> 最后更新:2026-09-08(M2 第三波收口:patbond-api `64c9b72` 191 测试、patbond-flutter `ba50332` 272 测试,均门禁全绿;三次 compose 实测无契约偏差;M2 条目见第 7~9 节) +> 最后更新:2026-09-10(M3 收官:patbond-api `8089c06` 334 测试、patbond-flutter `0e87413` 502 测试,均门禁全绿;E2E 烟囱 14/14 契约偏差 0;M2 条目见第 7~9 节,M3 见第 10~12 节) 图例:✅ 已完成且已测试 | 🟡 部分完成/有已知限制 | ⬜ 未开始 @@ -169,5 +169,54 @@ curl -s -i http://127.0.0.1:8082/internal/users/by-username/demo_user | head -1 | 事件字典 v2 白名单(pet 域 3 + health_record 域 7) | ✅ | 后端白名单 + 边界测试(api@64c9b72);page_viewed 正稿核对零修正 | | 客户端挂接:pet 域 3 事件 + health_record 域 6 事件 + pet_form 等页名 | ✅ | 强类型封装(pet_analytics/health_record_analytics);deleted 留待删除端点 | | Android 真机落库验证 + SessionTracker 30min 手测 | 🟡 | 桌面端全链路已通(platform 枚举拒绝属契约内);真机验证按方案 A 挂起待设备 | -| 队列完善:30s 定时冲刷、退避/429、anonymousId 持久化 | ⬜ | 报告 15 §4 遗留;429 依赖后端限流实现(报告 09 出入清单) | +| 队列完善:30s 定时冲刷、退避、anonymousId 持久化 | ✅ | **M3 第一波交付**(iteration-3/10,flutter@4d40c38);429 Retry-After 精细分支仍待后端限流 | + +--- + +# M3 社区(第三迭代) + +## 10. 社区域后端(patbond-community :8084 + media in user) + +| 功能 | 状态 | 自动化测试 | 说明 | +| --- | --- | --- | --- | +| Flyway V5 community 8 表 + pg_trgm 扩展 | ✅ | 迁移验证 8 例(干净库 V1→V5) | 剪 2 条跨 schema FK:`posts.generation_job_id`(M4 补回)、`posts.region_id`(M5 补回),裸列与索引保留 | +| patbond-community 独立模块(ADR-017)挂 pom + compose | ✅ | 骨架 7 例(含鉴权 5) | 骨架期即接 RS256 校验(无 token/畸形/错签/过期均 401+40101);只读写 community schema | +| **media 两步上传闭环**(ADR-016/017) | ✅ | 12 例(MinIO Testcontainer 全链路 + 六类失败) | 创建 upload 签预签名 PUT(10min)→ confirm ready → 私有桶预签名 GET(1h);post_image / jpeg·png·webp / 10 MiB;ObjectStorage 适配层隔离供应商 | +| 帖子生命周期 5 端点(草稿/编辑/发布/软删/详情/我的列表) | ✅ | 25 例(含真双线程并发 PATCH 恰一胜) | 发布走 PATCH draft→published;Idempotency-Key 必带 + request_hash(40905);防枚举 404/40403 逐字节一致(hidden 对作者亦不露) | +| 公共 Feed 游标分页 + FeedCard | ✅ | 分页专项 8 + 卡片定型 5 | `(published_at,id)` 游标对齐 `ix_posts_feed`;contentPreview 200 码点截断;三计数走冗余列写侧同事务维护 | +| 作者公开资料链路(D3-9 方案 B) | ✅ | 内部端点 8 + 作者链路 7 + Feign 线路 3 | user 增 `/internal/users/profiles`(≤50 批量,不入公网契约)→ community Feign + 60s 缓存;**user 故障时作者退 id-only、Feed 照常 200** | +| 单层评论(列表/创建/删除) | ✅ | 11 例 | **仅评论作者可删**(用户拍板,帖主不可删他人评论);40404/40406 | +| 点赞/收藏 PUT+DELETE 幂等 + 我的收藏 | ✅ | 真并发(4 线程 PUT 恰计 1 行 1 计数)+ 对账专项 | 响应回权威终态 `{liked,likeCount}`;互动面 = 帖子公开面(作者本人草稿亦 404) | +| 关注 PUT/DELETE + follow-stats | ✅ | 3 线程并发 follow 恰 1 行 | 自关注 42204(仅 PUT);自取关 200 幂等 no-op | +| 契约冻结 v1.3.0 + 快照矩阵 173 格 | ✅ | community 64 格 + media 8 格 + mutation 自证 | 43/43 操作零漂移;四模块字节级快照,升版须同步否则 CI 红;修 `nullable+allOf` 校验盲区 | +| uploading 超时未确认 asset 清理任务 | ⬜ | — | 方案在 iteration-3/13,定时任务另排 | +| 429 限流 | ⬜ | — | 承自 iteration-2/09 出入清单;连带客户端 Retry-After 分支挂起 | +| 话题 / 关注列表 / 作者主页 | ⬜ | — | ADR-018 裁剪出 M3(topics 表已建) | + +## 11. 社区域客户端(patbond-flutter) + +| 功能 | 状态 | 说明 | +| --- | --- | --- | +| community 数据层(契约 19 操作全覆盖,T3-12) | ✅ | 9 新错误码类型化;未知枚举抛 FormatException 暴露漂移;CursorPage 上移 core 供两域复用 | +| **ToggleSync 乐观更新状态机** | ✅ | 乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫 + 服务端权威终态收敛;竞态时序 controller 级单测 | +| MediaUploader 六态编排(T3-13) | ✅ | 选图→压缩→预签名直传→confirm;29 项专项(降质阶梯/凭据过期重取/多图并发保序/孤儿防护) | +| 首页 Feed 真实数据 + 四态 + 尾部三态(T3-14) | ✅ | 下拉刷新 + 触底游标翻页;PostCard 三形态/PostMediaGrid/LikeButton/FeedSkeleton 入 core;**SignedNetworkImage 剥离签名参数做缓存 key** | +| 帖子详情整页替换 + 评论区(T3-15) | ✅ | 真九宫格 + InteractiveViewer 大图;仅本人评论渲染删除入口;40403 返回 Feed 并刷新;关注双态钮 | +| 互动接线 + 三层视觉抑制(T3-16) | ✅ | 240ms 弹性激活 / 失败零动画跳变 + SnackBar / 对账静默替换;Feed 与详情共享 ToggleSync 同帧一致;减弱动态降级 | +| 发布页 PostComposePage(T3-17) | ✅ | 两步发布 createPost(draft)→PATCH published(「发布失败但草稿已保存」为事实);gating 双保险;失败三语义(40905/42203/网络同键重放);草稿两路径 + 进页恢复 | +| 社区 demo 数据消亡 | ✅ | `AppState.posts/publishPost/updatePost` 及持久化整体退役;create 页仅余 M4 的 AI 生成模拟 | +| 完整草稿列表 / 自动保存 | 🟡 | 最小实现(进页恢复最新一条);完整管理留待(报告 26 §7) | +| 大图「下滑关闭」手势 | 🟡 | InteractiveViewer 手势冲突,待 photo_view 复评 | +| `widthPx/heightPx` 真实宽高比 | 🟡 | 服务端恒 null(E2E 观察项 1),单图帖一律回落 4:3 | + +## 12. 埋点体系(M3 演进) + +| 功能 | 状态 | 说明 | +| --- | --- | --- | +| 队列三项加固(30s 定时冲刷 / 指数退避 / anonymousId 持久化) | ✅ | 13 号规范四触发点补齐;退避 30s→5min 只挡定时冲刷;anonymousId 跨冷启动稳定(A/B 前置 #4) | +| 事件字典 v3 白名单(community 域 19 + experiment_exposed) | ✅ | EventDictionary 22→42;**7 个被否决事件锁死 unknown**(含逐卡曝光 post_impression) | +| 客户端挂接 21 事件 | ✅ | feed 2(聚合 feed_viewed:≥50% 可见 ≥500ms、段内去重、离开结算)+ 互动 8 + 媒体三段 3 + 发布漏斗 5 + page_viewed 页名增量 | +| 逐卡 Feed 曝光 | ⬜ | ADR-020 否决(量级测算 7~14 个月击穿分区阈值 + 无背压);留 backlog 待 M4+ 服务端下发日志 | +| Android 真机验证(M2 两项 + M3 四项) | 🟡 | 步骤全部备齐在[真机验证清单](device-verification.md);桌面 `platform=linux` 整批 400 属契约内,落库只能真机验 | +| `eventVersion` 口径定型 | ⬜ | 契约描述可两读、服务端不校验、客户端硬编码 1(E2E 观察项 2) | diff --git a/docs/development/iterations/iteration-3/28-e2e-smoke-report.md b/docs/development/iterations/iteration-3/28-e2e-smoke-report.md new file mode 100644 index 0000000..58b1e98 --- /dev/null +++ b/docs/development/iterations/iteration-3/28-e2e-smoke-report.md @@ -0,0 +1,757 @@ +# 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 条待拍板;真机四项挂起待补验) diff --git a/docs/development/iterations/iteration-3/29-m3-summary.md b/docs/development/iterations/iteration-3/29-m3-summary.md new file mode 100644 index 0000000..c950184 --- /dev/null +++ b/docs/development/iterations/iteration-3/29-m3-summary.md @@ -0,0 +1,76 @@ +# 29 M3 收官总结:社区 + +**迭代周期**:2026-09-08 ~ 2026-09-10(开工分析 + 四波交付) +**验收结论**:**PASSED**——E2E 烟囱 14/14、契约偏差 0、M3 四条验收标准逐条取证通过;真机四项按既定方案挂起待设备 + +--- + +## 0. 终态对照开工基线(07 号基线快照) + +| 维度 | 开工基线(2026-09-08) | 收官终态(2026-09-10) | +| --- | --- | --- | +| patbond-api 测试 | 191 | **334**(+143) | +| patbond-flutter 测试 | 272 | **502**(+230) | +| openapi.yaml | v1.2.0,18 路径/24 操作/45 schema | **v1.3.0 冻结**,31 路径/43 操作/72 schema | +| 契约一致性矩阵 | 43 格(pets+auth 部分) | **173 格,43/43 操作,零漂移** | +| Flyway | V1~V4 | V1~V5(community 8 表 + pg_trgm) | +| 后端模块/端口 | common/auth:8081/user:8082/pet:8083 | + **patbond-community:8084** | +| 部署形态 | 四容器 | **六容器**(+MinIO 对象存储) | +| 媒体能力 | 有表无代码 | **两步上传闭环**(预签名直传 + 私有桶签名读) | +| 事件白名单 | 22 事件 | **42 事件**(+19 community 域 + experiment_exposed) | +| ADR | 001~015 | **001~021** | +| 社区功能 | Flutter demo 数据 | 三页全真实后端(home/详情/发布),demo 消亡 | +| 凭证防泄漏 | 无 | **9 规则两层检查三仓在线** | + +## 1. 交付主线回顾 + +- **开工分析**(报告 01~08):8 角色并行;Reality Checker 给出其设立以来**首个 CERTIFIED 无条件放行**(M2 收官声称全部亲验命中、E2E 冷启动复跑 11/11);Evidence 证据链 100%;ADR-016~021 拍板 +- **第一波**(09~14):V5 迁移(剪 2 条跨 schema FK) + community 骨架 + **MinIO 媒体闭环** + 埋点队列三项加固 + 凭证防泄漏三仓 + auth 契约测试补齐 +- **第二波**(15~20):社区后端纵切 16 端点(帖子/Feed/评论/互动/关注) + **契约冻结 v1.3.0** + 快照同步与矩阵扩展(零漂移) +- **第三波**(21~27):Flutter 五单接入(数据层/媒体上传/Feed/详情互动/发布页) + 字典 v3;社区 demo 三页消亡 +- **第四波**(28~29):E2E 烟囱 14 场景收官取证 + 文档收口 + +## 2. M3 四条验收标准证据索引(28 号报告) + +| 标准 | 结论 | 取证强度 | +| --- | --- | --- | +| 发布后另一客户端可见 | ✓ | B token 拉 Feed 首位即 A 帖;FeedCard 11 必填键逐项断言 + 6 类裁剪字段确认缺席;另有 T3-17 双 App 实例真 UI 实测 | +| 重复点赞不重复计数 | ✓ | 3 次 PUT 均返权威终态 `{liked:true,likeCount:1}`;psql `post_likes` 恰 1 行库层互证;后端另有 4 线程真并发测试 | +| 分页不丢不重 | ✓ | 同一 Feed 以 limit=7(10 页)与 limit=100(1 页)两次全量翻页,**有序 id 列表逐位相等**;psql 谓词行数交叉核对 | +| 删除内容不出公共 Feed | ✓ | 删除前后各全量翻页(66→65,差集恰为该帖);B 与作者 A 直读均 404/40403 逐字节一致 | + +## 3. 质量机制的兑现 + +- **契约测试累计抓修 3 处真实问题**:events 的 reason 字段误序列化 null(第一波 auth 矩阵)、校验器对 `nullable + allOf` 静默跳过的盲区(第二波矩阵扩展)、CreatePetRequest.sex 必填漂移(M2 期) +- **compose 实测抓出跨服务接线缺陷**:T3-13 发现 media 端点在 user:8082 而 T3-12 误挂 community:8084(真链路必 404),单测无法覆盖此类装配错误 +- **widget 测试抓出两处真 bug**:Feed 尾部失败态被滚动自动重试冲掉、回前台不开新曝光段 +- **多源核验**:一个 agent 识破 Monitor 的假 success 事件(时间戳晚于实时时钟),坚持以 Gitea API 多次直查为准 +- **量级测算否决设计**:逐卡 Feed 曝光被埋点角色以「7~14 个月击穿分区阈值 + 接收端无限流背压」否决,改聚合 `feed_viewed`,并在后端字典层把 `post_impression` 等 7 事件锁死为 unknown + +## 4. 遗留与 M4 建议 + +**真机挂起四项**(步骤已在 [真机验证清单](../../device-verification.md) 备齐):媒体上传弱网、乐观更新手感、Feed 图片加载、社区事件落库。**时限提醒**:埋点角色建议 2026-09-21(北极星首次出数日)前完成 M2 两项,否则首批读数只能标未验收。 + +**M3 范围内遗留**: +1. 完整草稿列表与自动保存(26 号 §7);大图「下滑关闭」手势待 photo_view 复评 +2. `widthPx/heightPx` 恒 null(28 号观察项 1):契约描述称 confirm 后回填,实现无尺寸探测——单图帖一律回落 4:3,不呈现真实宽高比;补实现或改契约描述二选一 +3. `eventVersion` 口径未定型(28 号观察项 2):契约描述可两读、服务端不校验、客户端硬编码 1;需定型为「事件 schema 版本」并写入字典纪律 +4. uploading 超时未确认 asset 的清理定时任务(13 号已有方案未实现) +5. 429 限流未实现,连带客户端 Retry-After 精细分支挂起(承自 09 号出入清单) +6. 话题功能(ADR-018 剪出)、关注列表/作者主页(裁剪项) + +**跨迭代遗留**(承自 M1/M2,未变化):access token 黑名单、`/internal` 改 mTLS。 + +**M4 方向输入**(开发计划 M4:AI 创作): +- 模型/风格目录、生成任务创建/查询/取消、Worker 队列消费(租约/重试/幂等键)、输出写媒体表后一键建社区草稿——**媒体链路与社区草稿两端已在 M3 就位**,M4 可直接复用 +- V5 已为 `posts.generation_job_id` 留裸列,M4 迁移补回该外键即可打通 AI 产出→社区发布 +- create 页的 AI 生成模拟(M3 刻意零改动)是 M4 的替换目标 +- A/B 前置 8 项:M3 末 6 项全绿 + 1 项部分绿(feature flag 随社区发布开关落地),M4 可启动首个实验;北极星「7 日回访记录率」复评点为 H7 读数 + +## 5. 收官提交索引 + +| 仓库 | 收官 HEAD | 测试 | +| --- | --- | --- | +| patbond-api | dev@8089c06 | 334 | +| patbond-flutter | dev@0e87413 | 502 | +| patbond-doc | 本收口提交 | strict 通过 | diff --git a/docs/development/iterations/iteration-3/index.md b/docs/development/iterations/iteration-3/index.md new file mode 100644 index 0000000..202dca3 --- /dev/null +++ b/docs/development/iterations/iteration-3/index.md @@ -0,0 +1,58 @@ +# 第三迭代进展看板 + +> 目标:M3 社区——图片媒体上传闭环 + 帖子草稿/发布/删除 + 公共 Feed 游标分页 + 单层评论 + 点赞/收藏幂等 + 关注最小接口 + Flutter 三页替换 demo 与乐观更新回滚,依据[开发实施计划](../../development-plan.md) M3 节。 +> 更新日期:2026-09-10(**M3 收官,验收 PASSED**)。本页是团队共享的进度事实来源。 + +## 当前状态一览 + +| 状态 | 内容 | +| --- | --- | +| ✅ 第一波 | V5 community 迁移 + patbond-community 骨架 + **MinIO 媒体闭环**(ADR-016/017)+ 埋点队列三项加固 + 凭证防泄漏三仓 + auth 契约测试 | +| ✅ 第二波 | 社区后端 16 端点纵切 + **契约冻结 v1.3.0** + 快照同步与契约矩阵 173 格零漂移 | +| ✅ 第三波 | Flutter 五单接入(数据层/媒体上传/Feed/详情互动/发布页)+ 字典 v3;社区 demo 三页消亡 | +| ✅ 第四波 | E2E 烟囱 **14/14**、契约偏差 **0**、M3 四条验收标准逐条取证(报告 28);收官总结见报告 29 | +| ⚠️ 遗留 | 真机四项挂起(清单已备齐步骤)、widthPx/heightPx 恒 null、eventVersion 口径未定型、uploading 清理任务、429 限流——完整清单见报告 29 §4 | + +## 测试与契约演进 + +| 时点 | patbond-api | patbond-flutter | openapi.yaml | +| --- | --- | --- | --- | +| M3 开工基线 | 191 | 272 | v1.2.0(18 路径) | +| 第一波收口 | 226 | 286 | v1.2.0 | +| 第二波收口 | 325 | 286 | **v1.3.0 冻结**(31 路径/43 操作/72 schema) | +| 第三波收口 | 334 | 502 | v1.3.0(矩阵 173 格零漂移) | +| **收官** | **334** | **502**(+E2E 脚本) | v1.3.0(E2E 逐场景核验偏差 0) | + +## 已完成(附提交) + +**开工分析(报告 01~08)**:8 角色并行评估;Reality Checker 首个 **CERTIFIED** 无条件放行;对象存储选型经用户拍板定为自托管 MinIO 起步(ADR-016,预留迁云);ADR-016~021 入档(`patbond-doc@d286782`)。 + +**第一波(报告 09~14)** + +- V5 community 8 表 + pg_trgm,剪 2 条跨 schema FK(`posts.generation_job_id`→M4、`posts.region_id`→M5 补回)(`patbond-api@a97814a`)。 +- patbond-community:8084 骨架,骨架期即接 RS256 校验(`3c671fc`)。 +- **MinIO 媒体闭环**:ObjectStorage 适配层 + 两步上传(预签名 PUT 直传 → confirm ready)+ 私有桶预签名 GET(`10a43f8`)。 +- auth 域契约测试补齐,首轮抓修 events `reason` 序列化漂移(`263cd88`)。 +- 埋点队列三项:30s 定时冲刷 / 指数退避 / anonymousId 持久化(`patbond-flutter@4d40c38`)。 +- 凭证防泄漏 9 规则两层检查三仓落地(`api@8330885`/`flutter@66f983d`/`doc@8e1fe2f`)。 + +**第二波(报告 15~20)** + +- 帖子生命周期 5 端点 + 幂等 + 防枚举 40403(`101ac0f`);Feed + AuthorSummary + `/internal` 批量资料 + Feign 降级(`40bac85`/`99a3c1f`);评论/互动/关注 11 端点 + 真并发幂等 + 计数同事务(`19e8cba`/`7f1dd33`)。 +- **契约冻结 v1.3.0**:26 项草案修正照单全收(`patbond-doc@f848476`);四模块快照同步 + community 64 格 + media 8 格矩阵,修 `nullable+allOf` 校验盲区(`0569585`)。 + +**第三波(报告 21~27)** + +- community 数据层 19 操作 + **ToggleSync** 乐观更新状态机(`19bd8c1`);MediaUploader 六态 + 孤儿防护(`1441f01`,并抓修 media 端点错挂服务的缺陷);Feed 四态 + 曝光聚合 + SignedNetworkImage(`8aac8c5`);详情页 + 互动跨页一致 + 三层视觉抑制(`92524da`/`f873acf`);发布页两步发布 + 草稿两路径 + 漏斗埋点(`9892b65`)。 +- 字典 v3 白名单 22→42 事件,7 个被否决事件锁死(`api@8089c06`)。 + +**第四波(报告 28~29)** + +- E2E 烟囱脚本 `test_e2e_m3_manual.dart`,14 场景一次通过、复跑再次通过(`flutter@0e87413`)。 + +## 相关文档 + +- [后端模块结构与职责](../../../architecture/backend-modules.md) +- [技术决策记录](../../../architecture/decisions.md)(ADR-016~021 为 M3 决策) +- [真机验证清单](../../device-verification.md)(M3 四项步骤已备齐) +- 契约:`docs/api/openapi.yaml` v1.3.0(冻结纪律见报告 18) diff --git a/mkdocs.yml b/mkdocs.yml index 93b7da6..63d2448 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,6 +64,7 @@ nav: - 29 M2 收官总结: development/iterations/iteration-2/29-m2-summary.md - 30 真机补验清单: development/iterations/iteration-2/30-device-verification-checklist.md - 第三迭代: + - 进展看板: development/iterations/iteration-3/index.md - 01 任务分解: development/iterations/iteration-3/01-pm-task-breakdown.md - 02 后端技术评估: development/iterations/iteration-3/02-backend-technical-assessment.md - 03 Flutter 技术评估: development/iterations/iteration-3/03-flutter-technical-assessment.md @@ -91,6 +92,8 @@ nav: - 25 详情页与互动: development/iterations/iteration-3/25-detail-interactions-report.md - 26 发布页与漏斗: development/iterations/iteration-3/26-publish-page-report.md - 27 第三波收口: development/iterations/iteration-3/27-wave3-closure.md + - 28 E2E 烟囱收官: development/iterations/iteration-3/28-e2e-smoke-report.md + - 29 M3 收官总结: development/iterations/iteration-3/29-m3-summary.md - API: - 契约说明: api/index.md - 架构: