Files
photo-live/apps/mobile/lib/features/photos/models.dart
T
浅念 f975321b67 feat(mobile): api client, session storage and contract-aligned models
P3 计划 Task 2(apps/mobile):

- core/config.dart:API_BASE/VIEWER_BASE 经 --dart-define 覆盖,缺省
  10.0.2.2 模拟器宿主回环;viewer 相册链接构造 {VIEWER_BASE}/a/{token}。
- core/error_codes.dart:errors.ts 17 码 1:1 镜像(含注释),另设 4 个
  客户端本地伪码(NETWORK_ERROR/NETWORK_TIMEOUT/CANCELLED/HTTP_ERROR)。
- core/api_client.dart:dio 封装——Bearer 注入(登录端点豁免)、401
  驱逐(清会话 + onUnauthorized 回调去重,App 壳注册跳 /login)、
  DioException/错误体 {code,message,requestId} 归一 AppException、
  通用游标分页助手;auth/albums/members/categories/admin/uploads/photos
  全端点方法;riverpod provider 出口。
- core/session_store.dart:SessionStore 抽象 + flutter_secure_storage
  实现(token/expiresAt/account 三键,encode/decode 纯函数可测)。
- features/{auth,albums,uploads,photos}/models.dart:手写 fromJson/
  toJson,逐字段对齐 openapi.yaml(类注释标注契约 schema/operationId);
  locale 按字符串透传(zh-CN 标识符不兼容),业务枚举 Dart 化并在
  未知值 fail-fast。
- 测试 66 项:17 码表驱动 + 网络/超时/取消 + 兜底映射、游标边界
  (含 CursorPageMeta 不变式违约容错)、模型往返(fixture 转写自
  openapi.yaml schema 与 api e2e 断言,来源在测试头注释)、拦截器
  行为(手写 FakeAdapter:Bearer 注入/401 驱逐/登录豁免/并发去重/
  尽力而为 logout/refresh 滑动续期)。flutter analyze 零告警。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 00:40:02 +08:00

235 lines
7.1 KiB
Dart
Raw 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.
/// 照片域模型(P3 计划 Task 2):管理端照片记录与照片枚举。
///
/// 契约来源:`packages/contracts/openapi.yaml` tag `photos`。
library;
/// 照片对外可见性。
///
/// 契约 `components.schemas.PhotoVisibility`:unpublished/published/hidden/
/// deleted;deleted 为软删除。管理端 PATCH /photos/{photoId} 仅允许在
/// hidden 与 published 间切换。
enum PhotoVisibility {
unpublished('unpublished'),
published('published'),
hidden('hidden'),
deleted('deleted');
const PhotoVisibility(this.wire);
/// 线上(JSON)取值。
final String wire;
static PhotoVisibility fromJson(String value) => values.firstWhere(
(v) => v.wire == value,
orElse: () => throw FormatException('未知 PhotoVisibility: $value'),
);
}
/// 照片服务端处理状态。
///
/// 契约 `components.schemas.PhotoProcessingState`(技术实施方案 §5.5
/// 「服务端处理」链路)。
enum PhotoProcessingState {
pendingUpload('pending_upload'),
uploaded('uploaded'),
processing('processing'),
ready('ready'),
processingFailed('processing_failed');
const PhotoProcessingState(this.wire);
/// 线上(JSON)取值。
final String wire;
static PhotoProcessingState fromJson(String value) => values.firstWhere(
(v) => v.wire == value,
orElse: () => throw FormatException('未知 PhotoProcessingState: $value'),
);
}
/// 管理端照片记录。
///
/// 契约 `components.schemas.PhotoRecord`(技术实施方案 §4 photos 表字段)。
/// 作为 GET /albums/{albumId}/photos 条目时经 [AdminPhoto] 内嵌 uploader;
/// PATCH /photos/{photoId}(operationId `updatePhoto`)的响应为裸
/// PhotoRecord。
class PhotoRecord {
const PhotoRecord({
required this.photoId,
required this.albumId,
required this.categoryId,
required this.uploaderId,
required this.visibility,
required this.processingState,
required this.bytes,
required this.width,
required this.height,
required this.shotAt,
required this.uploadedAt,
required this.publishedAt,
required this.likeCount,
});
factory PhotoRecord.fromJson(Map<String, dynamic> json) => PhotoRecord(
photoId: json['photoId'] as String,
albumId: json['albumId'] as String,
categoryId: json['categoryId'] as String?,
uploaderId: json['uploaderId'] as String,
visibility: PhotoVisibility.fromJson(json['visibility'] as String),
processingState:
PhotoProcessingState.fromJson(json['processingState'] as String),
bytes: json['bytes'] as int,
width: json['width'] as int?,
height: json['height'] as int?,
shotAt: json['shotAt'] == null
? null
: DateTime.parse(json['shotAt'] as String),
uploadedAt: DateTime.parse(json['uploadedAt'] as String),
publishedAt: json['publishedAt'] == null
? null
: DateTime.parse(json['publishedAt'] as String),
likeCount: json['likeCount'] as int,
);
/// 照片 ID。
final String photoId;
/// 所属相册 ID。
final String albumId;
/// 分类 ID;null 表示未分类。
final String? categoryId;
/// 上传人用户 ID。
final String uploaderId;
/// 对外可见性。
final PhotoVisibility visibility;
/// 服务端处理状态。
final PhotoProcessingState processingState;
/// 原图字节数。
final int bytes;
/// 原图宽度(像素);处理前为 null。
final int? width;
/// 原图高度(像素);处理前为 null。
final int? height;
/// 拍摄时间(EXIF);无有效值时为 null。
final DateTime? shotAt;
/// 上传完成时间。
final DateTime uploadedAt;
/// 发布时间;未发布为 null。
final DateTime? publishedAt;
/// 累计点赞数。
final int likeCount;
Map<String, dynamic> toJson() => {
'photoId': photoId,
'albumId': albumId,
'categoryId': categoryId,
'uploaderId': uploaderId,
'visibility': visibility.wire,
'processingState': processingState.wire,
'bytes': bytes,
'width': width,
'height': height,
'shotAt': shotAt?.toUtc().toIso8601String(),
'uploadedAt': uploadedAt.toUtc().toIso8601String(),
'publishedAt': publishedAt?.toUtc().toIso8601String(),
'likeCount': likeCount,
};
}
/// 管理列表条目内嵌的上传人脱敏信息。
///
/// 契约 `components.schemas.AdminPhotoUploader`(不含邮箱、手机等凭据字段)。
class AdminPhotoUploader {
const AdminPhotoUploader({
required this.userId,
required this.account,
required this.displayName,
});
factory AdminPhotoUploader.fromJson(Map<String, dynamic> json) =>
AdminPhotoUploader(
userId: json['userId'] as String,
account: json['account'] as String,
displayName: json['displayName'] as String,
);
/// 上传人用户 ID(与 PhotoRecord.uploaderId 一致)。
final String userId;
/// 登录账号。
final String account;
/// 展示姓名(来自用户资料)。
final String displayName;
Map<String, dynamic> toJson() => {
'userId': userId,
'account': account,
'displayName': displayName,
};
}
/// 管理端照片列表条目。
///
/// 契约 `components.schemas.AdminPhoto`(= PhotoRecord + uploader 内嵌),
/// GET /albums/{albumId}/photos(operationId `listAlbumPhotos`)的条目。
class AdminPhoto extends PhotoRecord {
const AdminPhoto({
required this.uploader,
required super.photoId,
required super.albumId,
required super.categoryId,
required super.uploaderId,
required super.visibility,
required super.processingState,
required super.bytes,
required super.width,
required super.height,
required super.shotAt,
required super.uploadedAt,
required super.publishedAt,
required super.likeCount,
});
factory AdminPhoto.fromJson(Map<String, dynamic> json) => AdminPhoto(
uploader: AdminPhotoUploader.fromJson(
json['uploader'] as Map<String, dynamic>,
),
photoId: json['photoId'] as String,
albumId: json['albumId'] as String,
categoryId: json['categoryId'] as String?,
uploaderId: json['uploaderId'] as String,
visibility: PhotoVisibility.fromJson(json['visibility'] as String),
processingState:
PhotoProcessingState.fromJson(json['processingState'] as String),
bytes: json['bytes'] as int,
width: json['width'] as int?,
height: json['height'] as int?,
shotAt: json['shotAt'] == null
? null
: DateTime.parse(json['shotAt'] as String),
uploadedAt: DateTime.parse(json['uploadedAt'] as String),
publishedAt: json['publishedAt'] == null
? null
: DateTime.parse(json['publishedAt'] as String),
likeCount: json['likeCount'] as int,
);
/// 上传人脱敏信息。
final AdminPhotoUploader uploader;
@override
Map<String, dynamic> toJson() => {...super.toJson(), 'uploader': uploader.toJson()};
}