新增: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
+854
View File
@@ -0,0 +1,854 @@
/// pets 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.2.0
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
/// 以便契约漂移在测试期暴露而非静默吞掉)。
library;
/// 物种(创建即定,不可修改)。
enum PetSpecies {
dog,
cat,
other;
static PetSpecies fromJson(String value) =>
_enumFromJson(values, value, 'species');
}
/// 性别。
enum PetSex {
male,
female,
unknown;
static PetSex fromJson(String value) => _enumFromJson(values, value, 'sex');
}
/// 宠物状态(deleted 为内部软删态,接口永不返回)。
enum PetStatus {
active,
lost,
deceased,
archived;
static PetStatus fromJson(String value) =>
_enumFromJson(values, value, 'status');
}
/// 调用者对宠物的权限角色(客户端据此显隐写入口)。
enum PetRole {
owner,
caregiver,
viewer;
static PetRole fromJson(String value) =>
_enumFromJson(values, value, 'myRole');
}
/// 体重记录来源。
enum WeightSource {
manual,
clinic,
device;
static WeightSource fromJson(String value) =>
_enumFromJson(values, value, 'source');
}
/// 疫苗记录状态。
enum VaccinationStatus {
scheduled,
completed,
cancelled;
static VaccinationStatus fromJson(String value) =>
_enumFromJson(values, value, 'status');
}
/// 健康事件六类类型。
enum HealthEventType {
medical,
feeding,
deworming,
grooming,
measurement,
note;
static HealthEventType fromJson(String value) =>
_enumFromJson(values, value, 'eventType');
}
/// 照护提醒四类类型。
enum CareReminderType {
deworming,
checkup,
medication,
other;
static CareReminderType fromJson(String value) =>
_enumFromJson(values, value, 'reminderType');
}
/// 照护提醒状态。
enum CareReminderStatus {
pending,
completed,
dismissed;
static CareReminderStatus fromJson(String value) =>
_enumFromJson(values, value, 'status');
}
/// 摘要「下次接种」的取值来源。
enum NextVaccinationSource {
planned,
nextDue;
static NextVaccinationSource fromJson(String value) =>
_enumFromJson(values, value, 'source');
}
T _enumFromJson<T extends Enum>(List<T> values, String raw, String field) {
for (final value in values) {
if (value.name == raw) return value;
}
throw FormatException('未知的 $field 取值:$raw');
}
/// 日期字段(契约 format: date)序列化为 YYYY-MM-DD。
String dateToJson(DateTime date) {
final y = date.year.toString().padLeft(4, '0');
final m = date.month.toString().padLeft(2, '0');
final d = date.day.toString().padLeft(2, '0');
return '$y-$m-$d';
}
DateTime? _dateOrNull(Object? value) =>
value == null ? null : DateTime.parse(value as String);
/// cursor 分页正典信封 `{items, nextCursor, hasMore}`(体重、健康事件)。
class CursorPage<T> {
const CursorPage({
required this.items,
required this.nextCursor,
required this.hasMore,
});
factory CursorPage.fromJson(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) itemFromJson,
) {
return CursorPage(
items: (json['items'] as List)
.map((item) => itemFromJson(item as Map<String, dynamic>))
.toList(),
// 不透明 base64url 游标,客户端不得解析;hasMore=false 时恒为 null。
nextCursor: json['nextCursor'] as String?,
hasMore: json['hasMore'] as bool,
);
}
final List<T> items;
final String? nextCursor;
final bool hasMore;
}
/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。
/// breedId 与 customBreedName 恰有其一非空;breedDisplayName 随 breedId 存在。
class Pet {
const Pet({
required this.id,
required this.name,
required this.species,
required this.breedId,
required this.breedDisplayName,
required this.customBreedName,
required this.sex,
required this.birthDate,
required this.birthDateEstimated,
required this.personality,
required this.microchipNo,
required this.sterilizedOn,
required this.status,
required this.myRole,
required this.createdAt,
required this.updatedAt,
required this.version,
});
factory Pet.fromJson(Map<String, dynamic> json) {
return Pet(
id: json['id'] as String,
name: json['name'] as String,
species: PetSpecies.fromJson(json['species'] as String),
breedId: json['breedId'] as String?,
breedDisplayName: json['breedDisplayName'] as String?,
customBreedName: json['customBreedName'] as String?,
sex: PetSex.fromJson(json['sex'] as String),
birthDate: _dateOrNull(json['birthDate']),
birthDateEstimated: json['birthDateEstimated'] as bool,
personality: json['personality'] as String?,
microchipNo: json['microchipNo'] as String?,
sterilizedOn: _dateOrNull(json['sterilizedOn']),
status: PetStatus.fromJson(json['status'] as String),
myRole: PetRole.fromJson(json['myRole'] as String),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
version: json['version'] as int,
);
}
final String id;
final String name;
final PetSpecies species;
final String? breedId;
final String? breedDisplayName;
final String? customBreedName;
final PetSex sex;
final DateTime? birthDate;
final bool birthDateEstimated;
final String? personality;
final String? microchipNo;
final DateTime? sterilizedOn;
final PetStatus status;
final PetRole myRole;
final DateTime createdAt;
final DateTime updatedAt;
final int version;
}
/// 创建宠物请求。breedId 与 customBreedName 二选一且互斥(400/40000 兜底在服务端)。
class CreatePetRequest {
const CreatePetRequest({
required this.name,
required this.species,
required this.sex,
this.breedId,
this.customBreedName,
this.birthDate,
this.birthDateEstimated,
this.personality,
this.microchipNo,
this.sterilizedOn,
});
final String name;
final PetSpecies species;
final PetSex sex;
final String? breedId;
final String? customBreedName;
final DateTime? birthDate;
final bool? birthDateEstimated;
final String? personality;
final String? microchipNo;
final DateTime? sterilizedOn;
Map<String, Object?> toJson() => {
'name': name,
'species': species.name,
'sex': sex.name,
if (breedId != null) 'breedId': breedId,
if (customBreedName != null) 'customBreedName': customBreedName,
if (birthDate != null) 'birthDate': dateToJson(birthDate!),
if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated,
if (personality != null) 'personality': personality,
if (microchipNo != null) 'microchipNo': microchipNo,
if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!),
};
}
/// 更新宠物请求(部分更新:缺席字段不变;不支持清空回 null;
/// 品种对整体替换;species 不可改;version 乐观锁必填)。
class UpdatePetRequest {
const UpdatePetRequest({
required this.version,
this.name,
this.breedId,
this.customBreedName,
this.sex,
this.birthDate,
this.birthDateEstimated,
this.personality,
this.microchipNo,
this.sterilizedOn,
this.status,
});
final int version;
final String? name;
final String? breedId;
final String? customBreedName;
final PetSex? sex;
final DateTime? birthDate;
final bool? birthDateEstimated;
final String? personality;
final String? microchipNo;
final DateTime? sterilizedOn;
final PetStatus? status;
Map<String, Object?> toJson() => {
'version': version,
if (name != null) 'name': name,
if (breedId != null) 'breedId': breedId,
if (customBreedName != null) 'customBreedName': customBreedName,
if (sex != null) 'sex': sex!.name,
if (birthDate != null) 'birthDate': dateToJson(birthDate!),
if (birthDateEstimated != null) 'birthDateEstimated': birthDateEstimated,
if (personality != null) 'personality': personality,
if (microchipNo != null) 'microchipNo': microchipNo,
if (sterilizedOn != null) 'sterilizedOn': dateToJson(sterilizedOn!),
if (status != null) 'status': status!.name,
};
}
/// 品种目录项(只读字典)。
class Breed {
const Breed({
required this.id,
required this.species,
required this.code,
required this.displayName,
});
factory Breed.fromJson(Map<String, dynamic> json) {
return Breed(
id: json['id'] as String,
species: PetSpecies.fromJson(json['species'] as String),
code: json['code'] as String,
displayName: json['displayName'] as String,
);
}
final String id;
final PetSpecies species;
final String code;
final String displayName;
}
/// 体重记录(append-only,无乐观锁)。
class WeightRecord {
const WeightRecord({
required this.id,
required this.petId,
required this.weightKg,
required this.measuredAt,
required this.source,
required this.note,
required this.createdAt,
});
factory WeightRecord.fromJson(Map<String, dynamic> json) {
return WeightRecord(
id: json['id'] as String,
petId: json['petId'] as String,
// 服务端 numeric(6,2),整数值也可能以 int 下发。
weightKg: (json['weightKg'] as num).toDouble(),
measuredAt: DateTime.parse(json['measuredAt'] as String),
source: WeightSource.fromJson(json['source'] as String),
note: json['note'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
final String id;
final String petId;
final double weightKg;
final DateTime measuredAt;
final WeightSource source;
final String? note;
final DateTime createdAt;
}
/// 添加体重记录请求。
class CreateWeightRequest {
const CreateWeightRequest({
required this.weightKg,
required this.measuredAt,
this.source,
this.note,
});
final double weightKg;
final DateTime measuredAt;
final WeightSource? source;
final String? note;
Map<String, Object?> toJson() => {
'weightKg': weightKg,
'measuredAt': measuredAt.toIso8601String(),
if (source != null) 'source': source!.name,
if (note != null) 'note': note,
};
}
/// 疫苗目录项(只读字典)。
class VaccineCatalogItem {
const VaccineCatalogItem({
required this.id,
required this.code,
required this.name,
required this.species,
required this.description,
});
factory VaccineCatalogItem.fromJson(Map<String, dynamic> json) {
return VaccineCatalogItem(
id: json['id'] as String,
code: json['code'] as String,
name: json['name'] as String,
species: PetSpecies.fromJson(json['species'] as String),
description: json['description'] as String?,
);
}
final String id;
final String code;
final String name;
final PetSpecies species;
final String? description;
}
/// 疫苗记录。vaccineName 由目录解出(列表页免二次查字典)。
class Vaccination {
const Vaccination({
required this.id,
required this.petId,
required this.vaccineId,
required this.vaccineName,
required this.seriesKey,
required this.doseNo,
required this.doseLabel,
required this.status,
required this.plannedOn,
required this.administeredOn,
required this.nextDueOn,
required this.manufacturer,
required this.batchNo,
required this.notes,
required this.createdAt,
required this.updatedAt,
required this.version,
});
factory Vaccination.fromJson(Map<String, dynamic> json) {
return Vaccination(
id: json['id'] as String,
petId: json['petId'] as String,
vaccineId: json['vaccineId'] as String,
vaccineName: json['vaccineName'] as String,
seriesKey: json['seriesKey'] as String,
doseNo: json['doseNo'] as int,
doseLabel: json['doseLabel'] as String?,
status: VaccinationStatus.fromJson(json['status'] as String),
plannedOn: _dateOrNull(json['plannedOn']),
administeredOn: _dateOrNull(json['administeredOn']),
nextDueOn: _dateOrNull(json['nextDueOn']),
manufacturer: json['manufacturer'] as String?,
batchNo: json['batchNo'] as String?,
notes: json['notes'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
version: json['version'] as int,
);
}
final String id;
final String petId;
final String vaccineId;
final String vaccineName;
final String seriesKey;
final int doseNo;
final String? doseLabel;
final VaccinationStatus status;
final DateTime? plannedOn;
final DateTime? administeredOn;
final DateTime? nextDueOn;
final String? manufacturer;
final String? batchNo;
final String? notes;
final DateTime createdAt;
final DateTime updatedAt;
final int version;
}
/// 创建疫苗记录请求(创建状态仅 scheduled / completed)。
class CreateVaccinationRequest {
const CreateVaccinationRequest({
required this.vaccineId,
required this.seriesKey,
required this.doseNo,
required this.status,
this.doseLabel,
this.plannedOn,
this.administeredOn,
this.nextDueOn,
this.manufacturer,
this.batchNo,
this.notes,
});
final String vaccineId;
final String seriesKey;
final int doseNo;
/// 契约允许 scheduled / completedcancelled 创建无业务意义,服务端 400)。
final VaccinationStatus status;
final String? doseLabel;
final DateTime? plannedOn;
final DateTime? administeredOn;
final DateTime? nextDueOn;
final String? manufacturer;
final String? batchNo;
final String? notes;
Map<String, Object?> toJson() => {
'vaccineId': vaccineId,
'seriesKey': seriesKey,
'doseNo': doseNo,
'status': status.name,
if (doseLabel != null) 'doseLabel': doseLabel,
if (plannedOn != null) 'plannedOn': dateToJson(plannedOn!),
if (administeredOn != null) 'administeredOn': dateToJson(administeredOn!),
if (nextDueOn != null) 'nextDueOn': dateToJson(nextDueOn!),
if (manufacturer != null) 'manufacturer': manufacturer,
if (batchNo != null) 'batchNo': batchNo,
if (notes != null) 'notes': notes,
};
}
/// 更新疫苗记录请求(vaccineId / seriesKey / doseNo 不可改;version 必填)。
class UpdateVaccinationRequest {
const UpdateVaccinationRequest({
required this.version,
this.status,
this.plannedOn,
this.administeredOn,
this.nextDueOn,
this.doseLabel,
this.manufacturer,
this.batchNo,
this.notes,
});
final int version;
final VaccinationStatus? status;
final DateTime? plannedOn;
final DateTime? administeredOn;
final DateTime? nextDueOn;
final String? doseLabel;
final String? manufacturer;
final String? batchNo;
final String? notes;
Map<String, Object?> toJson() => {
'version': version,
if (status != null) 'status': status!.name,
if (plannedOn != null) 'plannedOn': dateToJson(plannedOn!),
if (administeredOn != null) 'administeredOn': dateToJson(administeredOn!),
if (nextDueOn != null) 'nextDueOn': dateToJson(nextDueOn!),
if (doseLabel != null) 'doseLabel': doseLabel,
if (manufacturer != null) 'manufacturer': manufacturer,
if (batchNo != null) 'batchNo': batchNo,
if (notes != null) 'notes': notes,
};
}
/// 健康事件(六类;金额整数分传输,元/分换算留给 UI 层)。
class HealthEvent {
const HealthEvent({
required this.id,
required this.petId,
required this.eventType,
required this.occurredAt,
required this.title,
required this.notes,
required this.amountCents,
required this.createdByUserId,
required this.createdAt,
required this.updatedAt,
required this.version,
});
factory HealthEvent.fromJson(Map<String, dynamic> json) {
return HealthEvent(
id: json['id'] as String,
petId: json['petId'] as String,
eventType: HealthEventType.fromJson(json['eventType'] as String),
occurredAt: DateTime.parse(json['occurredAt'] as String),
title: json['title'] as String,
notes: json['notes'] as String?,
amountCents: json['amountCents'] as int?,
createdByUserId: json['createdByUserId'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
version: json['version'] as int,
);
}
final String id;
final String petId;
final HealthEventType eventType;
final DateTime occurredAt;
final String title;
final String? notes;
/// 金额(整数分,非负,可空)。DTO 层保持整数分不换算。
final int? amountCents;
final String createdByUserId;
final DateTime createdAt;
final DateTime updatedAt;
final int version;
}
/// 添加健康事件请求(createdByUserId 取自 token,不收请求体)。
class CreateHealthEventRequest {
const CreateHealthEventRequest({
required this.eventType,
required this.occurredAt,
required this.title,
this.notes,
this.amountCents,
});
final HealthEventType eventType;
final DateTime occurredAt;
final String title;
final String? notes;
/// 整数分;提交小数服务端 400/40000(不做静默截断)。
final int? amountCents;
Map<String, Object?> toJson() => {
'eventType': eventType.name,
'occurredAt': occurredAt.toIso8601String(),
'title': title,
if (notes != null) 'notes': notes,
if (amountCents != null) 'amountCents': amountCents,
};
}
/// 更新健康事件请求(仅 title / notes / amountCents 可编辑;version 必填)。
class UpdateHealthEventRequest {
const UpdateHealthEventRequest({
required this.version,
this.title,
this.notes,
this.amountCents,
});
final int version;
final String? title;
final String? notes;
final int? amountCents;
Map<String, Object?> toJson() => {
'version': version,
if (title != null) 'title': title,
if (notes != null) 'notes': notes,
if (amountCents != null) 'amountCents': amountCents,
};
}
/// 照护提醒(无 version 字段;completedAt 非空当且仅当 status=completed)。
class CareReminder {
const CareReminder({
required this.id,
required this.petId,
required this.reminderType,
required this.title,
required this.dueAt,
required this.status,
required this.completedAt,
required this.createdAt,
required this.updatedAt,
});
factory CareReminder.fromJson(Map<String, dynamic> json) {
return CareReminder(
id: json['id'] as String,
petId: json['petId'] as String,
reminderType: CareReminderType.fromJson(json['reminderType'] as String),
title: json['title'] as String,
dueAt: DateTime.parse(json['dueAt'] as String),
status: CareReminderStatus.fromJson(json['status'] as String),
completedAt: json['completedAt'] == null
? null
: DateTime.parse(json['completedAt'] as String),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
);
}
final String id;
final String petId;
final CareReminderType reminderType;
final String title;
final DateTime dueAt;
final CareReminderStatus status;
final DateTime? completedAt;
final DateTime createdAt;
final DateTime updatedAt;
}
/// 创建照护提醒请求(创建恒为 pending,不收 status)。
class CreateCareReminderRequest {
const CreateCareReminderRequest({
required this.reminderType,
required this.title,
required this.dueAt,
});
final CareReminderType reminderType;
final String title;
final DateTime dueAt;
Map<String, Object?> toJson() => {
'reminderType': reminderType.name,
'title': title,
'dueAt': dueAt.toIso8601String(),
};
}
/// 更新提醒状态请求(状态流转专用:仅 status + completedAt)。
class UpdateCareReminderRequest {
const UpdateCareReminderRequest({required this.status, this.completedAt});
final CareReminderStatus status;
/// status=completed 时必填;其余状态禁带(违反 422/42202)。
/// 由客户端提交(而非服务端 now()),允许补记实际完成时刻。
final DateTime? completedAt;
Map<String, Object?> toJson() => {
'status': status.name,
if (completedAt != null) 'completedAt': completedAt!.toIso8601String(),
};
}
/// 摘要:最新体重(无记录 → 整体 null)。
class SummaryLatestWeight {
const SummaryLatestWeight({required this.weightKg, required this.measuredAt});
factory SummaryLatestWeight.fromJson(Map<String, dynamic> json) {
return SummaryLatestWeight(
weightKg: (json['weightKg'] as num).toDouble(),
measuredAt: DateTime.parse(json['measuredAt'] as String),
);
}
final double weightKg;
final DateTime measuredAt;
}
/// 摘要:疫苗进度(totalDoses=0 → 整体 null,不是 0/0)。
class SummaryVaccinationProgress {
const SummaryVaccinationProgress({
required this.completedDoses,
required this.totalDoses,
});
factory SummaryVaccinationProgress.fromJson(Map<String, dynamic> json) {
return SummaryVaccinationProgress(
completedDoses: json['completedDoses'] as int,
totalDoses: json['totalDoses'] as int,
);
}
final int completedDoses;
final int totalDoses;
}
/// 摘要:下次接种(候选集空 → 整体 null;dueOn 可为过去日期——逾期针仍是下一针)。
class SummaryNextVaccination {
const SummaryNextVaccination({
required this.vaccinationId,
required this.vaccineId,
required this.vaccineName,
required this.doseNo,
required this.doseLabel,
required this.dueOn,
required this.source,
});
factory SummaryNextVaccination.fromJson(Map<String, dynamic> json) {
return SummaryNextVaccination(
vaccinationId: json['vaccinationId'] as String,
vaccineId: json['vaccineId'] as String,
vaccineName: json['vaccineName'] as String,
doseNo: json['doseNo'] as int,
doseLabel: json['doseLabel'] as String?,
dueOn: DateTime.parse(json['dueOn'] as String),
source: NextVaccinationSource.fromJson(json['source'] as String),
);
}
final String vaccinationId;
final String vaccineId;
final String vaccineName;
final int doseNo;
final String? doseLabel;
final DateTime dueOn;
final NextVaccinationSource source;
}
/// 摘要:当月花费(恒非 null;无支出 amountCents=0;月边界随 tz 移动)。
class SummaryMonthlyExpense {
const SummaryMonthlyExpense({
required this.month,
required this.timezone,
required this.amountCents,
});
factory SummaryMonthlyExpense.fromJson(Map<String, dynamic> json) {
return SummaryMonthlyExpense(
month: json['month'] as String,
timezone: json['timezone'] as String,
amountCents: json['amountCents'] as int,
);
}
/// ISO year-month(如 2026-09)。
final String month;
final String timezone;
final int amountCents;
}
/// 档案聚合摘要(四项聚合实时计算;前三项无记录时为 null,
/// monthlyExpense 恒非 null——契约 PetSummary null 语义)。
class PetSummary {
const PetSummary({
required this.petId,
required this.latestWeight,
required this.vaccinationProgress,
required this.nextVaccination,
required this.monthlyExpense,
});
factory PetSummary.fromJson(Map<String, dynamic> json) {
return PetSummary(
petId: json['petId'] as String,
latestWeight: json['latestWeight'] == null
? null
: SummaryLatestWeight.fromJson(
json['latestWeight'] as Map<String, dynamic>,
),
vaccinationProgress: json['vaccinationProgress'] == null
? null
: SummaryVaccinationProgress.fromJson(
json['vaccinationProgress'] as Map<String, dynamic>,
),
nextVaccination: json['nextVaccination'] == null
? null
: SummaryNextVaccination.fromJson(
json['nextVaccination'] as Map<String, dynamic>,
),
monthlyExpense: SummaryMonthlyExpense.fromJson(
json['monthlyExpense'] as Map<String, dynamic>,
),
);
}
final String petId;
final SummaryLatestWeight? latestWeight;
final SummaryVaccinationProgress? vaccinationProgress;
final SummaryNextVaccination? nextVaccination;
final SummaryMonthlyExpense monthlyExpense;
}