2026-09-19 00:40:02 +08:00
|
|
|
|
/// 照片域模型(P3 计划 Task 2):管理端照片记录与照片枚举。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 契约来源:`packages/contracts/openapi.yaml` tag `photos`。
|
|
|
|
|
|
library;
|
|
|
|
|
|
|
2026-09-21 14:09:35 +08:00
|
|
|
|
import '../albums/models.dart' show SignedUrl;
|
|
|
|
|
|
|
2026-09-19 00:40:02 +08:00
|
|
|
|
/// 照片对外可见性。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 契约 `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,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 14:09:35 +08:00
|
|
|
|
/// 管理照片列表数据范围(UI-01,listAlbumPhotos scope 查询参数)。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// all=本相册全部照片(缺省,仅 owner/admin);own=仅当前用户上传(任何
|
|
|
|
|
|
/// 有效成员,服务端过滤)。
|
|
|
|
|
|
enum AlbumPhotoScope {
|
|
|
|
|
|
all('all'),
|
|
|
|
|
|
own('own');
|
|
|
|
|
|
|
|
|
|
|
|
const AlbumPhotoScope(this.wire);
|
|
|
|
|
|
|
|
|
|
|
|
/// 线上(JSON)取值。
|
|
|
|
|
|
final String wire;
|
|
|
|
|
|
|
|
|
|
|
|
static AlbumPhotoScope fromJson(String value) => values.firstWhere(
|
|
|
|
|
|
(v) => v.wire == value,
|
|
|
|
|
|
orElse: () => throw FormatException('未知 AlbumPhotoScope: $value'),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 管理端照片预览(UI-01)。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 契约 `components.schemas.PhotoPreview`:thumb/browse 短期签名 URL。
|
|
|
|
|
|
/// 有权限(admin 全部 / 相册 owner 本相册 / 协作者仅自己上传)且有有效
|
|
|
|
|
|
/// 变体、未删除才签发;隐藏照片仅 admin/owner。无预览时条目 preview 为
|
|
|
|
|
|
/// null(PhotoRecord 记录本身不受影响)。
|
|
|
|
|
|
class PhotoPreview {
|
|
|
|
|
|
const PhotoPreview({required this.thumb, required this.browse});
|
|
|
|
|
|
|
|
|
|
|
|
factory PhotoPreview.fromJson(Map<String, dynamic> json) => PhotoPreview(
|
|
|
|
|
|
thumb: SignedUrl.fromJson(json['thumb'] as Map<String, dynamic>),
|
|
|
|
|
|
browse: SignedUrl.fromJson(json['browse'] as Map<String, dynamic>),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/// 缩略图签名 URL。
|
|
|
|
|
|
final SignedUrl thumb;
|
|
|
|
|
|
|
|
|
|
|
|
/// 浏览图签名 URL。
|
|
|
|
|
|
final SignedUrl browse;
|
|
|
|
|
|
|
|
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
|
|
|
|
'thumb': thumb.toJson(),
|
|
|
|
|
|
'browse': browse.toJson(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 00:40:02 +08:00
|
|
|
|
/// 管理端照片列表条目。
|
|
|
|
|
|
///
|
2026-09-21 14:09:35 +08:00
|
|
|
|
/// 契约 `components.schemas.AdminPhoto`(= PhotoRecord + uploader 内嵌 +
|
|
|
|
|
|
/// preview 可选扩展),GET /albums/{albumId}/photos(operationId
|
|
|
|
|
|
/// `listAlbumPhotos`)的条目。preview(UI-01):无权签发/无变体/已删除为
|
|
|
|
|
|
/// null;旧响应缺键同样解析为 null。
|
2026-09-19 00:40:02 +08:00
|
|
|
|
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,
|
2026-09-21 14:09:35 +08:00
|
|
|
|
this.preview,
|
2026-09-19 00:40:02 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-21 14:09:35 +08:00
|
|
|
|
preview: json['preview'] == null
|
|
|
|
|
|
? null
|
|
|
|
|
|
: PhotoPreview.fromJson(json['preview'] as Map<String, dynamic>),
|
2026-09-19 00:40:02 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/// 上传人脱敏信息。
|
|
|
|
|
|
final AdminPhotoUploader uploader;
|
|
|
|
|
|
|
2026-09-21 14:09:35 +08:00
|
|
|
|
/// 受权限管理的照片预览(UI-01);无权/无变体/已删除为 null。
|
|
|
|
|
|
final PhotoPreview? preview;
|
|
|
|
|
|
|
2026-09-19 00:40:02 +08:00
|
|
|
|
@override
|
2026-09-21 14:09:35 +08:00
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
|
|
|
|
...super.toJson(),
|
|
|
|
|
|
'uploader': uploader.toJson(),
|
|
|
|
|
|
'preview': preview?.toJson(),
|
|
|
|
|
|
};
|
2026-09-19 00:40:02 +08:00
|
|
|
|
}
|