Files
patbond-doc/docs/development/iterations/iteration-2/22-pets-feature-datalayer.md
T
lixi b81c050e03
CI / docs-build (push) Successful in 58s
docs: M2 第三波收口——报告 22~27 入档挂导航
- 22~26 Flutter 接入五单报告(T2-11~14 + 白名单扩充,flutter 测试 64→272)
- 27 第三波收口总表:demo 数据消亡、四态纪律、埋点端到端贯通、DEBT-1 偿还
- 三次 compose 实测无契约偏差;波内 agent 中断续跑事故记录在案

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

142 lines
11 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.
# T2-11 pets feature 状态拆分与 API Client(数据层交付报告)
**执行日期**2026-09-08
**角色**Frontend DeveloperFlutter
**工单**T2-11M2 第三波前置,T2-12~14 依赖本单数据层)
**契约依据**`docs/api/openapi.yaml` v1.2.0(冻结)+ 21 号收口报告 §1 定型语义
**提交**patbond-flutter dev@`7fb9031`(基线 33b993c,已 push origin dev
---
## 0. 结论摘要
- pets 域 **12 路径 / 18 操作全部覆盖**DTO 逐字段对齐冻结契约;
- 新 8 个错误码全部映射为类型化异常,复用既有网络层与 token 拦截;
- 宠物档案状态自 `AppState` 拆出为独立 pets featureController → Repository → API Client);pets feature 零依赖 AppState demo 数据(AppState 的既有消费方按工单不动,留给 T2-12);
- 测试 **64 → 126 全绿**`flutter analyze` 0 问题,`dart format` 无 diff。
## 1. 分层结构
```text
T2-12 接入)Page/Widget
PetsControllerlib/features/pets/pets_controller.dart
· ChangeNotifier;宠物档案列表/详情内存副本
· 四态:initial / loading / ready(含 isEmpty 空态) / error(+lastError)
· refresh 收敛错误为 error 态;create/update 类型化异常外抛给表单层
PetsRepository(抽象)/ ApiPetsRepositorylib/features/pets/pets_repository.dart
· 18 操作全量方法;路径/方法/查询参数/请求体按契约组装
· 四个 POST 自动携带 Idempotency-Keyuuid v4,每次逻辑提交换新键)
· ApiBusinessException → pets 域类型化异常升格(pet_exceptions.dart
ApiClientlib/core/network/api_client.dart,既有复用)
· 统一信封解析 {code,message,data}、validateStatus 放行
· Bearer 注入 + 401/40101 单飞刷新重放(重放沿用同一幂等键,有测试锁定)
· 本单增量:query 参数支持;patbondPetApiBaseUrl:8083
patbond-pet 服务 http://127.0.0.1:8083--dart-define=PATBOND_PET_API_BASE_URL 可覆盖)
```
支撑文件:
| 文件 | 职责 |
|------|------|
| `lib/features/pets/pet_models.dart` | 全部响应/请求 DTO + 10 个枚举 + CursorPage 分页信封 |
| `lib/features/pets/pet_exceptions.dart` | 8 个类型化异常 + `mapPetBusinessException` |
| `lib/features/pets/money.dart` | 元/分换算工具(DTO 层保持整数分,T2-14 UI 使用) |
| `lib/core/network/api_exception.dart` | ApiCodes 补 pets 域 8 码;ApiBusinessException 开放继承 |
## 2. DTO / Client 覆盖清单(对照契约 18 操作)
| # | operationId | 方法 路径 | Repository 方法 | DTO | 状态 |
|---|-------------|-----------|-----------------|-----|------|
| 1 | listPets | GET /api/v1/pets | `listPets()` | `Pet`(含 myRole | ✅ |
| 2 | createPet | POST /api/v1/pets | `createPet(CreatePetRequest)` | `CreatePetRequest``Pet`(201;不带幂等键,契约由唯一约束兜底) | ✅ |
| 3 | getPet | GET /api/v1/pets/{petId} | `getPet(petId)` | `Pet` | ✅ |
| 4 | updatePet | PATCH /api/v1/pets/{petId} | `updatePet(petId, UpdatePetRequest)` | `UpdatePetRequest`(version 必填、缺席字段不发) | ✅ |
| 5 | listBreeds | GET /api/v1/breeds | `listBreeds({species})` | `Breed` | ✅ |
| 6 | listWeights | GET /api/v1/pets/{petId}/weights | `listWeights(petId, {limit, cursor})` | `CursorPage<WeightRecord>` | ✅ |
| 7 | createWeight | POST /api/v1/pets/{petId}/weights | `createWeight(...)` | `CreateWeightRequest``WeightRecord`Idempotency-Key ✅) | ✅ |
| 8 | listVaccineCatalog | GET /api/v1/vaccine-catalog | `listVaccineCatalog({species})` | `VaccineCatalogItem` | ✅ |
| 9 | listVaccinations | GET /api/v1/pets/{petId}/vaccinations | `listVaccinations(petId)` | `Vaccination`(不分页,服务端排序原样保留) | ✅ |
| 10 | createVaccination | POST /api/v1/pets/{petId}/vaccinations | `createVaccination(...)` | `CreateVaccinationRequest`Idempotency-Key ✅) | ✅ |
| 11 | updateVaccination | PATCH /api/v1/vaccinations/{vaccinationId} | `updateVaccination(...)` | `UpdateVaccinationRequest`(顶层短路径;vaccineId/seriesKey/doseNo 不在请求体) | ✅ |
| 12 | listHealthEvents | GET /api/v1/pets/{petId}/health-events | `listHealthEvents(petId, {limit, cursor})` | `CursorPage<HealthEvent>` | ✅ |
| 13 | createHealthEvent | POST /api/v1/pets/{petId}/health-events | `createHealthEvent(...)` | `CreateHealthEventRequest`amountCents 整数分;Idempotency-Key ✅) | ✅ |
| 14 | updateHealthEvent | PATCH /api/v1/health-events/{eventId} | `updateHealthEvent(...)` | `UpdateHealthEventRequest`(仅 title/notes/amountCents | ✅ |
| 15 | listCareReminders | GET /api/v1/pets/{petId}/care-reminders | `listCareReminders(petId, {status})` | `CareReminder`(status 白名单过滤参数) | ✅ |
| 16 | createCareReminder | POST /api/v1/pets/{petId}/care-reminders | `createCareReminder(...)` | `CreateCareReminderRequest`(不收 statusIdempotency-Key ✅) | ✅ |
| 17 | updateCareReminder | PATCH /api/v1/care-reminders/{reminderId} | `updateCareReminder(...)` | `UpdateCareReminderRequest`(仅 status+completedAt | ✅ |
| 18 | getPetSummary | GET /api/v1/pets/{petId}/summary | `getPetSummary(petId, {tz})` | `PetSummary`(tz 参数;四聚合嵌套对象) | ✅ |
契约语义落点:
- **分页信封**`CursorPage<T>` 严格按 `{items, nextCursor, hasMore}` 解析,nextCursor 视为不透明串;末页 nextCursor 缺席/null 同义处理(有测试)。
- **PetSummary null 语义**latestWeight / vaccinationProgress / nextVaccination 三项无记录为 nullmonthlyExpense 恒非 null、无支出 amountCents=0dueOn 允许过去日期(逾期针)——均有 DTO 测试锁定。
- **金额**DTO 层保持 `amountCents` 整数分(`int?`),换算工具 `formatCentsAsYuan` / `parseYuanToCents`(拒绝超两位小数/负数)随本单交付并带单测。
- **部分更新语义**:全部 Update 请求 toJson 只发送提交的字段(缺席≠null),version 恒带(提醒无 version,按契约仅 status+completedAt)。
- **枚举严格解析**:10 个枚举未知取值抛 FormatException——契约漂移在测试期显式暴露而非静默吞掉。
- **幂等**weights/vaccinations/health-events/care-reminders 四个 POST 自动携带 uuid v4 幂等键,每次逻辑提交换新键;token 刷新后的自动重放沿用同一键(测试锁定);createPet 按契约不带键。
## 3. 错误映射表(新 8 码 → 类型化异常)
映射发生在 `ApiPetsRepository._request``mapPetBusinessException`),全部继承 `ApiBusinessException`,既有按基类捕获的通用处理不受影响;每条映射均有单测。
| 错误码 | HTTP | 类型化异常 | 语义 / 客户端处理 |
|--------|------|-----------|------------------|
| 40300 | 403 | `PetAccessDeniedException` | 对可见宠物无操作权限(viewer 写、非 owner 改档案)→ 隐藏/禁用写入口 |
| 40401 | 404 | `PetNotFoundException` | 宠物不存在/软删/无关系(防枚举三态同响应)→ 返回列表并刷新 |
| 40402 | 404 | `PetRecordNotFoundException` | 记录级防枚举 → 刷新所在列表 |
| 40902 | 409 | `PetVersionConflictException` | 乐观锁冲突(提醒条件更新守卫同码)→ 提示刷新取新 version 重提 |
| 40903 | 409 | `MicrochipTakenException` | 芯片号已被登记 → 字段级报错 |
| 40904 | 409 | `VaccinationDoseExistsException` | 同系列同剂次已存在 → 表单提示(cancel 后可重建) |
| 42201 | 422 | `VaccinationRuleException` | 疫苗状态机/状态-日期规则违反 → 表单拦截兜底提示 |
| 42202 | 422 | `CareReminderRuleException` | 提醒状态机/completedAt 一致性违反 → 表单拦截兜底提示 |
| 40000 等未列码 | — | 保持 `ApiBusinessException` | 沿用通用处理(有测试锁定不误升格) |
网络/会话类沿用既有:`ApiNetworkException`(超时/断网/5xx)、`ApiRateLimitException`429)、`SessionExpiredException`(刷新失败清会话)。
## 4. 测试数变化
| 时点 | 测试数 | 说明 |
|------|--------|------|
| 基线(dev@33b993c | 64 | 第二波收口 |
| 本单(dev@7fb9031 | **126+62,全绿)** | 见下分布 |
新增测试分布(test/features/pets/):
| 文件 | 数量 | 覆盖 |
|------|------|------|
| `pet_models_test.dart` | 25 | 每个响应 DTO 全字段+null 变体映射、枚举严格性、请求体序列化(部分更新缺席字段、日期 YYYY-MM-DD)、分页信封、PetSummary null 语义 |
| `pets_repository_test.dart` | 22 | 18 操作请求线路(路径/方法/Bearer/查询参数/tz)、四 POST 幂等键(每次换新键+刷新重放同键)、8 码类型化映射+40000 不误升格、:8083 基地址常量 |
| `pets_controller_test.dart` | 9 | 四态流转(loading→ready/error、空态、重试恢复)、create 插头/update 与 getPet 回写副本、类型化异常外抛 |
| `money_test.dart` | 6 | 分→元格式化、元→分解析(拒超两位小数/负数/非法)、往返一致 |
质量门禁:`flutter test` 126/126 全绿;`flutter analyze` No issues found`dart format --set-exit-if-changed lib test` 无 diff。
## 5. 对既有代码的增量改动(仅 2 个核心文件)
1. `lib/core/network/api_client.dart`:新增 `patbondPetApiBaseUrl`(默认 `http://127.0.0.1:8083``--dart-define=PATBOND_PET_API_BASE_URL` 覆盖,照 patbondUserApiBaseUrl 先例);`ApiClient.request` 增加可选 `query` 参数(GET 过滤/分页所需,既有调用零改动)。
2. `lib/core/network/api_exception.dart``ApiCodes` 补 pets 域 8 码;`ApiBusinessException``final class` 改为可继承 `class`pets 类型化异常的基类,`sealed ApiException` 的穷举性不受影响)。
`AppState``pets_page.dart` 的 demo 数据消费方**未动**T2-12 范围);pets feature 不 import AppState/demo_data。
## 6. 契约出入记录
无。本单纯客户端按冻结契约实现,未做后端实测比对(契约测试已在 api 侧锁两端一致,21 号报告 §2);实现中未发现契约自身矛盾。
## 7. 交接给 T2-12~14
- T2-12:注入方式照 auth 先例——`buildPatbondDio(session, baseUrl: patbondPetApiBaseUrl)` + 共享 `TokenRefresher` 构造 `ApiClient`,再 `ApiPetsRepository(api: ...)``PetsController`;页面依赖 `PetsRepository` 抽象,widget 测试注入假仓库(`test/features/pets/pets_controller_test.dart``FakePetsRepository` 可直接复用/搬升 helpers)。
- T2-13/14:体重/疫苗/事件/提醒直接经 Repository 取数;页面级状态可扩展 PetsController 或按页自建轻量控制器。
- 40902 处理路径已定型:提示「数据已被修改」→ `getPet`/重新拉取取新 version → 重提。
- 金额输入框用 `parseYuanToCents`null 即格式错误),展示用 `formatCentsAsYuan`
---
**Frontend Developer** · 2026-09-08 · patbond-flutter dev@7fb9031