Files
patbond-doc/docs/development/iterations/iteration-3.5/08-release-e2e-regression.md
T
lixi 5cc6361534
CI / docs-build (push) Successful in 1m22s
docs: v0.4.0 发布记录 + 功能清单 M3.5 节 + 发布 E2E 回归报告
- releases.md 新增 v0.4.0(M3.5 体验补齐):三仓 tag、门禁 8 项证据、
  首次经 PR 流程发布的操作记录与流程验证结论、期间安全事件索引
- checklist 变更登记:**回归清单由两份改为四份**(M1/M2/M3/M3.5),
  原则「每个引入对外端点的迭代都应有对应 E2E 脚本并在此后每次发布回归」
- feature-checklist 新增第 13 节(M3.5 共 13 条)
- 08 号报告:E2E 四份 42/42 场景 234 断言零失败、契约偏差 0;
  另实证零迁移在存量库升级路径(Flyway: No migration necessary)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 11:19:13 +08:00

350 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 08 v0.4.0 发布前 E2E 回归门禁
> 作者:QAFrontend Developer 角色执行)
> 日期:2026-09-14
> 任务:v0.4.0 发布 checklist 第 2 步「compose 全栈起,跑 E2E 烟囱脚本,全场景 PASS」
> 输入基线:patbond-api dev@`3cd8005`379 测试)、patbond-flutter dev@`6945436`597 测试)、
> 契约 `openapi.yaml` **v1.4.0**32 路径 / 45 操作 / 75 schema
> 提交:`test_e2e_m35_manual.dart` 新增,已推 `origin/dev`(见 §7
> 结论先行:**门禁 PASS。四份脚本 42/42 场景全绿(M1 7/7、M2 11/11、M3 14/14、M3.5 新写 10/10),
> 共 234 条断言零失败;契约偏差 **0**;M3.5 新增对外面 8 项逐项验证通过;三门禁命令全绿
> analyze 0 issue、format 0 changed、flutter test 597 passed)。patbond-api 与 patbond-doc
> 代码/契约零改动。**
---
## 1. 结论表:四份脚本场景通过数
| 脚本 | 迭代 | 场景 | 通过 | 断言数 | 退出码 | 判定 |
| --- | --- | --- | --- | --- | --- | --- |
| `test_e2e_manual.dart` | M1 | 7 | **7/7** | 7 | 0 | ✅ PASS |
| `test_e2e_m2_manual.dart` | M2 | 11 | **11/11** | 44 | 0 | ✅ PASS |
| `test_e2e_m3_manual.dart` | M3 | 14 | **14/14** | 88 | 0 | ✅ PASS |
| `test_e2e_m35_manual.dart` | **M3.5(本次新写)** | 10 | **10/10** | 95 | 0 | ✅ PASS |
| **合计** | | **42** | **42/42** | **234** | | ✅ **PASS** |
- **契约偏差数:0**。四份脚本对 v1.4.0 冻结契约的每一处断言(状态码、业务码、字段键集合、
可空性、枚举、错误谱)均命中,无一处需要"以实现为准"地放宽期望。
- **失败项:无**。M3.5 脚本首次运行即 10/10 通过(未出现需要调整断言或重试的场景)。
- 三份既有回归脚本**逐字未改**——回归价值恰在于脚本本身不动。
### 执行顺序与理由
依次串行 M1 → M2 → M3 → M3.5,不并行。M3 场景 10/11 对 Feed 做**全量翻页前后计数比对**
`idsAfter.length == idsBefore.length - 1`),任何并发发帖都会污染该断言;M3.5 场景 10 也要发帖。
串行是这两个断言成立的前提。
---
## 2. M3.5 新增对外面逐项验证结果
任务列出的 8 项必覆盖点,逐项对照:
| # | 要求 | 覆盖场景 | 结果 | 关键证据 |
| --- | --- | --- | --- | --- |
| 1 | 注册 → `GET /me` 断言 nickname 为 null(未设)、avatarUrl 为 null | [1/10] | ✅ | 全新账号 `nickname=null avatarUrl=null`,两键**恒在**;同时断言 Me 形态在冻结的 6 字段内 |
| 2 | `PATCH /me` 设昵称 → 回读一致 | [3/10] | ✅ | PATCH 回显与 `GET /me` 回读**逐字段一致**userId/username/phone/createdAt/avatarUrl 五字段逐一比对),证明两动词同一 `Me` 形态 |
| 3 | 三态:只带 nickname 不影响头像 / 显式 `null` 清空生效 / 空 patch 400 / 纯空白 400 | [5/10] | ✅ | 见 §2.1 展开 |
| 4 | 昵称边界:1 可、32 可、33 拒、**32 emoji 按码点计应通过** | [6/10] | ✅ | 见 §2.2 展开 |
| 5 | 用户头像两步上传(user_avatar) → 挂载 → avatarUrl 可下载且字节一致 → 响应**不含** avatarAssetId | [4/10] | ✅ | 见 §2.3 展开 |
| 6 | 宠物头像(pet_avatar) → 挂载 → 详情/列表 avatarUrl 可用;错 purpose 被拒 | [8/10] + [9/10] | ✅ | 见 §2.4 展开。**错 purpose 实测为 404/40405**(与契约一致,非 42203 |
| 7 | `GET /me/community-stats`:空 0/0 → 发帖 → 自赞 → 1/1 → 软删归零 | [10/10] | ✅ | 见 §2.5 展开 |
| 8 | purpose 白名单外(`id_card`)创建上传被拒 | [2/10] | ✅ | `400/40000`message 回显白名单三值 |
`/internal` 侧按任务要求**未测**(内部端点不入公网契约)。
### 2.1 `PATCH /api/v1/me` 三态与错误谱(场景 5,13 条断言)
| 请求 | 期望 | 实测 | 说明 |
| --- | --- | --- | --- |
| `{nickname: "豆豆"}`(头像已在) | 200,头像不变 | 200,`avatarUrl` 仍非 null | **键缺省 = 不改**:只带昵称的 patch 不碰头像 |
| `{nickname: null}` | 200,昵称清空、头像留存 | 200,`nickname=null``avatarUrl` 非 null | **显式 null = 清空**,且只作用于携带的键;回读复核已持久化 |
| `{avatarAssetId: null}` | 200`avatarUrl` 回落 null | 200`avatarUrl=null` | 头像清除生效 |
| `{}` | 400/40000 | 400/40000 | message`请至少提交一个可更新字段:nickname 或 avatarAssetId`——不静默 200 |
| `{nickname: " "}` | 400/40000 | 400/40000 | **纯空白不隐式清空**,清空只留显式 null 一条路 |
| `{nickname: ""}` | 400/40000 | 400/40000 | 空串与纯空白同判 |
| `{bio: "…"}` | 400/40000 | 400/40000 | 只带未声明字段 = 空 patch(`bio` 列存在但 M3.5 未开放读写) |
| `{avatarAssetId: "not-a-uuid"}` | 400/40000 | 400/40000 | 参数错先于引用校验 |
| 坏 JSON`{"nickname": ` | 400/40000 | 400/40000 | 非法 body |
| 无 token | 401/40101 | 401/40101 | 写入口同样强制鉴权 |
| 同 body 连发两次 | 两次 200 且状态一致 | 两次 200nickname 与"有头像"布尔一致 | 无 `Idempotency-Key`,天然幂等 |
每一处 `PATCH` 成功响应都跑了 `assertMeShape`:**键集合不超出冻结 6 字段**,且**不含 `avatarAssetId`**。
### 2.2 昵称边界——码点计而非 UTF-16 长度(场景 6,7 条断言)
| 昵称 | 码点 | UTF-16 长度 | 期望 | 实测 |
| --- | --- | --- | --- | --- |
| `柴` | 1 | 1 | 200 | ✅ 200 |
| `猫`×32 | 32 | 32 | 200 | ✅ 200 |
| **`🐕`×32** | **32** | **64** | **200** | ✅ **200** |
| `猫`×33 | 33 | 33 | 400/40000 | ✅ 400/40000 |
| `🐕`×33 | 33 | 66 | 400/40000 | ✅ 400/40000 |
| `" 豆豆 "` | — | — | 200 且存为 `豆豆` | ✅ btrim 对齐 |
**32 emoji 昵称通过是这一格的核心**:若应用层按 `String.length()` 校验,UTF-16 长度 64 会被误拒,
而 PostgreSQL `char_length` 数的是码点、数据库本可存下。实测通过 + 回读逐字一致
`runes.length == 32`,无截断),证明校验确实按码点。上边界对 CJK 与 emoji 两种字符集行为一致。
### 2.3 用户头像端到端(场景 4,9 条断言)
链路:`POST /media/uploads`(purpose=**user_avatar**) → 预签名 PUT 直传 MinIO →
`POST /complete`(→ready) → `PATCH /me {avatarAssetId}``GET <avatarUrl>`
- `avatarUrl` 由 null 转为**现签预签名 GET**,携带 `X-Amz-Signature` SigV4 query 族。
- objectKey 前缀随 purpose`patbond-media/user_avatar/2026/09/{assetId}`——证明 §4 的前缀规则生效。
- **预签名 GET 取回 344 字节,与上传逐字节一致**(`ListEquality` 全等比对)。
- `GET /me` 再取一次 `avatarUrl` 仍可下载 → **每次响应现签**(客户端不得持久化)。
- **同一对象去掉签名直访 → 403**:桶保持私有,头像不靠公开读。
- 挂头像时 `nickname` 未被改动(三态交叉验证)。
- 挂载/清空/初始三态的响应**均不含 `avatarAssetId`**。
### 2.4 宠物头像(场景 8 + 9,20 条断言)
| 断言 | 结果 |
| --- | --- |
| 建档响应 `avatarUrl` 键恒在且为 `null`**不含** `avatarAssetId` | ✅ |
| `PATCH /pets/{id} {version, avatarAssetId}` 仅头像字段 → 200WRITE 档) | ✅ |
| `version` 0→1(头像与资料共用同一把乐观锁) | ✅ |
| 只带 avatarAssetId 未动 `name`/`species` | ✅ |
| **详情** `GET /pets/{id}` 的 avatarUrl 可下载且字节一致 | ✅ |
| **列表** `GET /pets` 的 avatarUrl 可下载且字节一致(两处各自现签、均有效) | ✅ |
| 列表项同样不外露 `avatarAssetId` | ✅ |
| `{avatarAssetId: null}` 显式清除 → `avatarUrl=null`,回读复核 | ✅ |
asset 校验矩阵(场景 9):
| 引用的 asset | 期望 | 实测 | message |
| --- | --- | --- | --- |
| 错用途 `user_avatar`(跨域头像不通用) | 404/40405 | ✅ | `该媒体资源的用途不是 pet_avatar,不能作为宠物头像` |
| 错用途 `post_image` | 404/40405 | ✅ | 同上 |
| 幽灵 assetId | 404/40405 | ✅ | `媒体资源不存在` |
| 他人(B)的 asset | 404/40405 | ✅ | `媒体资源不存在`(防枚举合并) |
| 本人 `pet_avatar` 但仍 `uploading` | 422/42203 | ✅ | `媒体尚未就绪` |
| 缺 `version`(只带 avatarAssetId | 400/40000 | ✅ | `version 不能为空`——即便只改头像仍必填 |
**副作用复核**:以上 6 次失败的 PATCH 之后,宠物 `avatarUrl` 仍为 null 且 `version` 未 +1
——失败路径零副作用。
> **任务提出的"错 purpose 以实现为准(40405 或 42203"已定论:实测 404/40405**,与
> `openapi.yaml` v1.4.0 的 `paths./api/v1/pets/{petId}.patch` 404 描述及 03 号报告 §2.6
> 完全一致。42203 只留给"用途相符但状态未就绪"。**契约与实现无偏差。**
用户侧同构矩阵(场景 7,6 条断言):幽灵 / 他人(B) / 错用途 `post_image` / 错用途 `pet_avatar`
四路均 404/40405,且**幽灵 id 与他人 asset 的响应体逐字节一致**(不能据差异探出 id 是真的);
本人 `user_avatar``uploading` → 422/42203(用**真未直传**的 asset,非 SQL 造数据)。
5 次失败后头像未被改动。
### 2.5 `GET /api/v1/me/community-stats`(场景 1014 条断言)
| 步骤 | 期望 | 实测 |
| --- | --- | --- |
| 无 token | 401/40101(唯一错误谱) | ✅ `401/40101` |
| A 全新账号(无帖) | 200`{0, 0}`**永不 404** | ✅ `{"receivedLikeCount":0,"publishedPostCount":0}` |
| 形态 | 恰两字段,非 null | ✅ 无多余键 |
| 发布 1 帖 | `{0, 1}` | ✅ |
| **A 自赞该帖** | `{1, 1}`(自赞**计入** | ✅ `{"receivedLikeCount":1,"publishedPostCount":1}` |
| 对账 | 帖详情 `likeCount` == `receivedLikeCount` | ✅ 两处均为 1,**口径对得上** |
| 再建 1 草稿 | `{1, 1}`(**草稿不计**) | ✅ 作品数仍 1 |
| 再发布 1 帖 | `{1, 2}`(多帖求和) | ✅ |
| B 发布并自赞一帖 | A 视角仍 `{1, 2}`(**他人帖不串号**) | ✅ 主体恒为 token 里的调用者 |
| **软删被赞的那帖** | `{0, 1}`(获赞**归零** | ✅ `{"receivedLikeCount":0,"publishedPostCount":1}` |
超出任务要求补测的两格:**草稿不计** 与 **他人帖不串号**——前者是 §2.5 口径表最容易实现错的一格,
后者证明"路径上没有 userId"确实等价于主体隔离。
---
## 3. 契约偏差数:0
逐面核对 `patbond-doc/docs/api/openapi.yaml` v1.4.0,脚本断言与契约声明零分歧:
| 契约条目 | 声明 | 实测 |
| --- | --- | --- |
| `info.version` | 1.4.0 | — |
| 路径 / 操作 / schema 计数 | 32 / 45 / 75 | ✅ 与基线一致(机械计数复核) |
| `Me.required` | `[userId, username, nickname, avatarUrl, createdAt]` | ✅ 五键恒在;`phone` 可选可空 |
| `Me` 不含 `avatarAssetId` | 响应一律不外露 | ✅ 6 处响应(GET 初始/PATCH 回显/挂载后/清空后 等)逐一断言 |
| `Pet``avatarUrl` 进 required | 键恒在、值可空 | ✅ 创建/详情/列表三处 |
| `Pet` 不含 `avatarAssetId` | 只写不读 | ✅ 三处断言 |
| `UpdateMeRequest` 三态 | 缺省/null/给值 | ✅ 三态各一格 |
| `PATCH /me` 400 谱 | 空 patch、长度越界、纯空白/空串、非法 UUID、坏 JSON | ✅ 6 格全中 |
| `PATCH /me` 401/404/422 | 40101 / 40405 / 42203 | ✅ 全中 |
| `PATCH /pets/{petId}` 新增 404/40405、422/42203 | asset 四态 + 未就绪 | ✅ 6 格全中 |
| `CommunityStats` | 两 int64、required 非 nullable、空数据 0、永不 404 | ✅ 全中 |
| `CreateMediaUploadRequest.purpose` 枚举 | `[post_image, user_avatar, pet_avatar]`,外值 400/40000 | ✅ 三值放行 + `id_card` 被拒 |
---
## 4. 环境记录
| 项 | 值 |
| --- | --- |
| 日期 | 2026-09-14 |
| patbond-api | `dev@3cd8005`(工作树干净,零改动) |
| patbond-doc | `dev@38d9e97`(契约零改动;本报告为新增文件) |
| patbond-flutter | `dev@6945436` + 新增 `test_e2e_m35_manual.dart` |
| 契约 | `openapi.yaml` v1.4.0 — **32 路径 / 45 操作 / 75 schema**(机械计数复核一致) |
| Docker | 29.7.2Docker Compose 5.5.1 |
| Dart SDK | 3.12.2 (stable) |
| Flutter | 3.44.6 (stable)framework `ee80f08bbf` |
| JDK(打包) | `JAVA_HOME=/usr/lib/jvm/java-17-openjdk`17.0.20.1 |
### 六容器实况
| 容器 | 镜像 | 状态 | 端口 |
| --- | --- | --- | --- |
| `patbond-postgres-1` | `postgres:18`18.6 | Up (healthy) | 不发布(仅容器网) |
| `patbond-minio-1` | `minio/minio:RELEASE.2025-04-22T22-12-26Z` | Up (healthy) | 9000 |
| `patbond-auth-1` | `patbond-auth`(本地构建) | Up | 8081 |
| `patbond-user-1` | `patbond-user` | Up | 8082 |
| `patbond-pet-1` | `patbond-pet` | Up | 8083 |
| `patbond-community-1` | `patbond-community` | Up | 8084 |
MinIO 镜像 tag 与集成测试的 Testcontainer 钉同一版本(三环境零分叉,ADR-016)。
### 起停命令
```bash
cd <你的工作区>/patbond-api
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw -DskipTests package # exit 0
docker compose up -d --build # 等 postgres/minio healthy
# …跑四份脚本…
docker compose down
```
### 附带取证:M3.5 的"零 Flyway 迁移"在真实升级路径上成立
本次 compose 起在**已存在的 `pgdata` volume** 上(非全新库),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. No migration necessary.
```
即 M3.5 相对 M3 **未新增任何 Flyway 版本**(仍停在 V5,V6 留给后续真正建表的迭代),
ADR-022 的"零迁移"前提在**存量库升级**场景下得到实证,而不只是全新安装。
auth / pet / community 三服务不跑 Flyway(无迁移输出),与设计一致。
### 配置侧复核(03 号报告 §4 的部署警告)
03 号报告提醒:若环境的 `application.yml` 显式写了 `allowed-purposes: post_image`,头像上传会 400。
compose 挂载的是 `patbond-user/src/main/resources/application.yml.sample`,实测该文件第 59 行为
`allowed-purposes: post_image,user_avatar,pet_avatar` ✅;pet 服务已补齐
`PATBOND_MINIO_PUBLIC_ENDPOINT/ACCESS_KEY/SECRET_KEY`compose 第 107 行),
故宠物侧 `avatarUrl` 才可能非 null——场景 8 的下载成功即其端到端证明。
---
## 5. 门禁三命令输出
```bash
cd <你的工作区>/patbond-flutter
```
### 5.1 `flutter analyze`
```
Analyzing patbond-flutter...
No issues found! (ran in 2.1s)
```
退出码 **0**。(新增的根目录脚本也在分析范围内——`flutter analyze` 覆盖整仓 `.dart`。)
### 5.2 `dart format --set-exit-if-changed lib test`
```
Formatted 161 files (0 changed) in 0.66 seconds.
```
退出码 **0**
补充:该命令的范围是 `lib test`,**不覆盖仓库根目录的四份 E2E 脚本**。为免留下格式债,
另跑了一次显式检查,四份并列脚本均已 format-clean
```
$ dart format --output=none --set-exit-if-changed \
test_e2e_manual.dart test_e2e_m2_manual.dart \
test_e2e_m3_manual.dart test_e2e_m35_manual.dart
Formatted 4 files (0 changed) in 0.06 seconds.
```
退出码 **0**。(新脚本首版曾 1 changed,已 `dart format` 归一;formatter 拆出的一处
无花括号 `if` 已补花括号,避免 `curly_braces_in_flow_control_structures` 风格债。
归一后**重跑脚本复核仍 10/10**,即上表证据出自最终提交的文件。)
### 5.3 `flutter test`
```
00:30 +597 ~2: All tests passed!
```
退出码 **0****597 passed**、2 skipped、0 failed,与基线 597 **逐格一致**(本次未新增单测:
E2E 脚本是独立可执行的手动脚本,不进 `flutter test` 反应堆)。
---
## 6. 失败项与判定
**无失败项。**
| 类别 | 数量 | 说明 |
| --- | --- | --- |
| 真实回归 | **0** | M1/M2/M3 三份既有脚本 32/32 场景全绿,M3.5 新增字段未破坏任何既有断言 |
| 契约偏差 | **0** | 见 §3 |
| 需放宽的断言 | **0** | 含任务预留的"错 purpose 以实现为准"一格——实测与契约一致(40405) |
| 环境/配置问题 | **0** | §4 两处部署风险点均实测已闭环 |
**判定:v0.4.0 发布 checklist 第 2 步 PASS。**
### 本次门禁未覆盖的范围(不构成 FAIL,按既定方案挂起)
- **真机四项**`development/device-verification.md`):弱网上传头像、预签名 URL 过期后重取、
caregiver 账号改宠物头像、资料页获赞数与帖子详情点赞数对账。本脚本纯 `dart:io HttpClient`
跑后端契约面,不驱动 Flutter UI,故这四项仍按 M3 起的方案 A 挂起。
其中"获赞数对账"的**后端口径**已在场景 10 证明(详情 `likeCount` == `receivedLikeCount`),
真机侧待验的只剩 UI 呈现。
- **caregiver 分档**viewer 改头像 403 / caregiver 夹带混合 403):需要第二账号建立
`pet_owners` 照护关系,M2/M3 脚本均未铺该前置,本次亦未铺;后端已有
`PetAvatarIntegrationTest` 三段断言覆盖(03 号报告 §2.4)。
- **并发** `PATCH /me` **列级 UPDATE**:由后端 `MeProfileIntegrationTest.concurrentDisjointPatchesBothSurvive`
`CyclicBarrier` 覆盖,单线程脚本不复现,本次以"重放幂等"作为可观测替代。
- `/internal/**`:按任务要求不测(不入公网契约)。
---
## 7. 脚本落位与提交
| 项 | 值 |
| --- | --- |
| 新增文件 | `<你的工作区>/patbond-flutter/test_e2e_m35_manual.dart`(仓库**根目录**,与前三份并列,**不在 `test/`** |
| 行数 | 1166 行,10 场景 / 95 断言 |
| 依赖 | 无(纯 `dart:io HttpClient` + `dart:convert`,不引 `collection` 包——`ListEquality` 内联,与 M3 脚本同先例) |
| 运行 | `cd <你的工作区>/patbond-flutter && dart run test_e2e_m35_manual.dart` |
| 提交 | `dev` 分支(`main` 受保护禁直推) |
### 脱敏纪律(与 M3 脚本一致)
- **token**:仅打印前 20 字符 + `...<REDACTED>`
- **预签名 URL**`redactSignedUrl()` 保留 scheme/host/port/path**签名 query 整体替换为
`<SIGNATURE_REDACTED>`**——objectKey 前缀(`user_avatar/2026/09/…`)仍可读,便于核对 purpose
前缀规则,而签名族一字不落盘。
- **Idempotency-Key**:日志中以 `<KEY-1>` 占位,不打印真实 UUID。
- 超长昵称按"前 4 码点 + 码点数/UTF-16 长度"概述,不整条刷屏。
### 硬约束遵守情况
- `patbond-api``git status --porcelain` **空**HEAD 仍 `3cd8005` — 代码/契约零改动,仅起 compose。
- `patbond-doc`:契约 `openapi.yaml` 零改动,HEAD 仍 `38d9e97`;本报告为新增文件,
**只写不提交**(随后统一入档);`mkdocs.yml` **未动**
- `patbond-flutter`:仅新增 1 个文件,无既有文件改动。
---
## 8. 交接给发布 checklist 后续步骤
1. **第 2 步可勾**:42/42 场景、234 断言、契约偏差 0、门禁三命令全绿。
2. 本报告入档时需挂 `mkdocs.yml` 导航(本次按纪律未动)。
3. 建议把 `test_e2e_m35_manual.dart` 与前三份一同写进发布 checklist 的常设回归清单——
下个迭代的发布门禁应跑**四份**而非三份。
4. 真机四项与 caregiver 分档仍挂起,见 §6;若 v0.4.0 定位为可发布版本,
建议在 `device-verification.md` 明确登记"头像相关四项待真机补验",避免遗忘。
5. 收尾已执行 `docker compose down`volume 保留,未 `-v`)。