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>
This commit is contained in:
浅念
2026-09-21 14:09:35 +08:00
parent 3b5f8d2d77
commit 8f4ad11bd5
21 changed files with 1428 additions and 40 deletions
+34 -1
View File
@@ -70,10 +70,35 @@ enum AlbumStatus {
);
}
/// 私有对象的短期签名 URL。
///
/// 契约 `components.schemas.SignedUrl`(UI-01 起相册封面/照片预览共用):
/// 不得写入持久分享链接,过期后经原入口(列表/详情重取)刷新。
class SignedUrl {
const SignedUrl({required this.url, required this.expiresAt});
factory SignedUrl.fromJson(Map<String, dynamic> json) => SignedUrl(
url: json['url'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
);
/// 签名访问 URL。
final String url;
/// URL 失效时间。
final DateTime expiresAt;
Map<String, dynamic> toJson() => {
'url': url,
'expiresAt': expiresAt.toUtc().toIso8601String(),
};
}
/// 相册列表条目(完整开关与多语言标题,管理列表无需二次请求详情)。
///
/// 契约 `components.schemas.AlbumSummary`(GET /albums,
/// operationId `listAlbums` 的条目)。
/// operationId `listAlbums` 的条目)。cover(UI-01 可选扩展):当前生效
/// 封面的短期签名 URL;旧响应缺键或未配置封面为 null。
class AlbumSummary {
const AlbumSummary({
required this.id,
@@ -93,6 +118,7 @@ class AlbumSummary {
required this.createdAt,
required this.updatedAt,
required this.statsSyncedAt,
this.cover,
});
factory AlbumSummary.fromJson(Map<String, dynamic> json) => AlbumSummary(
@@ -123,6 +149,9 @@ class AlbumSummary {
statsSyncedAt: json['statsSyncedAt'] == null
? null
: DateTime.parse(json['statsSyncedAt'] as String),
cover: json['cover'] == null
? null
: SignedUrl.fromJson(json['cover'] as Map<String, dynamic>),
);
/// 相册 ID。
@@ -176,6 +205,9 @@ class AlbumSummary {
/// 统计副本最近同步时间(P2 A27);null 表示尚无回传应用。
final DateTime? statsSyncedAt;
/// 相册封面签名 URL(UI-01);未配置封面或旧响应缺键为 null。
final SignedUrl? cover;
Map<String, dynamic> toJson() => {
'id': id,
'publicToken': publicToken,
@@ -196,6 +228,7 @@ class AlbumSummary {
'createdAt': createdAt.toUtc().toIso8601String(),
'updatedAt': updatedAt.toUtc().toIso8601String(),
'statsSyncedAt': statsSyncedAt?.toUtc().toIso8601String(),
'cover': cover?.toJson(),
};
}
+63 -3
View File
@@ -3,6 +3,8 @@
/// 契约来源:`packages/contracts/openapi.yaml` tag `photos`。
library;
import '../albums/models.dart' show SignedUrl;
/// 照片对外可见性。
///
/// 契约 `components.schemas.PhotoVisibility`:unpublished/published/hidden/
@@ -180,10 +182,57 @@ class AdminPhotoUploader {
};
}
/// 管理照片列表数据范围(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 内嵌),
/// GET /albums/{albumId}/photos(operationId `listAlbumPhotos`)的条目。
/// 契约 `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,
@@ -200,6 +249,7 @@ class AdminPhoto extends PhotoRecord {
required super.uploadedAt,
required super.publishedAt,
required super.likeCount,
this.preview,
});
factory AdminPhoto.fromJson(Map<String, dynamic> json) => AdminPhoto(
@@ -224,11 +274,21 @@ class AdminPhoto extends PhotoRecord {
? 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()};
Map<String, dynamic> toJson() => {
...super.toJson(),
'uploader': uploader.toJson(),
'preview': preview?.toJson(),
};
}