f975321b67
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>
235 lines
7.1 KiB
Dart
235 lines
7.1 KiB
Dart
/// 照片域模型(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()};
|
||
}
|