- 22~26 Flutter 接入五单报告(T2-11~14 + 白名单扩充,flutter 测试 64→272) - 27 第三波收口总表:demo 数据消亡、四态纪律、埋点端到端贯通、DEBT-1 偿还 - 三次 compose 实测无契约偏差;波内 agent 中断续跑事故记录在案 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
11 KiB
T2-11 pets feature 状态拆分与 API Client(数据层交付报告)
执行日期:2026-09-08
角色:Frontend Developer(Flutter)
工单:T2-11(M2 第三波前置,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 feature(Controller → Repository → API Client);pets feature 零依赖 AppState demo 数据(AppState 的既有消费方按工单不动,留给 T2-12); - 测试 64 → 126 全绿,
flutter analyze0 问题,dart format无 diff。
1. 分层结构
(T2-12 接入)Page/Widget
│
▼
PetsController(lib/features/pets/pets_controller.dart)
· ChangeNotifier;宠物档案列表/详情内存副本
· 四态:initial / loading / ready(含 isEmpty 空态) / error(+lastError)
· refresh 收敛错误为 error 态;create/update 类型化异常外抛给表单层
│
▼
PetsRepository(抽象)/ ApiPetsRepository(lib/features/pets/pets_repository.dart)
· 18 操作全量方法;路径/方法/查询参数/请求体按契约组装
· 四个 POST 自动携带 Idempotency-Key(uuid v4,每次逻辑提交换新键)
· ApiBusinessException → pets 域类型化异常升格(pet_exceptions.dart)
│
▼
ApiClient(lib/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(不收 status;Idempotency-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 三项无记录为 null;monthlyExpense 恒非 null、无支出 amountCents=0;dueOn 允许过去日期(逾期针)——均有 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 个核心文件)
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 过滤/分页所需,既有调用零改动)。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