Files
photo-live/apps/mobile/lib/features/photos/models.dart
T
浅念 8f4ad11bd5 feat(contracts,api): UI-01 cover, preview, scope=own and embed config extensions
契约扩展(全部可选/可空,旧客户端零感知)+ cn/ov 实现 + e2e 权限矩阵:
- AlbumSummary += cover(kind=cover state=effective 最新行的短期签名 URL,
  列表/详情按页批量组装非 N+1;未配置为 null)
- AdminPhoto += preview{thumb,browse}(变体齐备且未删除才签发;隐藏仅
  admin/owner;协作者仅自己上传,scope=own 服务端过滤 uploaderId,
  成员资格逐请求裁决)
- listAlbumPhotos += scope=all|own(缺省 all 行为不变:仅 owner/admin,
  协作者 403;own 任何有效成员)
- EmbedSlotSettings += imageFit/offsetX/offsetBottom/zIndex(cn PATCH 校验
  → slot-state 投影 → ov 公开回读;settings 为 Json 列免迁移,读侧
  normalizeStoredSettings 兜旧形状默认值)
- Flutter 模型同步(SignedUrl/PhotoPreview/AlbumPhotoScope,可空容错)

e2e:ui01-contracts 9 例(封面选取/字节取回/N+1 计数断言、preview 权限
矩阵、scope 过滤与 403/404/400)+ embed ⑨ 配置链路;全量 e2e 257 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 14:09:35 +08:00

295 lines
9.0 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;
import '../albums/models.dart' show SignedUrl;
/// 照片对外可见性。
///
/// 契约 `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,
};
}
/// 管理照片列表数据范围(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(),
};
}
/// 管理端照片列表条目。
///
/// 契约 `components.schemas.AdminPhoto`(= PhotoRecord + uploader 内嵌 +
/// preview 可选扩展),GET /albums/{albumId}/photos(operationId
/// `listAlbumPhotos`)的条目。preview(UI-01):无权签发/无变体/已删除为
/// null;旧响应缺键同样解析为 null。
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,
this.preview,
});
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,
preview: json['preview'] == null
? null
: PhotoPreview.fromJson(json['preview'] as Map<String, dynamic>),
);
/// 上传人脱敏信息。
final AdminPhotoUploader uploader;
/// 受权限管理的照片预览(UI-01);无权/无变体/已删除为 null。
final PhotoPreview? preview;
@override
Map<String, dynamic> toJson() => {
...super.toJson(),
'uploader': uploader.toJson(),
'preview': preview?.toJson(),
};
}