新增:pets feature 数据层(T2-11,契约 v1.2.0 全 18 操作)
CI / flutter-gates (push) Successful in 1m12s

- pet_models.dart:pets 域 DTO 逐字段对齐冻结契约(宠物/品种/体重/
  疫苗目录/疫苗/健康事件/照护提醒/聚合摘要 + cursor 分页信封),
  枚举严格解析,请求体部分更新语义(缺席字段不出现)
- pets_repository.dart:PetsRepository 抽象 + ApiPetsRepository,
  12 路径 18 操作全覆盖;四个 POST 携带 Idempotency-Key;
  复用既有 ApiClient token 拦截与单飞刷新重放
- pet_exceptions.dart:新 8 错误码映射为类型化异常
  (40300/40401/40402/40902/40903/40904/42201/42202),
  仍可按 ApiBusinessException 基类捕获
- pets_controller.dart:宠物档案状态自 AppState 拆出
  (Controller → Repository → API Client 分层,四态就绪供 T2-12)
- money.dart:元/分换算工具(DTO 层保持整数分,UI 层 T2-14 使用)
- api_client.dart:新增 patbondPetApiBaseUrl(:8083,--dart-define
  可覆盖)与 query 参数支持;api_exception.dart 补 pets 域错误码
- 测试 64 → 126:DTO 映射、错误类型化映射、请求线路
  (路径/方法/鉴权/分页/幂等键/tz)、控制器状态机、金额换算

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 11:22:24 +08:00
parent 33b993ca0c
commit 7fb9031f8d
12 changed files with 2665 additions and 1 deletions
+312
View File
@@ -0,0 +1,312 @@
import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/pets/pet_exceptions.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:uuid/uuid.dart';
/// pets 域仓库接口(契约 v1.2.0 的 12 路径 / 18 操作全覆盖;
/// 页面依赖此抽象,widget 测试注入假实现)。
abstract class PetsRepository {
// ---- 宠物 CRUD ----
Future<List<Pet>> listPets();
Future<Pet> createPet(CreatePetRequest request);
Future<Pet> getPet(String petId);
Future<Pet> updatePet(String petId, UpdatePetRequest request);
// ---- 字典 ----
Future<List<Breed>> listBreeds({PetSpecies? species});
Future<List<VaccineCatalogItem>> listVaccineCatalog({PetSpecies? species});
// ---- 体重(cursor 分页)----
Future<CursorPage<WeightRecord>> listWeights(
String petId, {
int? limit,
String? cursor,
});
Future<WeightRecord> createWeight(String petId, CreateWeightRequest request);
// ---- 疫苗 ----
Future<List<Vaccination>> listVaccinations(String petId);
Future<Vaccination> createVaccination(
String petId,
CreateVaccinationRequest request,
);
Future<Vaccination> updateVaccination(
String vaccinationId,
UpdateVaccinationRequest request,
);
// ---- 健康事件(cursor 分页)----
Future<CursorPage<HealthEvent>> listHealthEvents(
String petId, {
int? limit,
String? cursor,
});
Future<HealthEvent> createHealthEvent(
String petId,
CreateHealthEventRequest request,
);
Future<HealthEvent> updateHealthEvent(
String eventId,
UpdateHealthEventRequest request,
);
// ---- 照护提醒 ----
Future<List<CareReminder>> listCareReminders(
String petId, {
CareReminderStatus? status,
});
Future<CareReminder> createCareReminder(
String petId,
CreateCareReminderRequest request,
);
Future<CareReminder> updateCareReminder(
String reminderId,
UpdateCareReminderRequest request,
);
// ---- 聚合摘要 ----
Future<PetSummary> getPetSummary(String petId, {String? tz});
}
/// 基于 [ApiClient] 的实现。全部端点走 Bearer 鉴权(复用既有 token
/// 拦截 + 401/40101 单飞刷新重放);pets 域业务错误码升格为类型化异常。
///
/// 幂等:weights / vaccinations / health-events / care-reminders 四个 POST
/// 按契约携带可选 `Idempotency-Key`(每次逻辑提交换新键;token 刷新后的
/// 自动重放沿用同一个键,与 auth register 先例一致)。
class ApiPetsRepository implements PetsRepository {
ApiPetsRepository({required this._api, this._uuid = const Uuid()});
final ApiClient _api;
final Uuid _uuid;
Future<Object?> _request(
String path, {
String method = 'GET',
Object? body,
Map<String, Object?>? query,
bool idempotent = false,
}) async {
try {
return await _api.request(
path,
method: method,
body: body,
query: query,
headers: idempotent ? {'Idempotency-Key': _uuid.v4()} : null,
requiresAuth: true,
);
} on ApiBusinessException catch (error) {
throw mapPetBusinessException(error);
}
}
Map<String, dynamic> _asMap(Object? data) => data! as Map<String, dynamic>;
List<T> _asList<T>(Object? data, T Function(Map<String, dynamic>) fromJson) {
return (data! as List)
.map((item) => fromJson(item as Map<String, dynamic>))
.toList();
}
@override
Future<List<Pet>> listPets() async {
final data = await _request('/api/v1/pets');
return _asList(data, Pet.fromJson);
}
@override
Future<Pet> createPet(CreatePetRequest request) async {
final data = await _request(
'/api/v1/pets',
method: 'POST',
body: request.toJson(),
);
return Pet.fromJson(_asMap(data));
}
@override
Future<Pet> getPet(String petId) async {
final data = await _request('/api/v1/pets/$petId');
return Pet.fromJson(_asMap(data));
}
@override
Future<Pet> updatePet(String petId, UpdatePetRequest request) async {
final data = await _request(
'/api/v1/pets/$petId',
method: 'PATCH',
body: request.toJson(),
);
return Pet.fromJson(_asMap(data));
}
@override
Future<List<Breed>> listBreeds({PetSpecies? species}) async {
final data = await _request(
'/api/v1/breeds',
query: {if (species != null) 'species': species.name},
);
return _asList(data, Breed.fromJson);
}
@override
Future<List<VaccineCatalogItem>> listVaccineCatalog({
PetSpecies? species,
}) async {
final data = await _request(
'/api/v1/vaccine-catalog',
query: {if (species != null) 'species': species.name},
);
return _asList(data, VaccineCatalogItem.fromJson);
}
@override
Future<CursorPage<WeightRecord>> listWeights(
String petId, {
int? limit,
String? cursor,
}) async {
final data = await _request(
'/api/v1/pets/$petId/weights',
query: {'limit': ?limit, 'cursor': ?cursor},
);
return CursorPage.fromJson(_asMap(data), WeightRecord.fromJson);
}
@override
Future<WeightRecord> createWeight(
String petId,
CreateWeightRequest request,
) async {
final data = await _request(
'/api/v1/pets/$petId/weights',
method: 'POST',
body: request.toJson(),
idempotent: true,
);
return WeightRecord.fromJson(_asMap(data));
}
@override
Future<List<Vaccination>> listVaccinations(String petId) async {
final data = await _request('/api/v1/pets/$petId/vaccinations');
return _asList(data, Vaccination.fromJson);
}
@override
Future<Vaccination> createVaccination(
String petId,
CreateVaccinationRequest request,
) async {
final data = await _request(
'/api/v1/pets/$petId/vaccinations',
method: 'POST',
body: request.toJson(),
idempotent: true,
);
return Vaccination.fromJson(_asMap(data));
}
@override
Future<Vaccination> updateVaccination(
String vaccinationId,
UpdateVaccinationRequest request,
) async {
final data = await _request(
'/api/v1/vaccinations/$vaccinationId',
method: 'PATCH',
body: request.toJson(),
);
return Vaccination.fromJson(_asMap(data));
}
@override
Future<CursorPage<HealthEvent>> listHealthEvents(
String petId, {
int? limit,
String? cursor,
}) async {
final data = await _request(
'/api/v1/pets/$petId/health-events',
query: {'limit': ?limit, 'cursor': ?cursor},
);
return CursorPage.fromJson(_asMap(data), HealthEvent.fromJson);
}
@override
Future<HealthEvent> createHealthEvent(
String petId,
CreateHealthEventRequest request,
) async {
final data = await _request(
'/api/v1/pets/$petId/health-events',
method: 'POST',
body: request.toJson(),
idempotent: true,
);
return HealthEvent.fromJson(_asMap(data));
}
@override
Future<HealthEvent> updateHealthEvent(
String eventId,
UpdateHealthEventRequest request,
) async {
final data = await _request(
'/api/v1/health-events/$eventId',
method: 'PATCH',
body: request.toJson(),
);
return HealthEvent.fromJson(_asMap(data));
}
@override
Future<List<CareReminder>> listCareReminders(
String petId, {
CareReminderStatus? status,
}) async {
final data = await _request(
'/api/v1/pets/$petId/care-reminders',
query: {if (status != null) 'status': status.name},
);
return _asList(data, CareReminder.fromJson);
}
@override
Future<CareReminder> createCareReminder(
String petId,
CreateCareReminderRequest request,
) async {
final data = await _request(
'/api/v1/pets/$petId/care-reminders',
method: 'POST',
body: request.toJson(),
idempotent: true,
);
return CareReminder.fromJson(_asMap(data));
}
@override
Future<CareReminder> updateCareReminder(
String reminderId,
UpdateCareReminderRequest request,
) async {
final data = await _request(
'/api/v1/care-reminders/$reminderId',
method: 'PATCH',
body: request.toJson(),
);
return CareReminder.fromJson(_asMap(data));
}
@override
Future<PetSummary> getPetSummary(String petId, {String? tz}) async {
final data = await _request(
'/api/v1/pets/$petId/summary',
query: {'tz': ?tz},
);
return PetSummary.fromJson(_asMap(data));
}
}