# 28 M2 收官:E2E 烟囱测试报告(T2-18) - 执行人:Frontend Developer - 日期:2026-09-08 - 环境:patbond-flutter (dev 分支) + patbond-api (docker compose 编排,代码零改动) - 测试脚本:`patbond-flutter/test_e2e_m2_manual.dart`(commit `720865b`,已推送 origin/dev) - 参照模式:iteration-1/18 号收官报告(格式与取证标准沿用) --- ## 0. 执行概要 ### 测试目标 M2 第四波收官(工单 T2-18):在 compose 真实后端上跑通 M2 完整链路烟囱并收集证据—— 登录 → 建档 → 记体重 → 登记疫苗 → 记健康事件 → 摘要数值核对 → 第二账号访问被拒 → 第二设备同账号全量读回,外加埋点落库与乐观锁冲突两条链路。 ### 测试结果 **✓ 11/11 场景全部通过**(单次运行一次通过;格式化后复跑再次 11/11) - Docker Compose 四容器健康运行(postgres + auth:8081 + user:8082 + pet:8083) - 契约一致性:响应字段、错误码、HTTP 状态码与冻结契约 openapi v1.2.0 完全一致 - **契约偏差数:0 个** - Flutter 门禁三命令全绿:`dart format`(0 changed)/ `flutter analyze`(No issues)/ `flutter test`(**272 passed**) - 数据库证据齐备:`pet_health` 六表 + `platform.product_events` psql 查证一致 ### ⚠️ 真机挂起项(显著标注:真机待补验) 真机不可用(用户确认),以下两项按既定方案 A 挂起,**本报告不含其证据**, 待真机可用后补验: | # | 挂起项 | 说明 | | --- | --- | --- | | 1 | **Android 真机事件落库观察** | 本报告以脚本直连 `/api/v1/events`(platform=android 模拟真机值)替代验证服务端链路;真机端 AnalyticsClient → 持久化队列 → 上报的端上链路待真机补验 | | 2 | **SessionTracker 30min 会话超时手测** | 前后台切换超时重建 sessionId 的真机手测;单元测试已覆盖规则(T2-15),真机行为待补验 | ### 脱敏声明 全部 token 截断至前 20 字符 + ``;密码不出现在任何输出;`.env` 内容未引用。 --- ## 1. 后端启动与健康检查 ### 1.1 构建与启动(patbond-api 代码零改动) ```bash cd patbond-api JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw -DskipTests package # BUILD SUCCESS docker compose up -d --build # Container patbond-postgres-1 Healthy # Container patbond-auth-1 Started # Container patbond-user-1 Started # Container patbond-pet-1 Started ``` ### 1.2 容器健康状态 ```text NAMES STATUS PORTS patbond-pet-1 Up 12 seconds 0.0.0.0:8083->8083/tcp patbond-auth-1 Up 12 seconds 0.0.0.0:8081->8081/tcp patbond-user-1 Up 12 seconds 0.0.0.0:8082->8082/tcp patbond-postgres-1 Up 15 seconds (healthy) 5432/tcp ``` ### 1.3 服务就绪验证 ```bash docker logs patbond-pet-1 | grep Started # Started PetApplication in 6.286 seconds curl -s http://127.0.0.1:8083/api/v1/pets # {"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. E2E 烟囱测试执行记录(11 场景) ### 2.1 测试脚本 `test_e2e_m2_manual.dart`(纯 dart HttpClient 脚本,无 Flutter 运行时依赖, 与第一迭代 `test_e2e_manual.dart` 并列放仓库根目录,**不在 test/ 目录**、 不进 `flutter test`)。随机生成账号 `e2e_m2_a_` / `e2e_m2_b_` 避免冲突。运行方式:`docker compose up -d` 后 `dart run test_e2e_m2_manual.dart`。 本次取证运行:账号 A `e2e_m2_a_1788849543120`,petId `01a07fbd-dcad-756a-a844-fe5323aa0713`。 ### 2.2 场景 1:注册账号 A → 登录 ```text [1/11] 注册账号 A → 登录 POST /api/v1/auth/register → 200 ✓ 注册成功 userId(A): 01a07fbd-d92f-7ee7-a52c-d33c7fa80071 accessToken: eyJhbGciOiJSUzI1NiJ9... POST /api/v1/auth/login → 200 ✓ 登录成功(设备 1 会话) ``` ### 2.3 场景 2:建档(含品种)→ 列表/详情读回核对 ```text [2/11] 建档(POST /pets,含品种)→ 列表/详情读回核对 GET /api/v1/breeds?species=dog → 200 ✓ 品种目录返回 16 条 选用品种: 中华田园犬 (3a545503-a8ad-484a-8bbf-a38d08c6dcea) POST /api/v1/pets → 201 ✓ 建档成功(201) petId: 01a07fbd-dcad-756a-a844-fe5323aa0713 myRole: owner / version: 0 / breedDisplayName: 中华田园犬 ✓ 创建者角色为 owner ✓ 品种展示名解出一致 GET /api/v1/pets → 200 ✓ 列表读回 1 只宠物且 id 一致 GET /api/v1/pets/{petId} → 200 ✓ 详情读回核对通过(name/species/breedId/status) ``` 契约验证:201 + PetEnvelope、`myRole=owner`(创建者自动 primary owner)、 `breedDisplayName` 由字典解出、petId 为 UUIDv7(前缀 `01a07fbd`)。 ### 2.4 场景 3:记体重 ×2 → cursor 分页读回 ```text [3/11] 记体重 ×2 → 列表 cursor 分页读回 POST /weights (8.20kg, 2026-09-06T06:39:04.429336Z) → 201 POST /weights (8.45kg, 2026-09-07T06:39:04.429336Z) → 201 GET /weights?limit=1 → 200 ✓ 第一页:最新体重 8.45kg 在前,hasMore=true,nextCursor 非空 GET /weights?limit=1&cursor=... → 200 ✓ 第二页:8.20kg,hasMore=false,nextCursor=null ``` 契约验证:分页正典形态 `{items, nextCursor, hasMore}`;`measured_at DESC` 排序; 末页 `nextCursor` 恒为 null。 ### 2.5 场景 4:登记疫苗(scheduled)→ 标记完成(乐观锁) ```text [4/11] 登记疫苗(scheduled)→ 标记完成(PATCH + version) GET /api/v1/vaccine-catalog?species=dog → 200 ✓ 疫苗目录返回 6 条 选用疫苗: 犬二联疫苗 (e07d9a48-a8d3-4b6a-a14b-03a42fe85589) POST /vaccinations (scheduled, plannedOn=2026-09-08) → 201 vaccinationId: 01a07fbd-ddc2-7dc8-aa2e-768a51785dc7 / version: 0 PATCH /vaccinations/{id} (→completed, version=0) → 200 ✓ 标记完成成功,version 0→1,administeredOn/nextDueOn 回读一致 ``` 契约验证:`scheduled → completed` 状态机合法迁移;`version` 提交比对通过后 +1; `vaccineName` 由目录解出;`administeredOn=2026-09-08`、`nextDueOn=2027-09-08` 原样回读。 ### 2.6 场景 5:记健康事件(金额整数分)→ 时间线读回 ```text [5/11] 记健康事件(amountCents 整数分)→ 时间线读回 POST /health-events (medical, amountCents=12500) → 201 ✓ amountCents=12500 原样回读,createdByUserId=token subject healthEventId: 01a07fbd-de0a-7532-8d71-023452ba21ad GET /health-events → 200 ✓ 时间线读回 1 条且字段一致 ``` 契约验证:金额整数分传输无精度损耗;`createdByUserId` 取自验签 token (等于账号 A userId),不收请求体。 ### 2.7 场景 6:创建提醒 → 标记完成(completedAt 校验) ```text [6/11] 创建提醒 → 标记完成(completedAt 校验) POST /care-reminders (deworming, dueAt=2026-10-08T06:39:04.429336Z) → 201 ✓ 提醒创建成功,恒为 pending 且 completedAt=null reminderId: 01a07fbd-de48-779f-87f6-11135ae93be7 PATCH /care-reminders/{id} (→completed) → 200 ✓ 标记完成成功,completedAt=2026-09-08T06:39:04.429336Z(客户端提交时刻回读) ``` 契约验证:创建恒为 `pending`(不收 status);`completedAt` 由客户端提交、 非空当且仅当 `status=completed`。 ### 2.8 场景 7:摘要四项聚合逐项断言 ```text [7/11] GET /summary?tz=Asia/Shanghai 四项聚合逐项断言 GET /summary → 200 ✓ 最新体重 = 8.45kg(第二条写入,measured_at DESC 首行) ✓ 疫苗进度 = 1/1(scheduled→completed 后) ✓ 下次接种 = completed 行的 nextDueOn(2027-09-08,source=nextDue) ✓ 当月花费 = 12500 分,month=2026-09,timezone 回显 Asia/Shanghai ``` 四项聚合与前述写入逐项一致(口径 = iteration-2 报告 18 §3 定型表): | 聚合项 | 前述写入 | 摘要返回 | 结论 | | --- | --- | --- | --- | | latestWeight | 8.45kg(measured_at 最新) | weightKg=8.45 | ✓ | | vaccinationProgress | 1 条 completed / 1 条已登记 | completedDoses=1, totalDoses=1 | ✓ | | nextVaccination | completed 行 nextDueOn=2027-09-08 | dueOn=2027-09-08, source=nextDue, vaccinationId 命中 | ✓ | | monthlyExpense | amountCents=12500(当月事件) | amountCents=12500, month=2026-09, timezone=Asia/Shanghai | ✓ | ### 2.9 场景 8:权限拒绝——账号 B 访问 A 的宠物四路(防枚举) ```text [8/11] 注册账号 B → 用 B 的 token 访问 A 的宠物四路(防枚举核对) POST /api/v1/auth/register (B) → 200 详情 GET /pets/{id} → 404 / code 40401 ✓ 体重 GET /pets/{id}/weights → 404 / code 40401 ✓ 疫苗 GET /pets/{id}/vaccinations → 404 / code 40401 ✓ 摘要 GET /pets/{id}/summary → 404 / code 40401 ✓ ✓ 四路响应体完全一致(防枚举):{"code":40401,"message":"宠物不存在","data":null} ✓ B 的宠物列表为空(列表天然隔离) ``` 契约验证:无关系调用者与「宠物不存在」响应逐字节一致,随机探测 UUID 无法区分 是否命中真实记录(防枚举语义)。 ### 2.10 场景 9:跨设备读取——账号 A 重新登录全量读回 ```text [9/11] 账号 A 重新登录(模拟第二设备新会话)→ 全量数据读回 POST /api/v1/auth/login (设备 2) → 200 ✓ 新会话 token 与设备 1 不同(独立 token family) ✓ 宠物列表:1 只(旺财M2) ✓ 体重记录:2 条 ✓ 疫苗记录:1 条(completed) ✓ 健康事件:1 条 ✓ 提醒:1 条(completed,completedAt=2026-09-08T06:39:04.429336Z) ``` 设备 1 写入的全部五类数据在设备 2 新会话完整读回,服务端为唯一事实源。 ### 2.11 场景 10:埋点链路——v2 事件上报与落库 ```text [10/11] POST /api/v1/events 上报 v2 事件(platform=android 模拟真机值) eventId: 0c673914-... (pet_create_succeeded) eventId: dd59a83a-... (health_record_create_succeeded, recordType=weight) eventId: 3993beb4-... (health_record_create_succeeded, recordType=vaccine) eventId: 87a4a82b-... (page_viewed) POST /api/v1/events (4 条) → 202 ✓ 4/4 逐条 accepted(accepted=4, duplicated=0, rejected=0) ``` **落库查证(docker exec psql)**: ```text patbond=# SELECT event_name, event_version, platform, user_id, left(event_id::text,8) AS event_id_prefix, props FROM platform.product_events WHERE session_id = '4d8375a7-ec77-45bf-90d6-1b7eff73a1ff' ORDER BY event_name; event_name | event_version | platform | user_id | event_id_prefix | props --------------------------------+---------------+----------+--------------------------------------+-----------------+------------------------------------------------------- health_record_create_succeeded | 2 | android | 01a07fbd-d92f-7ee7-a52c-d33c7fa80071 | dd59a83a | {"durationMs": 640, "recordType": "weight"} health_record_create_succeeded | 2 | android | 01a07fbd-d92f-7ee7-a52c-d33c7fa80071 | 3993beb4 | {"durationMs": 820, "recordType": "vaccine"} page_viewed | 2 | android | 01a07fbd-d92f-7ee7-a52c-d33c7fa80071 | 87a4a82b | {"pageName": "pet_detail", "referrer": "pet_list"} pet_create_succeeded | 2 | android | 01a07fbd-d92f-7ee7-a52c-d33c7fa80071 | 0c673914 | {"species": "dog", "petIndex": 1, "durationMs": 1200} (4 rows) ``` 4 条 v2 事件全部落 `platform.product_events`,eventId、props 白名单键、 platform=android、user_id 归因逐项一致。(真机端上链路见 §0 挂起项 1。) ### 2.12 场景 11:乐观锁冲突明确性 ```text [11/11] 两次 PATCH 宠物档案提交同一 version → 第二次 409/40902 PATCH /pets/{id} (version=0, 第一次) → 200 ✓ 第一次 PATCH 成功,version 0→1 PATCH /pets/{id} (同一过期 version=0, 第二次/设备 2) → 409 ✓ 第二次被明确拒绝:409/40902(数据已被修改,请刷新后重试),先写者数据保留 ✓ 读回确认先写者数据保留(personality=沉稳) ``` 契约验证:不静默覆盖;先写者胜出;后写者得到明确的 409/40902 与可行动 message。 (Flutter 端对 40902 的「明确提示 + 取新 version 重提」交互已有 widget 测试覆盖, 见 `test/features/pets/pet_form_page_test.dart`。) --- ## 3. 数据库查询证据(pet_health schema) ```text patbond=# SELECT name, species, status, version, personality FROM pet_health.pets WHERE id = '01a07fbd-...0713'; name | species | status | version | personality --------+---------+--------+---------+------------- 旺财M2 | dog | active | 1 | 沉稳 patbond=# SELECT role, is_primary FROM pet_health.pet_owners WHERE pet_id = ...; role | is_primary -------+------------ owner | t patbond=# SELECT weight_kg, measured_at FROM pet_health.pet_weight_records WHERE pet_id = ... ORDER BY measured_at DESC; weight_kg | measured_at -----------+------------------------------- 8.45 | 2026-09-07 06:39:04.429336+00 8.20 | 2026-09-06 06:39:04.429336+00 patbond=# SELECT status, dose_no, administered_on, next_due_on, version FROM pet_health.pet_vaccinations WHERE pet_id = ...; status | dose_no | administered_on | next_due_on | version -----------+---------+-----------------+-------------+--------- completed | 1 | 2026-09-08 | 2027-09-08 | 1 patbond=# SELECT event_type, title, amount_cents FROM pet_health.health_events WHERE pet_id = ...; event_type | title | amount_cents ------------+-------------+-------------- medical | M2 烟囱体检 | 12500 patbond=# SELECT reminder_type, status, completed_at FROM pet_health.care_reminders WHERE pet_id = ...; reminder_type | status | completed_at ---------------+-----------+------------------------------- deworming | completed | 2026-09-08 06:39:04.429336+00 ``` 验证点:全部数据持久化落库;`pets.version=1`(一次成功 PATCH 后)与 40902 拒绝语义 互证;`amount_cents` 整数分无损;`completed_at` 与 API 回读一致。 --- ## 4. Flutter 门禁验证(三命令随行取证) ### 4.1 格式化检查 ```bash dart format --output=none --set-exit-if-changed lib test # Formatted 97 files (0 changed) in 0.39 seconds. # EXIT: 0 ``` ### 4.2 静态分析 ```bash flutter analyze # Analyzing patbond-flutter... # No issues found! (ran in 0.9s) ``` (含根目录两个 E2E 脚本在内全仓 0 issues;两脚本头部 `ignore_for_file: avoid_print`。) ### 4.3 单元/组件测试 ```bash flutter test # 00:17 +272: All tests passed! ``` **✓ 272 个测试全部通过**(E2E 脚本在仓库根目录,不被 `flutter test` 收集)。 --- ## 5. M2 四条验收标准逐条对照 | # | 验收标准 | 证据 | 结论 | | --- | --- | --- | --- | | 1 | **跨设备数据一致**:同账号第二设备读到全部数据 | 场景 9:设备 2 新会话读回宠物/体重×2/疫苗/事件/提醒全量一致;§3 psql 证实服务端持久化 | ✓ 通过 | | 2 | **无权限访问被拒**:他人宠物不可见 | 场景 8:账号 B 四路全部 404/40401 且响应体逐字节一致(防枚举);B 列表为空 | ✓ 通过 | | 3 | **并发冲突明确**:不静默覆盖 | 场景 4(疫苗 version 0→1)+ 场景 11(同 version 二次 PATCH → 409/40902,读回证实先写者保留);前端 40902 交互有 widget 测试 | ✓ 通过 | | 4 | **双端测试齐备** | 后端:compose 真实链路 11 场景全绿 + 契约测试基线(报告 20);前端:272 单元/组件测试全绿 + 门禁三命令 0 偏差 | ✓ 通过(真机两项挂起,见 §0) | ## 6. 契约偏差声明 **契约偏差数:0 个。** 本次烟囱对照冻结契约 openapi v1.2.0(报告 19 冻结)逐场景核验:HTTP 状态码 (200/201/202/404/409)、业务错误码(40401/40902)、信封结构 `{code, message, data}`、 分页正典形态、乐观锁语义、防枚举响应体、埋点逐条结果语义,全部一致,无需修复项。 --- ## 7. 环境清理 ```bash cd patbond-api && docker compose down # Container patbond-pet-1 / patbond-auth-1 / patbond-user-1 / patbond-postgres-1 Removed # Network patbond_default Removed ``` ## 8. 工作仓库状态 - patbond-flutter dev:`720865b` `test: M2 E2E 烟囱脚本(T2-18 收官)` 已推送 origin/dev - patbond-api:**代码零改动**(仅 compose 起停) - patbond-doc:本报告(28 号),提交与 mkdocs 导航由 T2-19 文档收口统一处理 ## 9. 遗留清单 1. **真机待补验 ×2**(见 §0 显著标注):Android 真机事件落库观察、SessionTracker 30min 会话超时手测——真机可用后按方案 A 补验并追加证据。 2. caregiver/viewer 角色的 403/40300 路径本次未走(M2 无邀请入口,T2-10 已用 测试数据直构场景覆盖,见报告 20),烟囱层面留待 M3 邀请流程落地后自然覆盖。 3. E2E 脚本可在 M3 纳入 CI 定期回归(当前为手动验收工具,与第一迭代建议一致)。 --- **Frontend Developer** 日期:2026-09-08 验收状态:**PASSED**(11/11 场景,契约偏差 0,M2 四条验收标准全部通过;真机两项挂起待补验)