- 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:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user