新增: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
+30
View File
@@ -0,0 +1,30 @@
/// 金额换算工具:契约传输整数分(amountCents),UI 展示以元。
/// DTO 层保持整数分不换算(开发计划 §4.3);本工具供 UI 层(T2-14)使用。
library;
/// 整数分 → 元展示字符串。整元不带小数("128"),
/// 非整元固定两位("128.50")。负数入参非法(契约金额非负)。
String formatCentsAsYuan(int cents) {
if (cents < 0) {
throw ArgumentError.value(cents, 'cents', '金额不得为负');
}
final yuan = cents ~/ 100;
final remainder = cents % 100;
if (remainder == 0) return '$yuan';
return '$yuan.${remainder.toString().padLeft(2, '0')}';
}
/// 元输入串 → 整数分。接受整数或最多两位小数("12"、"12.5"、"12.50");
/// 空白、非法字符、超两位小数、负数返回 null(表单层据此报「金额格式不正确」)。
int? parseYuanToCents(String input) {
final trimmed = input.trim();
final match = RegExp(r'^(\d+)(?:\.(\d{1,2}))?$').firstMatch(trimmed);
if (match == null) return null;
final yuan = int.parse(match.group(1)!);
final fractional = match.group(2);
final cents = switch (fractional) {
null => 0,
_ => int.parse(fractional.padRight(2, '0')),
};
return yuan * 100 + cents;
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:patbond_flutter/core/network/api_exception.dart';
/// pets 域类型化业务异常(契约 v1.2.0 定型的 8 个新错误码,
/// 21 号收口报告 §1)。全部继承 [ApiBusinessException]
/// 既有按基类捕获的通用错误处理不受影响。
/// 40300:对可见宠物无相应操作权限(viewer 写记录、caregiver/viewer 改档案)。
final class PetAccessDeniedException extends ApiBusinessException {
const PetAccessDeniedException({required super.message})
: super(code: ApiCodes.petAccessDenied);
}
/// 40401:宠物不存在 / 已软删 / 与调用者无关系(防枚举,三种情况响应一致)。
final class PetNotFoundException extends ApiBusinessException {
const PetNotFoundException({required super.message})
: super(code: ApiCodes.petNotFound);
}
/// 40402:记录不存在或记录所属宠物对调用者不可见(记录级防枚举)。
final class PetRecordNotFoundException extends ApiBusinessException {
const PetRecordNotFoundException({required super.message})
: super(code: ApiCodes.recordNotFound);
}
/// 40902:乐观锁版本冲突(version 过期;提醒的条件更新守卫落空同码)。
/// 客户端处理:刷新取新数据后重提。
final class PetVersionConflictException extends ApiBusinessException {
const PetVersionConflictException({required super.message})
: super(code: ApiCodes.versionConflict);
}
/// 40903:芯片号已被登记(跨用户唯一)。
final class MicrochipTakenException extends ApiBusinessException {
const MicrochipTakenException({required super.message})
: super(code: ApiCodes.microchipTaken);
}
/// 40904:同宠物同疫苗同系列同剂次的非 cancelled 疫苗记录已存在
/// (cancel 后同剂次可重新登记)。
final class VaccinationDoseExistsException extends ApiBusinessException {
const VaccinationDoseExistsException({required super.message})
: super(code: ApiCodes.vaccinationDoseExists);
}
/// 42201:疫苗状态机非法迁移或状态-日期规则违反(HTTP 422)。
final class VaccinationRuleException extends ApiBusinessException {
const VaccinationRuleException({required super.message})
: super(code: ApiCodes.vaccinationRuleViolation);
}
/// 42202:提醒状态机非法迁移或 completed-completedAt 一致性违反(HTTP 422)。
final class CareReminderRuleException extends ApiBusinessException {
const CareReminderRuleException({required super.message})
: super(code: ApiCodes.careReminderRuleViolation);
}
/// 把通用业务异常按 pets 域错误码升格为类型化异常;
/// 未覆盖的码(40000 参数错误等)原样返回,沿用通用处理。
ApiBusinessException mapPetBusinessException(ApiBusinessException error) {
return switch (error.code) {
ApiCodes.petAccessDenied => PetAccessDeniedException(
message: error.message,
),
ApiCodes.petNotFound => PetNotFoundException(message: error.message),
ApiCodes.recordNotFound => PetRecordNotFoundException(
message: error.message,
),
ApiCodes.versionConflict => PetVersionConflictException(
message: error.message,
),
ApiCodes.microchipTaken => MicrochipTakenException(message: error.message),
ApiCodes.vaccinationDoseExists => VaccinationDoseExistsException(
message: error.message,
),
ApiCodes.vaccinationRuleViolation => VaccinationRuleException(
message: error.message,
),
ApiCodes.careReminderRuleViolation => CareReminderRuleException(
message: error.message,
),
_ => error,
};
}
+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;
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/foundation.dart';
import 'package:patbond_flutter/core/network/api_exception.dart';
import 'package:patbond_flutter/features/pets/pet_models.dart';
import 'package:patbond_flutter/features/pets/pets_repository.dart';
/// 网络数据四态(第 9 节硬要求:loading / empty / error / retry
/// data 态含空列表判定,页面据 [PetsLoadPhase] + 数据渲染四态)。
enum PetsLoadPhase { initial, loading, ready, error }
/// pets feature 状态控制器(开发计划 §4.2 分层:
/// Page/Widget → PetsController → PetsRepository → ApiClient)。
///
/// 宠物档案状态自此从 `AppState` 拆出:本 feature 不读写 AppState 的
/// 宠物/疫苗 demo 数据;服务端是唯一事实来源,内存副本仅作展示缓存
/// (无本地持久化——档案数据以 [refresh] 为失效/刷新策略)。
///
/// 本单只承载列表 + 详情主链路状态;体重/疫苗/事件/提醒的页面级
/// 状态由 T2-12~14 按页面直接经 Repository 取数或扩展本控制器。
class PetsController extends ChangeNotifier {
PetsController({required this._repository});
final PetsRepository _repository;
PetsLoadPhase _phase = PetsLoadPhase.initial;
List<Pet> _pets = const [];
ApiException? _lastError;
bool _disposed = false;
PetsLoadPhase get phase => _phase;
/// 当前用户可见宠物(服务端 created_at DESC 排序原样保留)。
List<Pet> get pets => _pets;
/// ready 且列表为空 → 空态(新用户建档引导)。
bool get isEmpty => _phase == PetsLoadPhase.ready && _pets.isEmpty;
/// 最近一次加载失败的类型化错误(error 态时非 null)。
ApiException? get lastError => _lastError;
/// 加载 / 重试宠物列表。错误不外抛,收敛为 error 态供页面渲染 + retry。
Future<void> refresh() async {
_phase = PetsLoadPhase.loading;
_lastError = null;
_notify();
try {
_pets = await _repository.listPets();
_phase = PetsLoadPhase.ready;
} on ApiException catch (error) {
_lastError = error;
_phase = PetsLoadPhase.error;
}
_notify();
}
/// 创建宠物:成功后就地插入列表头(服务端 created_at DESC,新建最前),
/// 失败按类型化异常外抛给表单层处理(40903 芯片号冲突等)。
Future<Pet> createPet(CreatePetRequest request) async {
final pet = await _repository.createPet(request);
_pets = [pet, ..._pets];
if (_phase != PetsLoadPhase.ready) _phase = PetsLoadPhase.ready;
_notify();
return pet;
}
/// 拉取单只宠物详情并同步列表内存副本。
Future<Pet> getPet(String petId) async {
final pet = await _repository.getPet(petId);
_replaceInList(pet);
return pet;
}
/// 更新宠物档案:成功后同步列表副本;40902 版本冲突等外抛,
/// 页面提示后应调用 [getPet] 刷新取新 version 重提。
Future<Pet> updatePet(String petId, UpdatePetRequest request) async {
final pet = await _repository.updatePet(petId, request);
_replaceInList(pet);
return pet;
}
void _replaceInList(Pet pet) {
final index = _pets.indexWhere((item) => item.id == pet.id);
if (index == -1) return;
_pets = [..._pets]..[index] = pet;
_notify();
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
+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));
}
}