Files
lixi 19bd8c1810
CI / flutter-gates (push) Successful in 2m26s
新增:community feature 数据层——契约 v1.3.0 十九操作全覆盖 + ToggleSync 乐观更新状态机(T3-12)
- community_models:community/media 域 DTO 逐字段照冻结契约手写 JSON 映射
  (Post/FeedCard/PostComment/AuthorSummary 降级形态/媒体两步上传凭据等),
  未知枚举抛 FormatException 暴露契约漂移;CursorPage 上移 core 复用
- community_repository:13 路径 19 操作全覆盖;createPost/createComment
  必带 Idempotency-Key(每次逻辑提交换新键、刷新重放同键);点赞/收藏/
  关注走 PUT/DELETE 语义幂等
- community_exceptions:v1.3.0 新增 9 码 + 40902 共码类型化异常映射
- toggle_sync:乐观翻转 + 快照回滚 + 单飞合并最终意图 + 代次守卫,
  点赞/收藏共用一套参数化状态机,权威终态对账收敛
- community_controller:Feed 多页缓存 + 首屏四态 + 尾部加载三态 +
  游标拼接 + 刷新代次丢弃旧尾页;详情副本与卡片互动状态同源;reset 清态
- app.dart 装配:community 服务分端口直连(:8084),共享 TokenRefresher,
  登出同步 reset
- 测试 286 → 347(模型映射 / 19 操作线路 / 错误映射 / 竞态序列全覆盖)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-09 12:01:25 +08:00

830 lines
23 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// pets 域响应 / 请求模型(接口契约冻结稿 openapi.yaml v1.2.0
/// 字段名与后端逐字一致;枚举取值严格校验,未知值抛 [FormatException]
/// 以便契约漂移在测试期暴露而非静默吞掉)。
library;
export 'package:patbond_flutter/core/models/cursor_page.dart';
/// 物种(创建即定,不可修改)。
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);
/// 宠物档案(列表 / 详情 / 创建 / 更新统一响应形态)。
/// 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;
}