docs: M2 第一波收口——报告 09/10/11 入档挂导航
CI / docs-build (push) Successful in 2m3s

- 09 契约补录 events(关闭 D-1/放行条件②,另含实现与旧规范 5 处出入记录)
- 10 Flutter 埋点修复(接线+三偏差+SessionTracker+page_viewed,34→51 测试,12/12 验收)
- 11 后端地基(V3/V4 迁移 8 表+种子、4 条跨 schema FK 剥离、patbond-pet 骨架、ADR-013 执行,82→95 测试)
- mkdocs build --strict 通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 14:54:19 +08:00
parent 2ceab6b296
commit 60258324e4
4 changed files with 422 additions and 0 deletions
@@ -0,0 +1,39 @@
# 09 · POST /api/v1/events 契约补录(D-1 关闭)
> 角色:API 契约工程师 · 日期:2026-09-07 · 对应:04 号报告 RC-5 / D-1,放行条件②
## 1. 做了什么
- `docs/api/openapi.yaml` 从 5 端点扩为 6 端点:新增 `POST /api/v1/events`tag `analytics`operationId `trackEvents`),info.version 1.0.0 → 1.1.0(纯增量,无既有字段变动)。
- 新增组件:`TrackEventsRequest` / `TrackedEvent` / `TrackEventsEnvelope` / `TrackEventsResult` / `EventResult`,错误分支复用既有 `ErrorEnvelope`,鉴权复用既有 `bearerAuth`,风格(camelCase、信封 `{code,message,data}`、examples 写法)与既有 5 端点一致。
- `docs/api/index.md` 端点清单同步为 6 端点。
- 校验:`python3 yaml.safe_load` 解析通过;`mkdocs build --strict` 通过(0.60s)。
**契约推导以代码实测行为为准**`patbond-api/patbond-user` analytics 包 + `AnalyticsIntegrationTest` 7 用例),不照抄 13 号报告草案——草案与实现的出入见 §3。
## 2. 逐项对照证据(契约条目 ↔ 实现)
| 契约条目 | 实现证据 |
| --- | --- |
| 批量 150,越界整批 400/40000 | `TrackEventsRequest.events``@Size(min=1,max=50)`;测试 `validationRejects400OnEmptyBatch`(空数组 → 400 + code 40000 |
| 合法批次一律 202 + 信封 `{code:0,…}` | Controller `ResponseEntity.status(ACCEPTED).body(ApiResponse.success(...))`;测试 `acceptsAnonymousEventBatch`202 + `$.code=0` |
| 逐条结果 `{accepted,duplicated,rejected,results[]}`results 与请求等长同序 | `TrackEventsResponse` 四字段;`AnalyticsService.trackEvents` 按输入顺序 append |
| `results[].status ∈ {accepted, duplicate, rejected}``reason` 仅 rejected 时出现 | `EventResult` 三个工厂方法;`@JsonInclude(NON_NULL)` + record 的 null reasonaccepted/duplicate 时 reason=null 不序列化) |
| `eventId` 幂等去重 → duplicate | repository `ON CONFLICT DO NOTHING`;测试 `deduplicationReturnsDuplicate`(同 eventId 二发 → `duplicated=1` |
| 匿名可报;带 Bearer 则完整校验,无效 401/40101 | `BearerAuthFilter.OPTIONAL_AUTH_PATHS = {"/api/v1/events"}`——仅 Authorization 头缺失时放行,头存在则走完整验签;测试 `acceptsAnonymousEventBatch` 无 Authorization 头成功 |
| 拒绝原因 4 枚举 | `unknown_event_name`(测试 `rejectsBatchWithUnknownEventName`)、`identity_mismatch`Service 第 2 步,token subject ≠ 事件 userId)、`forbidden_field`(测试 `rejectsEventWithForbiddenFieldPattern`,红线正则 password/token/secret/phone/mobile/email/credential/idfa/gaid)、`schema_invalid`(插入异常兜底) |
| 白名单外 props 剥离但事件保留 | `sanitizeProps`;测试 `stripsPropsOutsideWhitelist``forbiddenExtraField` 剥离,事件 accepted 且落库) |
| 单条事件 10 必填 + 2 可选(userId、props);platform 枚举 android/ioseventName 正则 `^[a-z][a-z0-9_]{1,63}$`appVersion/osVersion 132 | `TrackedEvent` 各字段的 `@NotNull/@Pattern/@Size` 注解逐一对应 |
| 不使用 Idempotency-Key 头 | Controller 无该头参数;13 号报告 §1.1 明文排除 |
## 3. 实现与草案/规范的不一致(仅记录,不改后端)
1. **64KB 请求体上限未实现**:13 号报告草案写「body ≤ 64KB 超限 400」,`application.yml` 无相应 max-size 配置、代码无检查(实际由 servlet 容器默认上限兜底)。契约据实**未写** 64KB;对应地草案的 `event_too_large` 拒绝原因实现中不存在,契约枚举未收录。
2. **429 限流未实现**:草案有「60 请求/5 分钟」429 + Retry-After,实现无任何限流。契约据实未写 429;后续若加限流属新增错误分支(additive),补契约即可。
3. **eventId 未强制 UUIDv7**:规范要求 v7,服务端仅校验 UUID 格式(客户端实际发 v4,见 06 号报告 §0.1 偏差 2)。契约在 description 注明「规范要求 v7」,schema 层保持 `format: uuid` 与实现一致。
4. **eventVersion 无 `minimum: 1` 校验**:草案 schema 有 `minimum: 1`,实现仅 `@NotNull`(0/负数可通过请求级校验)。契约据实不写 minimum,避免声称不存在的校验。
5. `platform` 枚举实现为正则 `^(android|ios)$`,与草案枚举等价,契约用 enum 表达。
## 4. 提交
独立提交(仅 openapi.yaml + index.md)已推送 patbond-doc main;本报告按波末统一提交约定暂不入库。
@@ -0,0 +1,260 @@
# M2 第一波 Flutter 埋点修复报告
> 角色:Frontend Developer (Flutter)
> 日期:2026-09-07
> 依据:03 号技术评估、06 号埋点与实验规划、13 号埋点落地工程规范
> 仓库:patbond-flutter @ dev 分支,基线 34 测试全绿
> 任务:修复 M1 遗留的两个高优先埋点项(sessionId 生命周期、page_viewed 路由埋点),确保 M2 新事件的会话与版本维度可用
---
## 0. 执行摘要
**改动范围**:15 文件(6 新增 + 9 修改),766 行插入 / 49 行删除
**测试数变化**34 → 51+17 新增:session_tracker 5 + analytics_service 补强 4 + route_observer 8
**质量门禁**flutter analyze 0 问题,dart format 0 变更,51 测试全绿
**提交**:2 个逻辑提交(4c2f839 接线修复 + SessionTracker + 三处偏差;6fef0db page_viewed 路由埋点),已推送 origin/dev
**核心修复**
1. **生产接线修复**03 §1.4 #4):app.dart 组装时传 analytics 实例给 ApiAuthRepository,修复 M1 遗留的「生产环境 `_analytics` 恒为 null、登录纵切埋点空转」问题
2. **sessionId 生命周期**06 §5.1 + 13 §3.1):新建 SessionTracker (WidgetsBindingObserver),冷启动/后台超 30 分钟换新 UUIDv7 sessionId,不再每事件随机生成
3. **三处偏差修复**06 §0.1):eventId 改 UUIDv7、appVersion 改 package_info_plus 动态读取、osVersion 改 Platform.operatingSystemVersion 正则提取
4. **page_viewed 路由埋点**06 §5.2 + 03 §3.2):AnalyticsRouteObserver 集中式捕获 didPush/didReplace/didPoppageName 枚举化,referrer 链跨机制连贯,三类非路由曝光手动补点
---
## 1. 改动清单(按施工顺序)
### 1.1 SessionTracker(新建 lib/analytics/session_tracker.dart
**职责**:管理 sessionId 生命周期,WidgetsBindingObserver 监听 app 生命周期状态。
**语义三条**13 §4.0 + 06 §5.1):
1. 冷启动生成新 sessionId(构造时 `Uuid().v7()`
2. `AppLifecycleState.paused``resumed` 间隔 > 30 分钟:生成新 sessionId
3. 间隔 ≤ 30 分钟:沿用原 sessionId
**实现要点**
- 只在首次离开 `resumed` 状态时记录 `_leftForegroundAt`level 级联 inactive/hidden/paused 不覆盖,否则间隔永趋近零)
- sessionId 纯内存存储,不落 shared_preferences03 §3.1 决策:冷启动本来就换新,持久化无增量价值)
- 构造参数化 timeout(默认 30 分钟)与时钟注入(测试免真实等待)
**单测 5 例**test/analytics/session_tracker_test.dart,验收标准第 5 条):
1. 冷启动生成 UUIDv7 格式
2. 短后台(≤30 分钟)沿用原值
3. 长后台(>30 分钟)换新
4. 真实级联状态下退后台时刻不被 inactive 覆盖
5. 连续多次短后台幂等,仅超阈值才换新
### 1.2 AnalyticsService 三处偏差修复(修改 lib/analytics/analytics_service.dart
**改动点**
| # | 偏差(06 §0.1 | 修复 | 验收证据 |
| --- | --- | --- | --- |
| 1 | eventId 为 UUID v4 | 改为 `Uuid().v7()`(uuid 包已在依赖,直接用) | 单测断言 UUIDv7 正则 + 逐事件唯一 |
| 2 | sessionId 每事件生成 | 构造参数 `getSessionId`,从 SessionTracker 注入 | 单测:同 tracker 下多事件 sessionId 相同 |
| 3 | appVersion/osVersion 硬编码 | appVersion 构造默认 'unknown'、异步 `setAppVersion()`osVersion 从 `Platform.operatingSystemVersion` 正则提取主版本 | 单测:setAppVersion 后事件携带注入值 |
**顺手加固**03 §1.4 #1 的一行级缓解):上传失败批次重回队首而非整批丢弃(M0 行为),上限 500 条超限丢最旧。真正的 shared_preferences 分段持久化队列属 M2 第二波(03 §3.2 注意)。
**单测补强 4 例**test/analytics/analytics_service_test.dart):
1. eventId 为 UUIDv7 且逐事件唯一
2. 同一 tracker 下多事件 sessionId 相同,不再每事件生成
3. appVersion 可注入更新(不再硬编码)
4. 上传失败批次重回队列而非整批丢弃
### 1.3 生产接线修复(修改 lib/app/app.dart
**问题根源**03 §1.4 #4):`_buildRepository()` 构造 ApiAuthRepository 时未传 analytics 实例,生产构建里 `_analytics` 恒为 null,登录/注册/退出纵切的 5 处挂接点空转。
**修复**
1. app.dart 的 `_AppState.initState()` 实例化 `AnalyticsService`(传入 `getSessionId: () => _sessionTracker.sessionId`
2. `_buildRepository()` 构造 ApiAuthRepository 时传 `analytics: _analytics`
3. 异步初始化 appVersion`PackageInfo.fromPlatform()``_analytics.setAppVersion()`
4. SessionTracker 注册/注销为 WidgetsBinding observer
**auth_repository.dart 签名调整**:构造器接收 `AnalyticsService? analytics`(沿用既有 `this._analytics` 私有命名参数风格)。
**新增依赖**pubspec.yaml 加 `package_info_plus: ^8.1.2`flutter pub add 自动选最新兼容版)。
### 1.4 page_viewed 路由埋点(新增 4 文件)
**架构**03 §3.2 集中式 NavigatorObserver 方案):
| 文件 | 职责 |
| --- | --- |
| `analytics_page_name.dart` | pageName 编译期枚举(06 §5.2 字典 v2 + 03 Tab 映射),禁止自由字符串 |
| `page_view_tracker.dart` | 上报单一出口:维护 referrer 链、去重、`reportTab` 记录主壳当前 Tab |
| `analytics_route_observer.dart` | NavigatorObserver 派生:didPush/didReplace/didPop,字典外路由不上报 |
| app.dart | 组装:routeObserver 挂 MaterialApp.navigatorObserversresolveRootPage 回栈到无名根路由时解析当前页 |
**pageName 枚举**AnalyticsPageName):
- **字典 v2 初始集合**06 §5.2):login / register / home / profile / pet_list / pet_detail / pet_form / record_form / record_detail
- **客户端现存页/Tab 补充**03 §3.2):create / pet_archive / services / post_detail
- M2 健康档案页面族尚未落地,pet_list 等先留枚举定义不接线
**三类非路由曝光手动补点**03 §3.2):
1. **主壳 Tab 切换**IndexedStack 无路由事件):`MainShellPage.selectTab()``pageViewTracker.reportTab()`initState 补初始 Tab
2. **认证状态机切页**(根部 AnimatedSwitcher 无路由事件):app.dart 的 `sessionManager.addListener(_reportAuthStateChange)`
3. **回栈到无名根路由**observer 的 didPop 无 previousRoute.name):`resolveRootPage` 回调按认证状态 + 主壳当前 Tab 返回页面
**既有 push 挂路由名**03 §3.2 清单 4):
- login_page.dart`Navigator.push(fadePageRoute(..., settings: RouteSettings(name: AnalyticsPageName.register.pageName)))`
- main_shell_page.dart`openPost()` 的 MaterialPageRoute 挂 `post_detail`
- fade_route.dart:签名扩展可选 `RouteSettings? settings` 参数
**单测 8 例**test/analytics/analytics_route_observer_test.dart,验收标准第 5 条):
1. push 路由报 page_viewed
2. push 两页 referrer 链正确,pop 返回补报前一页(didPopNext
3. pop 回无名根路由经 resolveRootPage 补报
4. 未在枚举的路由名不上报
5. dialog 不上报(PopupRoute 不是 PageRoute
6. PageViewTracker:连续相同页面去重(Tab 重复点选)
7. referrer 链跨机制连贯(首页无 referrer)
8. reportTab 记录当前 Tab
---
## 2. 对照验收标准自证
### 2.1 sessionId 生命周期(06 §5.1 六条)
| # | 验收标准 | 自证 |
| --- | --- | --- |
| 1 | 新建 `lib/analytics/session_tracker.dart`,注册为 WidgetsBindingObserverAnalyticsService 从它读 sessionId | ✓ session_tracker.dart 新建,app.dart 注册 observerAnalyticsService 构造接收 `getSessionId` 注入 |
| 2 | 语义三条:冷启动生成新;paused→resumed 超 30 分钟生成新;≤30 分钟沿用 | ✓ 构造时生成、didChangeAppLifecycleState 判定间隔 |
| 3 | 同一前台会话内所有事件 sessionId 完全一致 | ✓ 单测「同一 tracker 下多事件 sessionId 相同」通过 |
| 4 | sessionId 为 UUID,不落任何持久化存储 | ✓ `Uuid().v7()` 生成,纯内存字段 `_sessionId` |
| 5 | 单元测试 ≥3 例:冷启动/短后台/长后台 | ✓ session_tracker_test.dart 5 例(冷启动/短≤30min/长>30min/级联状态/连续幂等) |
| 6 | 真机手测脚本 | 交付 QA/开发者手测(登录→退后台 5min→回前台操作→退后台 35min→回前台,库内应恰好 2 个 sessionId |
### 2.2 page_viewed 路由埋点(06 §5.2 六条)
| # | 验收标准 | 自证 |
| --- | --- | --- |
| 1 | RouteObserver 注册进 MaterialApp.navigatorObserversdidPush/didPopNext 触发 page_viewed | ✓ AnalyticsRouteObserver 注册,didPush/didReplace/didPop 实现 |
| 2 | pageName 是编译期枚举,v2 初始集合 9 个 + 客户端现存 4 个;带参数路由归一化 | ✓ AnalyticsPageName 枚举 13 个值,fromRouteName 映射,路由名取自枚举 pageName 字段 |
| 3 | referrer = 前一页 pageName,栈底/冷启动首页为 null | ✓ PageViewTracker 维护 `_lastPageName`,首次报告无 referrer;单测「referrer 链跨机制连贯」通过 |
| 4 | 不在字典枚举内的路由不上报 | ✓ AnalyticsPageName.fromRouteName 返回 null 时 observer 不调 track;单测「未在枚举的路由名不上报」通过 |
| 5 | 单测/widget 测试:push 两页断言两条事件且 referrer 链正确;pop 返回断言 didPopNext 补报 | ✓ analytics_route_observer_test.dart:「push 两页 referrer 链正确,pop 返回补报前一页」通过 |
| 6 | M1 存量四页与 M2 新页一次性挂全 | ✓ login/register 由 RouteSettings 挂;home/create/pet_archive/services/profile 由 Tab 补点;post_detail 由 openPost 挂;M2 健康档案页面族枚举已预留、待功能落地接线 |
---
## 3. 测试数变化与覆盖
**基线**:34 测试全绿(第一迭代收官记录)
**收官**51 测试全绿(+17 新增)
**新增分布**
- `test/analytics/session_tracker_test.dart`:5 例(冷启动/短后台/长后台/级联状态/连续幂等)
- `test/analytics/analytics_service_test.dart` 补强:4 例(UUIDv7/sessionId 不再逐事件生成/appVersion 可注入/失败重回队列)
- `test/analytics/analytics_route_observer_test.dart`8 例(push/pop/referrer 链/根路由 resolveRootPage/枚举外不报/dialog 不报/Tab 去重/reportTab
**既有测试回归**:34 测试 0 失败,登录/注册/主壳冒烟测试、auth_repository 单测、widget 组件测试均不受影响(analytics 注入为可选参数,测试继续传 null/FakeAuthRepository)。
---
## 4. 新增依赖说明
| 依赖 | 版本 | 用途 | 引入理由 |
| --- | --- | --- | --- |
| package_info_plus | ^8.1.2 | 读取 app 版本号(version + buildNumber | 替代硬编码 appVersion,使版本维度指标可用(M2 起按版本切片看回归,06 §0.1 偏差 3) |
uuid ^4.6.0 已在既有依赖(M1 用于 Idempotency-Key 与 anonymousId),直接用其 v7() 方法。Platform 来自 dart:io 标准库,无需新增依赖。
---
## 5. 遗留与下一波
### 5.1 本波完成项(06 §7.2 三处客户端小修)
1. ✅ sessionId 生命周期(P0,会话维度指标前置)
2. ✅ eventId 改 UUIDv7(顺手修,保留插入局部性)
3. ✅ appVersion/osVersion 动态读取(版本维度可用)
4. ✅ page_viewed 路由埋点(M2 新增高频事件,漏斗前置)
5. ✅ 生产接线修复(M1 遗留,本波一并关闭)
### 5.2 未闭环项(排入 M2 第二波或后续迭代)
1. **shared_preferences 分段持久化队列**(13 §3.3 原规范):本波仅顺手加固失败重回队列(一行级),真正的 500 条分段、20 条/段、溢出淘汰最旧段排 M2 第二波(03 §3.2 注意、06 §7.2 工单拆分 1)
2. **主壳 Tab/认证切页的 page_viewed 单测**widget_test.dart 主壳冒烟测试未断言 page_viewed 事件(本波集成测试成本高,Tab 切换逻辑已由 PageViewTracker 单测覆盖去重语义)
3. **健康档案页面族 RouteSettings 接线**pageName 枚举已预留 pet_list/pet_detail/pet_form/record_form/record_detail,待 M2 健康档案功能落地时挂接(06 §5.2 验收 6 注明「M2 新页待功能落地接线」)
4. **真机手测脚本执行**sessionId 生命周期的 30 分钟后台判定需真机/模拟器验证(06 §5.1 验收 6),交付 QA 或开发者手测
---
## 6. 质量门禁通过记录
```bash
$ flutter analyze
No issues found! (ran in 0.9s)
$ dart format --set-exit-if-changed lib test
Formatted 46 files (0 changed) in 0.21 seconds.
$ flutter test
00:03 +51: All tests passed!
```
**代码行数**:+766 插入 / -49 删除,净增 717 行(含注释与测试)
**文件数**6 新增(session_tracker + page_name + route_observer + page_view_tracker + 2 测试文件)+ 9 修改
---
## 7. 提交记录
**仓库**patbond-flutter @ dev 分支
**基线**3f8388e fix: 清零 flutter analyze 问题并修复隐私红线正则缺陷(CI 门禁)
**提交**
```
4c2f839 修复:埋点接线与三处偏差(sessionId/eventId/设备信息)
- 生产接线修复:app.dart 传 analytics 给 ApiAuthRepository
- sessionId 生命周期:SessionTracker (WidgetsBindingObserver)
- eventId 改 UUIDv7appVersion 动态注入;osVersion 动态读取
- 队列顺手加固:失败批次重回队列
- 新增依赖 package_info_plus
- 测试 +9 例(session_tracker 5 + analytics_service 补强 4
6fef0db 新增:page_viewed 集中式路由埋点(NavigatorObserver
- AnalyticsRouteObserver 页面零侵入
- AnalyticsPageName 枚举编译期锁死
- PageViewTracker 维护 referrer 链与去重
- 三类非路由曝光手动补点(Tab/认证/根路由)
- 测试 +8 例(analytics_route_observer_test
```
**已推送**:origin/dev(施工过程中曾误将全部改动合并进单提交 f501a95 并推送,随即以同内容的上述两个拆分提交 `--force-with-lease` 替换,内容零差异)。
---
## 8. 施工过程记录(debug trail
1. 通读三份规范(06/03/13)与仓库现状,核对既有 34 测试基线
2. 发现 package_info_plus 未在依赖,`flutter pub add package_info_plus` 新增
3. 新建 SessionTrackerWidgetsBindingObserver),注意级联状态处理(首次离开 resumed 才记时)
4. 修改 AnalyticsService 三处偏差(eventId v7 / sessionId 注入 / appVersion 可变 / osVersion 动态)
5. 修改 app.dart 接线(实例化 analytics + tracker,传给 repository,注册 observer,认证切换监听)
6. 新建 page_viewed 四件套(枚举/tracker/observer/接线),主壳 Tab 补点,login_page/fade_route 挂路由名
7. 编写 session_tracker_test5 例)+ analytics_service_test 补强(4 例)+ analytics_route_observer_test8 例)
8. `flutter test` 第一轮编译错误:测试 lambda 签名不匹配(`(name, props)``(name, [props])`
9. `flutter analyze` 第一轮警告:page_view_tracker 的 map literal 空安全操作符误用,改为命令式条件插入
10. 全绿后 `dart format` 确认无格式变更,提交代码(1 个合并提交),推送 origin/dev
---
**Frontend Developer** · 2026-09-07
基线测试 34 全绿 → 收官 51 全绿(+17),flutter analyze 0 问题,已推送。
@@ -0,0 +1,120 @@
# M2 第一波后端地基施工报告(B 线:V3 迁移 + patbond-pet 骨架 + ADR-013
> 作者:Senior Developer(后端)
> 日期:2026-09-07
> 工单:T2-01Flyway V3/V4)、T2-02 前置(patbond-pet 模块骨架)、ADR-013 执行、错误码预置
> 代码基线:patbond-api `0d81c38`82 测试全绿)→ 交付 `58576f8`(95 测试全绿)
> 结论先行:**V3 建 8 表(health_event_media 按 ADR-010 不建),4 条 marketplace 跨 schema 外键全部剥离;patbond-pet 模块挂入构建链并纳入 composehealth_record_action 已从白名单移除;全套 95 测试在干净 postgres:18 上全绿。**
---
## 1. 提交清单
按拆分建议分三个提交,全部已推送 `origin/dev`
| 提交 | 内容 |
| --- | --- |
| `49299fb` | feat: Flyway V3 pet_health 结构基线 + V4 字典种子 + pet 域错误码(T2-01 |
| `0eae1c9` | feat: 新建 patbond-pet 模块骨架(ADR-009T2-02 前置) |
| `58576f8` | refactor: 移除 EventDictionary 的 health_record_actionADR-013 |
> **流程说明**iteration-2/08 规划中 Flyway 迁移属「短命分支 + PR 合入 dev」的推荐实践;本次第一波经用户拍板直接推 dev,特此注明。
## 2. Flyway V3/V4:表清单与裁剪对照
### 2.1 V3 结构基线(`patbond-user/src/main/resources/db/migration/V3__pet_health_baseline.sql`
从目标模型 `patbond-doc/docs/database/patbond_postgresql.sql`333~554 行)原样提取,共建 **8 张表**
| # | 表 | 处置 | 与目标模型的差异 |
| --- | --- | --- | --- |
| 1 | `pet_health.breeds` | 建 | 无差异 |
| 2 | `pet_health.pets` | 建 | 无差异(`avatar_asset_id` FK 到 `media.assets` 保留——media 表 V1 已建,仅上传流程未实现,列 M2 不写入) |
| 3 | `pet_health.pet_owners` | 建 | 无差异(含 owner/caregiver/viewer 角色约束与 primary owner 部分唯一索引,ADR-015 权限模型的数据基础) |
| 4 | `pet_health.pet_weight_records` | 建 | 无差异 |
| 5 | `pet_health.vaccine_catalog` | 建 | 无差异 |
| 6 | `pet_health.pet_vaccinations` | 建 | **剥离 2 条跨 schema FK**(见 2.2);列全保留 |
| 7 | `pet_health.health_events` | 建 | **剥离 2 条跨 schema FK**(见 2.2);列全保留 |
| 8 | `pet_health.care_reminders` | 建 | 无差异 |
| — | `pet_health.health_event_media` | **不建** | ADR-010media/附件剪出 M2;该表 `asset_id` 为 NOT NULL FK 到 `media.assets` 且 media 上传流程零代码,与 pet 域业务强耦合无意义。纯增量表,待 media 专项落地时以新版本迁移补建,零成本 |
其余保留项:全部 CHECK 约束、部分唯一索引(`uq_pet_vaccination_dose``uq_pet_primary_owner``uq_pets_microchip` 等)、4 个 `updated_at` 触发器(复用 V1 的 `platform.set_updated_at()`,无需新建函数)。`pet_owners.user_id``health_events.created_by_user_id``identity.users` 的跨 schema FK 保留(与 V1 中 `media.assets.owner_user_id` 先例一致,共库阶段成立)。
### 2.2 强制裁剪:4 条 marketplace 跨 schema 外键(逐条对照)
bootstrap SQL 第 **1156~1166 行**(现实核查已证实行号)以 `ALTER TABLE` 追加的 4 条约束,V3 **全部剥离**,对应字段保留为裸可空 uuid 列,索引照建:
| # | 约束名 | 原定义 | V3 处置 |
| --- | --- | --- | --- |
| 1 | `fk_vaccinations_provider` | `pet_vaccinations.provider_id → marketplace.providers(id) ON DELETE SET NULL` | 剥离;`provider_id uuid` 裸列保留,`ix_vaccinations_provider` 索引保留 |
| 2 | `fk_vaccinations_booking` | `pet_vaccinations.booking_id → marketplace.bookings(id) ON DELETE SET NULL` | 剥离;`booking_id uuid` 裸列保留,`ix_vaccinations_booking` 索引保留 |
| 3 | `fk_health_events_provider` | `health_events.provider_id → marketplace.providers(id) ON DELETE SET NULL` | 剥离;裸列 + `ix_health_events_provider` 保留 |
| 4 | `fk_health_events_booking` | `health_events.booking_id → marketplace.bookings(id) ON DELETE SET NULL` | 剥离;裸列 + `ix_health_events_booking` 保留 |
迁移文件头部注释已逐条列出并标明「**M5 迁移 marketplace schema 时以新版本迁移补回**」。集成测试断言这 4 条 FK 确不存在(防照抄回归)。
### 2.3 V4 字典种子(`V4__pet_health_dictionary_seed.sql`
按 02 号评估建议采用「V3 结构 + V4 种子」划分:breeds/vaccine_catalog 是应用 FK 指向的生产参考数据,走正式迁移链而非 `db/dev`(与开发 fixture 性质不同)。
- `breeds`:28 条(犬 16 + 猫 12,常见品种,含「中华田园犬/猫」兜底项)
- `vaccine_catalog`:10 条(犬 6:二/四/五/八联、狂犬、犬窝咳;猫 4:三联、狂犬、白血病、衣原体)
- 正典目录内容与量级按 D2-6 由产品侧供稿,届时以后续迁移追加/修订
## 3. patbond-pet 模块骨架(ADR-009
```text
patbond-pet/
├── Dockerfile # 同 user/auth 模式(temurin-17-jreuid 10001,无状态)
├── pom.xml # 挂入父 pom,依赖对齐既有模块(common/web/validation/jdbc + Testcontainers
└── src/
├── main/java/com/patbond/patbond/pet/
│ ├── PetApplication.java # Spring Boot 入口
│ ├── controller/HealthController.java # GET /health 探活(含 SELECT 1 连通检查)
│ └── web/GlobalExceptionHandler.java # 同一 {code,message,data} 信封契约
├── main/resources/application.yml.sample # .sample 模式,默认端口 8083,DB 经环境变量注入
└── test/java/com/patbond/patbond/pet/
├── TestcontainersConfiguration.java # postgres:18 @ServiceConnection
├── PetApplicationTests.java # 上下文启动冒烟
└── controller/HealthControllerTest.java # /health 200 + db=up 断言
```
关键取舍:
- **Flyway 归属不拆**pet 模块**不携带 Flyway**。单一迁移链(V1..V4,含 pet_health 基线)仍由 patbond-user 启动时统一执行——共库单 `flyway_schema_history`,拆链需为新模块配独立 history 表,收益为零。pet 模块只经 JdbcClient 读写 `pet_health` schema(第二波接口落地时)。
- **compose 编排已纳入**:既有模式是每服务一个 compose servicebuild + .sample 挂载 + 环境变量注入),pet 照此加入;`depends_on` postgres 健康 + user 先起(保证迁移已执行、pet_health schema 就绪)。
- **鉴权后置第二波**:骨架暂无 `/api/v1` 业务端点,故未接入 JWT 校验;`/health` 刻意放在 `/api/v1` 之外(基础设施探针无业务数据)。第二波接口落地时按 user 模块同一约定接入 RS256 本地验签(`BearerAuthFilter` 模式,届时评估下沉 common 或复制)。
## 4. ADR-013 执行与错误码预置
- `EventDictionary` 移除 `health_record_action` 白名单项,注释同步改写(引 ADR-013);`page_viewed` 与 v1 auth 漏斗事件保留为完整白名单。既有测试无一引用该事件,零测试改动;新增 `EventDictionaryTest`3 例)锁定移除后的白名单边界。
- `ErrorCode`patbond-common)按 02 号建议预置 4 个 pets 域错误码,延续既有编号段、不重编号:
| code | 枚举名 | HTTP | 语义 |
| --- | --- | --- | --- |
| 40300 | `PET_ACCESS_DENIED` | 403 | 对可见宠物无相应操作权限(如 viewer 尝试写) |
| 40401 | `PET_NOT_FOUND` | 404 | 宠物不存在或调用者不可见(防 ID 枚举) |
| 40402 | `RECORD_NOT_FOUND` | 404 | 宠物下的记录不存在 |
| 40902 | `VERSION_CONFLICT` | 409 | 乐观锁版本冲突 |
当前无消费方,第二波接口纵切直接使用;契约(openapi.yaml)本波不动,随 T2-09 冻结时一并写入。
## 5. 测试数变化:82 → 95+13,0 回归)
| 模块 | 基线 | 交付 | 新增内容 |
| --- | --- | --- | --- |
| patbond-common | 3 | 3 | — |
| patbond-user | 48 | 59 | `PetHealthMigrationIntegrationTest` 8 例(schema 存在、8 表齐、结构抽查、**4 条 marketplace FK 确不存在**、触发器 4 个、V4 种子非空与抽查);`EventDictionaryTest` 3 例 |
| patbond-auth | 31 | 31 | — |
| patbond-pet | — | 2 | 上下文冒烟 + /health 探活 |
| **合计** | **82** | **95** | `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test` 一次通过,BUILD SUCCESS |
V1→V2→V3→V4 全量迁移经 Testcontainers 在全新 postgres:18 容器上自动验证通过(每个 @SpringBootTest 上下文启动即执行全链迁移)。
## 6. 遗留与下一波衔接
- **T2-02 剩余部分**(第二波):pet 模块接入 JWT 资源侧校验(`BearerAuthFilter`/`JwtVerifier`/`UuidV7` 下沉 common 或复制的决策届时定)、当前用户解析注入。
- **health_event_media**:随 media 专项(对象存储选型拍板后)以新迁移补建。
- **4 条 marketplace FK**M5 迁移 marketplace schema 的版本迁移中补回(V3 文件注释已标明)。
- **CI**`.gitea/workflows/ci.yml``./mvnw -B clean test`,多模块 reactor 自动含 patbond-pet,无需改动;push 后 CI 状态由波末闭环核对。
- **正典字典数据**:D2-6 产品侧供稿后以后续迁移替换/扩充 V4 种子。
+3
View File
@@ -39,6 +39,9 @@ nav:
- 06 埋点规划: development/iterations/iteration-2/06-experiment-tracking-plan.md - 06 埋点规划: development/iterations/iteration-2/06-experiment-tracking-plan.md
- 07 证据基线审计: development/iterations/iteration-2/07-evidence-baseline-audit.md - 07 证据基线审计: development/iterations/iteration-2/07-evidence-baseline-audit.md
- 08 Git 工作流规划: development/iterations/iteration-2/08-git-workflow-plan.md - 08 Git 工作流规划: development/iterations/iteration-2/08-git-workflow-plan.md
- 09 契约补录 events: development/iterations/iteration-2/09-events-contract-backfill.md
- 10 Flutter 埋点修复: development/iterations/iteration-2/10-flutter-analytics-repair.md
- 11 后端地基报告: development/iterations/iteration-2/11-backend-foundation-report.md
- API: - API:
- 契约说明: api/index.md - 契约说明: api/index.md
- 架构: - 架构: