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(),
};
}
@@ -128,6 +128,7 @@ Map<String, dynamic> albumBody() => <String, dynamic>{
'createdAt': '2026-09-10T08:00:00Z',
'updatedAt': '2026-09-10T08:00:00Z',
'statsSyncedAt': null,
'cover': null,
'categories': [],
};
@@ -48,6 +48,7 @@ final Map<String, dynamic> albumSummaryFixture = <String, dynamic>{
'createdAt': '2026-09-10T08:00:00.000Z',
'updatedAt': '2026-09-10T08:00:00.000Z',
'statsSyncedAt': null,
'cover': null,
};
final Map<String, dynamic> categoryFixture = <String, dynamic>{
@@ -96,6 +97,26 @@ void main() {
expect(model.toJson(), equals(fixture));
});
test('AlbumSummary cover(UI-01):非空往返与缺键容错', () {
final withCover = {
...albumSummaryFixture,
'cover': {
'url':
'http://minio:9000/photo-live/dev/albums/a1/assets/c1.jpg?X-Amz-Signature=s',
'expiresAt': '2026-09-21T09:00:00.000Z',
},
};
final model = AlbumSummary.fromJson(withCover);
expect(model.cover, isNotNull);
expect(model.cover!.url, startsWith('http://minio:9000/'));
expect(model.cover!.expiresAt, DateTime.utc(2026, 9, 21, 9));
expect(model.toJson(), equals(withCover));
// 旧响应缺 cover 键 → null(旧响应不 break)。
final legacy = {...albumSummaryFixture}..remove('cover');
expect(AlbumSummary.fromJson(legacy).cover, isNull);
});
test('Category fromJson/toJson 往返', () {
final model = Category.fromJson(categoryFixture);
expect(model.sortOrder, 0);
@@ -75,14 +75,46 @@ void main() {
'account': 'collab01',
'displayName': '李四',
},
'preview': null,
};
final model = AdminPhoto.fromJson(fixture);
// e2e:uploader 映射取的是资料 displayName,而非账号。
expect(model.uploader.displayName, '李四');
expect(model.uploader.userId, model.uploaderId);
expect(model.preview, isNull);
expect(model.toJson(), equals(fixture));
});
test('AdminPhoto preview(UI-01):非空往返与缺键容错', () {
final withPreview = {
...photoRecordFixture,
'uploader': {
'userId': 'usr-col-02',
'account': 'collab01',
'displayName': '李四',
},
'preview': {
'thumb': {
'url': 'http://minio:9000/bucket/variants/thumb/p1.webp?X-Amz-Signature=t',
'expiresAt': '2026-09-21T09:00:00.000Z',
},
'browse': {
'url': 'http://minio:9000/bucket/variants/browse/p1.webp?X-Amz-Signature=b',
'expiresAt': '2026-09-21T09:00:00.000Z',
},
},
};
final model = AdminPhoto.fromJson(withPreview);
expect(model.preview, isNotNull);
expect(model.preview!.thumb.url, contains('/thumb/'));
expect(model.preview!.browse.expiresAt, DateTime.utc(2026, 9, 21, 9));
expect(model.toJson(), equals(withPreview));
// 旧响应缺 preview 键 → null(旧响应不 break)。
final legacy = {...withPreview}..remove('preview');
expect(AdminPhoto.fromJson(legacy).preview, isNull);
});
test('PhotoVisibility / PhotoProcessingState 全枚举解析', () {
for (final value in const [
'unpublished',
+2
View File
@@ -211,6 +211,7 @@ Map<String, dynamic> albumJson({
'createdAt': '2026-09-01T00:00:00Z',
'updatedAt': '2026-09-01T00:00:00Z',
'statsSyncedAt': null,
'cover': null,
};
/// 契约 `Category` 响应体。
@@ -285,6 +286,7 @@ Map<String, dynamic> adminPhotoJson({
'account': 'staff02',
'displayName': 'Staff Two',
},
'preview': null,
};
/// 契约游标分页响应体(hasMore 与 nextCursor 成对)。
+93 -2
View File
@@ -949,6 +949,11 @@ paths:
相册照片管理列表(区别于公开列表):全部状态可见(unpublished/published/
hidden/deleted),条目含 uploader 脱敏信息。按 uploadedAt 与 ID 稳定倒序
游标分页。
scope(UI-01 可选扩展,缺省 all=既有行为不变):协作者显式查询自己
上传的照片——服务端过滤 uploaderId=当前用户,成员资格逐请求校验;
仅 owner/admin 可用 all(协作者 all → 403)。条目 preview 按请求者
权限逐张裁决(见 PhotoPreview)。
security:
- bearerAuth: []
parameters:
@@ -962,6 +967,17 @@ paths:
description: 分类 ID 过滤。
schema:
type: string
- name: scope
in: query
description: |
数据范围(UI-01):all=本相册全部照片(缺省,仅 owner/admin);
own=仅当前用户上传(任何有效成员,服务端过滤)。
schema:
type: string
enum:
- all
- own
default: all
- $ref: '#/components/parameters/CursorQuery'
- name: limit
in: query
@@ -987,7 +1003,9 @@ paths:
'401':
$ref: '#/components/responses/Unauthorized'
'403':
description: 仅创建者或管理员可访问管理列表(AUTH_FORBIDDEN)。
description: |
仅创建者或管理员可访问全量管理列表(scope=all 或缺省时协作者
AUTH_FORBIDDEN);scope=own 需相册有效成员。
content:
application/json:
schema:
@@ -1915,6 +1933,8 @@ paths:
返回展示位绑定相册的近期已发布照片(≤20,按 publishedAt 与 ID 稳定
倒序,小尺寸缩略图省流量)与相册摘要。lang 语义:auto(缺省)或取值
不支持时按相册 defaultLocale 回退(不报 400,与公开相册一致)。
settings(UI-01)返回后台配置的归一化全集(嵌入端应用后台配置,
data-* 覆盖优先)。
- 防探测:slotToken 不存在、展示位停用(enabled=false)或未绑定相册
一律 404 NOT_FOUND(同码同文案,不区分原因,避免枚举探测)。
@@ -2797,6 +2817,16 @@ components:
统计副本最近同步时间(P2 A27:ov→cn 统计快照在本相册的应用时刻);
null 表示尚无回传应用(viewCountReplica 副本位仍为初始 0)。照片级
点赞副本(PhotoRecord.likeCount)的同步时间不经相册行表达。
cover:
description: |
相册封面摘要(UI-01,readOnly 可选扩展):当前生效封面资产
(album_assets kind=cover、state=effective 的最新一行)的短期
签名 URL;未配置封面为 null。列表端点按页批量组装(非逐相册
查询);旧客户端忽略本字段不受影响。
readOnly: true
anyOf:
- $ref: '#/components/schemas/SignedUrl'
- type: 'null'
AlbumList:
allOf:
@@ -3513,6 +3543,21 @@ components:
readOnly: true
description: 展示姓名(来自用户资料)。
PhotoPreview:
type: object
description: |
管理端照片预览(UI-01,readOnly):thumb/browse 短期签名 URL 与过期
时间。签发条件(服务端逐张裁决):请求者有该照片管理权(admin 全部 /
相册 owner 本相册全部 / 协作者仅自己上传)且照片有有效变体、未删除;
隐藏照片仅 admin/owner 签发。不满足条件时 AdminPhoto.preview 为 null
(PhotoRecord 本身仍如实返回)。URL 过期后经列表重取刷新。
required: [thumb, browse]
properties:
thumb:
$ref: '#/components/schemas/SignedUrl'
browse:
$ref: '#/components/schemas/SignedUrl'
AdminPhoto:
allOf:
- $ref: '#/components/schemas/PhotoRecord'
@@ -3521,6 +3566,14 @@ components:
properties:
uploader:
$ref: '#/components/schemas/AdminPhotoUploader'
preview:
description: |
受权限管理的照片预览(UI-01,readOnly 可选扩展;无变体/已删除/
无权签发时为 null)。旧客户端忽略本字段不受影响。
readOnly: true
anyOf:
- $ref: '#/components/schemas/PhotoPreview'
- type: 'null'
AdminPhotoList:
allOf:
@@ -4012,7 +4065,9 @@ components:
description: |
展示位展示设置。整体提交语义:创建时 settings 可整体缺省(各字段按
下列缺省值落库),提供时字段齐全;更新为整体替换(未单独提交
settings 则保持不变)。响应中字段恒齐全。
settings 则保持不变)。响应中字段恒齐全。UI-01 新增字段
(imageFit/offsetX/offsetBottom/zIndex)为可选扩展:提交时可缺省
(按默认值归一化落库),旧 JSON/旧客户端零影响;响应恒为归一化全集。
required: [position, size, intervalMs, lang, collapsedDefault]
properties:
position:
@@ -4041,6 +4096,33 @@ components:
type: boolean
description: 初始是否收起(收起后仅展示小把手,点击展开)。
default: false
imageFit:
type: string
description: |
轮播图片适配方式(UI-01):contain=完整展示于 4:3 舞台(中性底);
cover=裁切填满舞台。可缺省(默认 contain)。
enum:
- contain
- cover
default: contain
offsetX:
type: integer
minimum: 0
maximum: 120
description: 组件距宿主页面边缘的水平偏移(像素,UI-01)。可缺省。
default: 16
offsetBottom:
type: integer
minimum: 0
maximum: 240
description: 组件距宿主页面底部的垂直偏移(像素,UI-01)。可缺省。
default: 24
zIndex:
type: integer
minimum: 1
maximum: 2147483000
description: 组件在宿主页面的层级(UI-01)。可缺省。
default: 1000
EmbedSlot:
type: object
@@ -4374,6 +4456,8 @@ components:
照片,按 publishedAt 与 ID 稳定倒序,上限 20(空数组=相册暂无已发布
照片,嵌入端展示空态卡)。album 当前恒非 null(未绑定/停用的展示位
公开端一律 404 防探测);null 为契约前瞻保留,嵌入端按空态处理。
settings(UI-01 可选扩展)为后台配置的归一化全集(缺字段按默认值
补齐)——嵌入端据此应用后台配置,data-* 覆盖优先于它。
required: [slotToken, configVersion, album, photos]
properties:
slotToken:
@@ -4386,6 +4470,13 @@ components:
minimum: 1
readOnly: true
description: 配置版本(嵌入端变更检测:版本变化即整体重置渲染)。
settings:
description: |
展示设置(UI-01 可选扩展;ov 副本 JSON 按契约默认值归一化后返回,
恒为全集)。旧客户端忽略本字段不受影响。
readOnly: true
allOf:
- $ref: '#/components/schemas/EmbedSlotSettings'
album:
description: 绑定相册摘要;未绑定为 null(当前部署不会出现,见 schema 描述)。
anyOf:
+10 -1
View File
@@ -141,13 +141,22 @@ export interface SlotStateUpdatedPayload {
entityVersion: number;
}
/** 展示位设置载荷(契约 EmbedSlotSettings 的载荷形状;字段恒齐全)。 */
/**
* 展示位设置载荷(契约 EmbedSlotSettings 的载荷形状;字段恒齐全)。
* UI-01 新增字段为可选:cn 写入侧恒发归一化全集,但部署切替期间 outbox/
* sync_inbox 中在途事件仍是旧形状——消费侧(ov 投影/公开读)须对缺字段按
* 契约默认值归一化,不得假设恒在。
*/
export interface SlotSettingsPayload {
position: string; // left|right
size: string; // sm|md
intervalMs: number;
lang: string; // auto|zh-CN|en
collapsedDefault: boolean;
imageFit?: string; // contain|cover(默认 contain)
offsetX?: number; // 0..120(默认 16)
offsetBottom?: number; // 0..240(默认 24)
zIndex?: number; // 1..2147483000(默认 1000)
}
// 运行时校验用:全部合法事件类型字符串。satisfies 保证 数组 ⊆ 接口 union;
+71 -2
View File
@@ -418,6 +418,11 @@ export interface paths {
* @description 相册照片管理列表(区别于公开列表):全部状态可见(unpublished/published/
* hidden/deleted),条目含 uploader 脱敏信息。按 uploadedAt 与 ID 稳定倒序
* 游标分页。
*
* scope(UI-01 可选扩展,缺省 all=既有行为不变):协作者显式查询自己
* 上传的照片——服务端过滤 uploaderId=当前用户,成员资格逐请求校验;
* 仅 owner/admin 可用 all(协作者 all → 403)。条目 preview 按请求者
* 权限逐张裁决(见 PhotoPreview)。
*/
get: operations["listAlbumPhotos"];
put?: never;
@@ -905,6 +910,8 @@ export interface paths {
* 返回展示位绑定相册的近期已发布照片(≤20,按 publishedAt 与 ID 稳定
* 倒序,小尺寸缩略图省流量)与相册摘要。lang 语义:auto(缺省)或取值
* 不支持时按相册 defaultLocale 回退(不报 400,与公开相册一致)。
* settings(UI-01)返回后台配置的归一化全集(嵌入端应用后台配置,
* data-* 覆盖优先)。
*
* - 防探测:slotToken 不存在、展示位停用(enabled=false)或未绑定相册
* 一律 404 NOT_FOUND(同码同文案,不区分原因,避免枚举探测)。
@@ -1379,6 +1386,13 @@ export interface components {
* 点赞副本(PhotoRecord.likeCount)的同步时间不经相册行表达。
*/
readonly statsSyncedAt: string | null;
/**
* @description 相册封面摘要(UI-01,readOnly 可选扩展):当前生效封面资产
* (album_assets kind=cover、state=effective 的最新一行)的短期
* 签名 URL;未配置封面为 null。列表端点按页批量组装(非逐相册
* 查询);旧客户端忽略本字段不受影响。
*/
readonly cover?: components["schemas"]["SignedUrl"] | null;
};
AlbumList: components["schemas"]["CursorPageMeta"] & {
items: components["schemas"]["AlbumSummary"][];
@@ -1754,8 +1768,24 @@ export interface components {
/** @description 展示姓名(来自用户资料)。 */
readonly displayName: string;
};
/**
* @description 管理端照片预览(UI-01,readOnly):thumb/browse 短期签名 URL 与过期
* 时间。签发条件(服务端逐张裁决):请求者有该照片管理权(admin 全部 /
* 相册 owner 本相册全部 / 协作者仅自己上传)且照片有有效变体、未删除;
* 隐藏照片仅 admin/owner 签发。不满足条件时 AdminPhoto.preview 为 null
* (PhotoRecord 本身仍如实返回)。URL 过期后经列表重取刷新。
*/
PhotoPreview: {
thumb: components["schemas"]["SignedUrl"];
browse: components["schemas"]["SignedUrl"];
};
AdminPhoto: components["schemas"]["PhotoRecord"] & {
uploader: components["schemas"]["AdminPhotoUploader"];
/**
* @description 受权限管理的照片预览(UI-01,readOnly 可选扩展;无变体/已删除/
* 无权签发时为 null)。旧客户端忽略本字段不受影响。
*/
readonly preview?: components["schemas"]["PhotoPreview"] | null;
};
AdminPhotoList: components["schemas"]["CursorPageMeta"] & {
items: components["schemas"]["AdminPhoto"][];
@@ -2043,7 +2073,9 @@ export interface components {
/**
* @description 展示位展示设置。整体提交语义:创建时 settings 可整体缺省(各字段按
* 下列缺省值落库),提供时字段齐全;更新为整体替换(未单独提交
* settings 则保持不变)。响应中字段恒齐全。
* settings 则保持不变)。响应中字段恒齐全。UI-01 新增字段
* (imageFit/offsetX/offsetBottom/zIndex)为可选扩展:提交时可缺省
* (按默认值归一化落库),旧 JSON/旧客户端零影响;响应恒为归一化全集。
*/
EmbedSlotSettings: {
/**
@@ -2069,6 +2101,28 @@ export interface components {
* @default false
*/
collapsedDefault: boolean;
/**
* @description 轮播图片适配方式(UI-01):contain=完整展示于 4:3 舞台(中性底);
* cover=裁切填满舞台。可缺省(默认 contain)。
* @default contain
* @enum {string}
*/
imageFit: "contain" | "cover";
/**
* @description 组件距宿主页面边缘的水平偏移(像素,UI-01)。可缺省。
* @default 16
*/
offsetX: number;
/**
* @description 组件距宿主页面底部的垂直偏移(像素,UI-01)。可缺省。
* @default 24
*/
offsetBottom: number;
/**
* @description 组件在宿主页面的层级(UI-01)。可缺省。
* @default 1000
*/
zIndex: number;
};
/**
* @description 展示位条目(管理视角)。albumId/albumTitle 为当前绑定相册(null=未
@@ -2244,12 +2298,19 @@ export interface components {
* 照片,按 publishedAt 与 ID 稳定倒序,上限 20(空数组=相册暂无已发布
* 照片,嵌入端展示空态卡)。album 当前恒非 null(未绑定/停用的展示位
* 公开端一律 404 防探测);null 为契约前瞻保留,嵌入端按空态处理。
* settings(UI-01 可选扩展)为后台配置的归一化全集(缺字段按默认值
* 补齐)——嵌入端据此应用后台配置,data-* 覆盖优先于它。
*/
PublicEmbedSlot: {
/** @description 展示位公开令牌(回显请求路径)。 */
readonly slotToken: string;
/** @description 配置版本(嵌入端变更检测:版本变化即整体重置渲染)。 */
readonly configVersion: number;
/**
* @description 展示设置(UI-01 可选扩展;ov 副本 JSON 按契约默认值归一化后返回,
* 恒为全集)。旧客户端忽略本字段不受影响。
*/
readonly settings?: components["schemas"]["EmbedSlotSettings"];
/** @description 绑定相册摘要;未绑定为 null(当前部署不会出现,见 schema 描述)。 */
album: components["schemas"]["PublicEmbedAlbum"] | null;
/** @description 近期照片(publishedAt 与 ID 稳定倒序,小尺寸缩略图)。 */
@@ -3598,6 +3659,11 @@ export interface operations {
visibility?: components["schemas"]["PhotoVisibility"];
/** @description 分类 ID 过滤。 */
categoryId?: string;
/**
* @description 数据范围(UI-01):all=本相册全部照片(缺省,仅 owner/admin);
* own=仅当前用户上传(任何有效成员,服务端过滤)。
*/
scope?: "all" | "own";
/** @description 分页游标(上一页响应的 nextCursor)。 */
cursor?: components["parameters"]["CursorQuery"];
/** @description 每页条数。 */
@@ -3631,7 +3697,10 @@ export interface operations {
};
};
401: components["responses"]["Unauthorized"];
/** @description 仅创建者或管理员可访问管理列表(AUTH_FORBIDDEN)。 */
/**
* @description 仅创建者或管理员可访问全量管理列表(scope=all 或缺省时协作者
* AUTH_FORBIDDEN);scope=own 需相册有效成员。
*/
403: {
headers: {
[name: string]: unknown;
+68 -3
View File
@@ -13,12 +13,18 @@
// 映射,P1a 规模下先查成员 albumId 集再 id IN ——成员相册数为个位到十位级,
// 不构成规模问题;若 P5 需要,可换 $queryRaw 子查询,接口不变)。
// 排序 createdAt DESC, id DESC(稳定决胜),游标经 shared/cursor 键集推进。
// · 封面摘要(UI-01):AlbumSummary.cover = album_assets kind=cover、
// state=effective 的最新一行的短期签名 URL(未配置为 null)。列表按页
// 一次 findMany 批量取(orderBy createdAt/id DESC 后每相册取首行,同
// banner「新行生效」替换语义),presign 为 SigV4 本地计算——两者都不随
// 相册数放大,杜绝逐相册 N+1。
// 响应映射严格契约 camelCase(DB snake 列名 → AlbumSummary/Album 形状)。
import { randomBytes } from 'node:crypto';
import { HttpStatus, Injectable } from '@nestjs/common';
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts';
import type { components } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn';
import type { StoragePort } from '@photo/storage';
import { ApiException } from '../api-exception';
import type { AuthUser } from '../auth/session.guard';
import { PrismaService } from '../prisma/prisma.service';
@@ -28,6 +34,8 @@ import {
keysetWhereDesc,
} from '../shared/cursor';
import { buildAlbumUpdatedPayload } from '../sync/payloads';
import { STORAGE_PORT } from '../uploads/storage.provider';
import { signedUrlTtlSeconds } from '../uploads/support';
import type {
AlbumCreateRequestDto,
AlbumTransferRequestDto,
@@ -46,6 +54,7 @@ export type AlbumTranslationResponse =
export type AlbumSummaryResponse = components['schemas']['AlbumSummary'];
export type AlbumResponse = components['schemas']['Album'];
export type AlbumListResponse = components['schemas']['AlbumList'];
type SignedUrlResponse = components['schemas']['SignedUrl'];
const PUBLIC_TOKEN_LENGTH = 24;
const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
@@ -97,6 +106,7 @@ function toTranslationResponse(
function toSummaryResponse(
album: AlbumRow,
translations: AlbumTranslationRow[],
cover: SignedUrlResponse | null = null,
): AlbumSummaryResponse {
return {
id: album.id,
@@ -118,6 +128,9 @@ function toSummaryResponse(
// P2 Task 9(A27 同步时间展示):统计副本最近回传应用时刻;null=尚无回传。
statsSyncedAt:
album.statsSyncedAt === null ? null : album.statsSyncedAt.toISOString(),
// UI-01 封面摘要:未配置封面(无 effective cover 行)为 null;签名 URL
// 短期有效,过期经列表/详情重取刷新。
cover,
};
}
@@ -126,6 +139,7 @@ export class AlbumsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
) {}
/**
@@ -647,6 +661,29 @@ export class AlbumsService {
}
}
// UI-01 封面摘要:本页相册一次批量查询(kind=cover、state=effective,
// createdAt/id DESC 后每相册首行即最新 effective 行——同 banner 替换语义,
// cover 无 locale 偏好)。presign 为本地计算,循环不产生网络/查询放大。
const covers = await this.prisma.album_assets.findMany({
where: {
albumId: { in: page.map((a) => a.id) },
kind: 'cover',
state: 'effective',
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: { albumId: true, objectKey: true },
});
const coverKeyByAlbum = new Map<string, string>();
for (const row of covers) {
if (!coverKeyByAlbum.has(row.albumId)) {
coverKeyByAlbum.set(row.albumId, row.objectKey);
}
}
const coverByAlbum = new Map<string, SignedUrlResponse>();
for (const [albumId, objectKey] of coverKeyByAlbum) {
coverByAlbum.set(albumId, await this.presign(objectKey));
}
const last = page.at(-1);
// 契约 CursorPageMeta 不变式:hasMore=true 当且仅当 nextCursor≠null——
// 以 nextCursor 派生 hasMore,结构上保证两者成对(防御未来调用方绕过
@@ -659,7 +696,13 @@ export class AlbumsService {
})
: null;
return {
items: page.map((a) => toSummaryResponse(a, byAlbumId.get(a.id) ?? [])),
items: page.map((a) =>
toSummaryResponse(
a,
byAlbumId.get(a.id) ?? [],
coverByAlbum.get(a.id) ?? null,
),
),
nextCursor,
hasMore: nextCursor !== null,
};
@@ -710,7 +753,7 @@ export class AlbumsService {
}
}
return {
...toSummaryResponse(album, translations),
...toSummaryResponse(album, translations, await this.latestCoverOf(albumId)),
categories: categories.map((c) => ({
id: c.id,
albumId: c.albumId,
@@ -719,4 +762,26 @@ export class AlbumsService {
})),
};
}
/**
* 相册最新生效封面(kind=cover、state=effective,createdAt/id DESC 首行)
* 的短期签名 URL;未配置封面返回 null。
*/
private async latestCoverOf(
albumId: string,
): Promise<SignedUrlResponse | null> {
const row = await this.prisma.album_assets.findFirst({
where: { albumId, kind: 'cover', state: 'effective' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: { objectKey: true },
});
return row === null ? null : this.presign(row.objectKey);
}
/** 现签短期 GET 凭证(TTL 惰性读取;SigV4 本地计算无网络副作用)。 */
private async presign(objectKey: string): Promise<SignedUrlResponse> {
const ttl = signedUrlTtlSeconds();
const url = await this.storage.presignGet(objectKey, ttl);
return { url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() };
}
}
@@ -17,6 +17,9 @@
// (契约评审修正:含照片条数与最新发布时间——新照片发布必变,避免仅比对
// 实体版本时新照片小版本导致 304 陈旧);If-None-Match 匹配 → 304 无body
// (判定在控制器,服务只产出 body+ETag)。空照片列:后两元取 0 与空串。
// UI-01:响应携带 settings(ov 副本 JSON 经 normalizeStoredSettings 归一化
// ——缺字段(UI-01 前旧投影行)按契约默认值补齐,嵌入端据此应用后台配置)。
// 设置变更必递增 configVersion(cn 管理面约定),ETag 随之变化,无需改四元组。
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import {
@@ -30,6 +33,7 @@ import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
import { OvRedisService } from '../redis-ov/ov-redis.module';
import { signedUrlTtlSeconds } from '../uploads/support';
import { STORAGE_PORT_OV } from '../public-ov/storage-ov.provider';
import { normalizeStoredSettings } from '../embed/embed.service';
import type { StoragePort } from '@photo/storage';
export type PublicEmbedSlotResponse = components['schemas']['PublicEmbedSlot'];
@@ -152,6 +156,8 @@ export class EmbedOvService {
body: {
slotToken,
configVersion: slot.configVersion,
// UI-01:后台展示设置归一化全集(旧投影行缺字段 → 契约默认值)。
settings: normalizeStoredSettings(slot.settings),
album: {
publicToken: album.publicToken,
title,
+39
View File
@@ -4,6 +4,8 @@
// · name 1..64(VarChar(64) 列宽);
// · settings 整体提交语义:创建可整体缺省(各字段按缺省值落库,服务层
// 归一),提供时字段齐全——嵌套 DTO 无 @IsOptional,缺字段 400;
// UI-01 新增字段(imageFit/offsetX/offsetBottom/zIndex)例外:全部
// @IsOptional(旧客户端/旧 JSON 兼容),缺省由服务层按契约默认值归一;
// · albumId [string,'null']:非 null 绑定/换绑(≤64,绑定存在性校验在
// 服务层事务内),null 解绑。@IsOptional 对 undefined/null 均跳过校验
// 且 null 值保留在 DTO 上——服务层以 `!== undefined` 区分「未提交」与
@@ -35,6 +37,15 @@ const EMBED_SLOT_SIZES = ['sm', 'md'] as const;
export const EMBED_INTERVAL_MS_MIN = 3000;
export const EMBED_INTERVAL_MS_MAX = 30000;
/** UI-01 新增字段枚举与边界(全部可选:缺省由服务层归一化为默认值)。 */
const EMBED_SLOT_IMAGE_FITS = ['contain', 'cover'] as const;
export const EMBED_OFFSET_X_MIN = 0;
export const EMBED_OFFSET_X_MAX = 120;
export const EMBED_OFFSET_BOTTOM_MIN = 0;
export const EMBED_OFFSET_BOTTOM_MAX = 240;
export const EMBED_Z_INDEX_MIN = 1;
export const EMBED_Z_INDEX_MAX = 2147483000;
export class EmbedSlotSettingsDto {
@IsIn(EMBED_SLOT_POSITIONS)
position!: (typeof EMBED_SLOT_POSITIONS)[number];
@@ -52,6 +63,34 @@ export class EmbedSlotSettingsDto {
@IsBoolean()
collapsedDefault!: boolean;
// —— UI-01 可选扩展(旧 JSON 兼容:缺省按默认值归一化落库)——
/** 图片适配方式;缺省 contain。 */
@IsOptional()
@IsIn(EMBED_SLOT_IMAGE_FITS)
imageFit?: (typeof EMBED_SLOT_IMAGE_FITS)[number];
/** 距宿主边缘水平偏移(0..120px);缺省 16。 */
@IsOptional()
@IsInt()
@Min(EMBED_OFFSET_X_MIN)
@Max(EMBED_OFFSET_X_MAX)
offsetX?: number;
/** 距宿主底部垂直偏移(0..240px);缺省 24。 */
@IsOptional()
@IsInt()
@Min(EMBED_OFFSET_BOTTOM_MIN)
@Max(EMBED_OFFSET_BOTTOM_MAX)
offsetBottom?: number;
/** 宿主页面层级;缺省 1000。 */
@IsOptional()
@IsInt()
@Min(EMBED_Z_INDEX_MIN)
@Max(EMBED_Z_INDEX_MAX)
zIndex?: number;
}
export class EmbedSlotCreateRequestDto {
+41 -3
View File
@@ -52,13 +52,17 @@ export type EmbedSlotSettingsResponse =
const PUBLIC_TOKEN_LENGTH = 24;
const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
/** 契约 EmbedSlotSettings 各字段缺省值(整体缺省时落库形态)。 */
/** 契约 EmbedSlotSettings 各字段缺省值(整体缺省时落库形态;UI-01 新字段同列)。 */
export const DEFAULT_EMBED_SLOT_SETTINGS: EmbedSlotSettingsResponse = {
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
};
/**
@@ -82,7 +86,12 @@ function generatePublicToken(): string {
return token;
}
/** 入参 settings → 归一化存储值(null/undefined=整体缺省 → 契约缺省值)。 */
/**
* 入参 settings → 归一化存储值(null/undefined=整体缺省 → 契约缺省值)。
* UI-01 新字段可缺省(旧客户端兼容):缺省按契约默认值归一,落库恒为全集
* ——旧存储行(无新字段)的读取侧归一在 embed-ov 公开响应完成(cn 管理
* 响应经本服务 toResponse 不做读归一,行由本函数写入即已全集)。
*/
function normalizeSettings(
settings: EmbedSlotSettingsDto | null | undefined,
): EmbedSlotSettingsResponse {
@@ -95,6 +104,34 @@ function normalizeSettings(
intervalMs: settings.intervalMs,
lang: settings.lang,
collapsedDefault: settings.collapsedDefault,
imageFit: settings.imageFit ?? DEFAULT_EMBED_SLOT_SETTINGS.imageFit,
offsetX: settings.offsetX ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetX,
offsetBottom:
settings.offsetBottom ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetBottom,
zIndex: settings.zIndex ?? DEFAULT_EMBED_SLOT_SETTINGS.zIndex,
};
}
/**
* 存储行 settings JSON → 归一化全集(readOnly 读取侧,cn 管理响应与 ov 公开
* 响应共用):UI-01 前落库的旧行无新字段,缺字段按契约默认值补齐——响应
* 恒为全集(契约「响应中字段恒齐全」),不要求全量改写旧记录。
*/
export function normalizeStoredSettings(
stored: Prisma.JsonValue,
): EmbedSlotSettingsResponse {
const row = (stored ?? {}) as Partial<EmbedSlotSettingsResponse>;
return {
position: row.position ?? DEFAULT_EMBED_SLOT_SETTINGS.position,
size: row.size ?? DEFAULT_EMBED_SLOT_SETTINGS.size,
intervalMs: row.intervalMs ?? DEFAULT_EMBED_SLOT_SETTINGS.intervalMs,
lang: row.lang ?? DEFAULT_EMBED_SLOT_SETTINGS.lang,
collapsedDefault:
row.collapsedDefault ?? DEFAULT_EMBED_SLOT_SETTINGS.collapsedDefault,
imageFit: row.imageFit ?? DEFAULT_EMBED_SLOT_SETTINGS.imageFit,
offsetX: row.offsetX ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetX,
offsetBottom: row.offsetBottom ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetBottom,
zIndex: row.zIndex ?? DEFAULT_EMBED_SLOT_SETTINGS.zIndex,
};
}
@@ -354,7 +391,8 @@ export class EmbedSlotsService {
albumId: slot.albumId,
albumTitle,
configVersion: slot.configVersion,
settings: slot.settings as EmbedSlotSettingsResponse,
// 读取侧归一化:UI-01 前旧行缺新字段 → 契约默认值补齐(响应恒全集)。
settings: normalizeStoredSettings(slot.settings),
updatedAt: slot.updatedAt.toISOString(),
};
}
@@ -22,7 +22,11 @@ import {
import { userOf } from '../albums/albums.controller';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { parseCursor, parseLimit } from '../shared/pagination';
import { PhotoHideBatchDto, parseVisibilityFilter } from './dto';
import {
PhotoHideBatchDto,
parseScopeFilter,
parseVisibilityFilter,
} from './dto';
import {
PhotosService,
type AdminPhotoListResponse,
@@ -37,12 +41,15 @@ export class AlbumPhotosController {
/**
* 管理照片列表:全部可见性状态可见(区别于公开列表仅 published),
* (uploadedAt,id) 稳定倒序键集游标;条目内嵌 uploader 脱敏信息。
* scope=own(UI-01):协作者显式查询自己上传(服务端过滤);缺省 all
* 行为不变(仅 owner/admin)。
*/
@Get()
listAlbumPhotos(
@Param('albumId') albumId: string,
@Query('visibility') visibility: string | undefined,
@Query('categoryId') categoryId: string | undefined,
@Query('scope') scope: string | undefined,
@Query('cursor') cursor: string | undefined,
@Query('limit') limit: string | undefined,
@Req() req: AuthenticatedRequest,
@@ -51,6 +58,7 @@ export class AlbumPhotosController {
visibility: parseVisibilityFilter(visibility),
// 空串视为缺省(同公开列表 category 规整惯例)。
categoryId: categoryId === '' ? undefined : categoryId,
scope: parseScopeFilter(scope),
cursor: parseCursor(cursor),
limit: parseLimit(limit),
});
+26 -1
View File
@@ -1,6 +1,6 @@
// services/api/src/photos/dto.ts —— 照片域入参规整(Task 24 起步:PATCH body;
// P1b Task 4 增补:管理列表 visibility 查询参数;P4a Task 2 增补:批量隐藏
// 请求体)。校验约束对齐契约:
// 请求体;UI-01 增补:管理列表 scope 查询参数 all|own)。校验约束对齐契约:
// PhotoVisibilityUpdateRequest.visibility 仅允许 hidden/published(enum 之外
// 任何值 → 400 VALIDATION_FAILED);deleted 只能经 DELETE 进入,不开放 PATCH
// 直达(单向终态,见 photos.service 状态机)。
@@ -67,3 +67,28 @@ export function parseVisibilityFilter(
}
return raw as PhotoVisibility;
}
/** 契约 listAlbumPhotos scope 参数取值(UI-01:all=缺省既有行为;own=仅自己上传)。 */
export const ALBUM_PHOTO_SCOPES = ['all', 'own'] as const;
export type AlbumPhotoScope = (typeof ALBUM_PHOTO_SCOPES)[number];
/**
* 管理列表 scope 查询参数(UI-01):空串视为缺省(同 visibility 规整惯例,
* ?scope= 等同未提交= all);非空须为 all|own,否则 400 VALIDATION_FAILED。
* 权限差异(collab 仅 own、all 仅 owner/admin)在服务层裁决。
*/
export function parseScopeFilter(
raw: string | undefined,
): AlbumPhotoScope | undefined {
if (raw === undefined || raw === '') {
return undefined;
}
if (!(ALBUM_PHOTO_SCOPES as readonly string[]).includes(raw)) {
throw new ApiException(
ErrorCodes.VALIDATION_FAILED,
'scope 取值非法',
HttpStatus.BAD_REQUEST,
);
}
return raw as AlbumPhotoScope;
}
+7 -4
View File
@@ -1,9 +1,12 @@
// services/api/src/photos/photos.module.ts —— 照片管理模块(Task 24;P1b Task 4
// 增补管理列表控制器)。装配:PhotosController/AlbumPhotosController/
// PhotosService + PermissionsService(经 AlbumsModule 导入复用 A02 逐次权限
// 判定;同 uploads 模块的装配先例)。
// 增补管理列表控制器;UI-01 增补 StoragePort——管理列表 preview 现签)。
// 装配:PhotosController/AlbumPhotosController/PhotosService +
// PermissionsService(经 AlbumsModule 导入复用 A02 逐次权限判定;同 uploads
// 模块的装配先例)+ storageProvider(与 AlbumsModule 同一 DI 模式:直接复用
// provider 工厂而非 import UploadsModule,避免模块环)。
import { Module } from '@nestjs/common';
import { AlbumsModule } from '../albums/albums.module';
import { storageProvider } from '../uploads/storage.provider';
import { AlbumPhotosController } from './album-photos.controller';
import { PhotosController } from './photos.controller';
import { PhotosService } from './photos.service';
@@ -11,6 +14,6 @@ import { PhotosService } from './photos.service';
@Module({
imports: [AlbumsModule],
controllers: [PhotosController, AlbumPhotosController],
providers: [PhotosService],
providers: [PhotosService, storageProvider],
})
export class PhotosModule {}
+108 -17
View File
@@ -15,6 +15,11 @@
// 权限(§2 矩阵,A02 逐次校验):canManagePhoto = admin 全部 / 相册 owner 全部 /
// 协作者仅自己上传。404/403 映射:照片不存在或相册不可见(非成员)→ 404
// PHOTO_NOT_FOUND(防枚举);可见但无管理权(协作者对他人照片)→ 403。
// UI-01 增补:listAlbumPhotos scope=own(协作者显式查询自己上传,服务端过滤
// uploaderId,成员资格逐请求检查——沿用 canViewAlbum 逐次直查口径)+ 条目
// preview(受权限管理的 thumb/browse 短期签名 URL;变体按页一次批量查询,
// 逐张裁决:无 thumb/browse 变体、已删除 → null;隐藏 → 仅 admin/owner;
// 无权签发不改变 PhotoRecord 本身——记录可见性与图片可见性分离)。
// 每次真实状态迁移:条件更新(WHERE visibility=期望前态,并发下恰一人胜出)
// + entityVersion 自增 + 事务内 outbox 事件(P2 副本按 entityVersion 单调消费)
// + audit。幂等前态(同值 PATCH / 重复 DELETE)直接回显当前记录,不产生
@@ -22,10 +27,11 @@
// P4a Task 5 检查点:可见性真实迁移(transition/hideBatch)提交后评估用量
// 阈值告警(deleted 不计用量——软删除可能解除告警;hidden 仍计)。评估在
// 自开事务执行且失败只记日志,绝不影响可见性操作本身(见 evaluateUsageSafe)。
import { Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ErrorCodes, type components } from '@photo/contracts';
import { evaluateUsageSafe } from '@photo/db-cn';
import type { Prisma } from '@photo/db-cn';
import type { StoragePort } from '@photo/storage';
import type { AuthUser } from '../auth/session.guard';
import { PermissionsService } from '../albums/permissions.service';
import {
@@ -40,7 +46,13 @@ import {
keysetWhereDesc,
} from '../shared/cursor';
import { buildPhotoPublishedPayload } from '../sync/payloads';
import type { PhotoVisibility, PhotoVisibilityTarget } from './dto';
import { STORAGE_PORT } from '../uploads/storage.provider';
import { signedUrlTtlSeconds } from '../uploads/support';
import type {
AlbumPhotoScope,
PhotoVisibility,
PhotoVisibilityTarget,
} from './dto';
import {
photoForbidden,
photoNotFound,
@@ -51,6 +63,8 @@ export type PhotoRecord = components['schemas']['PhotoRecord'];
export type AdminPhotoListResponse = components['schemas']['AdminPhotoList'];
export type BatchHideResult = components['schemas']['BatchHideResult'];
export type BatchHideItem = components['schemas']['BatchHideItem'];
export type PhotoPreviewResponse = components['schemas']['PhotoPreview'];
type SignedUrlResponse = components['schemas']['SignedUrl'];
type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
@@ -81,6 +95,7 @@ export class PhotosService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
) {}
/**
@@ -91,6 +106,12 @@ export class PhotosService {
* (协作者)→ 403 AUTH_FORBIDDEN;admin 对幽灵 id 权限恒真,空相册兜底 404。
* 排序 (uploadedAt,id) DESC 键集游标(shared/cursor)。
*
* scope(UI-01):缺省/'all'=上述既有行为(仅 owner/admin,协作者 403 不
* 变);'own'=显式查询自己上传——任何有效成员可用(canViewAlbum 逐请求
* 直查成员行:被移除后下一次请求即刻 404),服务端追加 uploaderId 过滤;
* admin/owner 同样可用 own(过滤为自己上传的历史)。preview(UI-01)逐张
* 裁决见 previewOf。
*
* 未完成上传(pending_upload、uploadedAt NULL)不入列表:uploadedAt 既是契约
* 必返字段又是键集排序键——NULL 行既无法以契约形状返回(toPhotoRecord 的
* new Date(0) 兜底会伪造 1970 时间轴),也无法参与键集谓词(SQL 对 NULL 的
@@ -102,14 +123,19 @@ export class PhotosService {
query: {
visibility?: PhotoVisibility;
categoryId?: string;
scope?: AlbumPhotoScope;
cursor?: string;
limit: number;
},
): Promise<AdminPhotoListResponse> {
const scope: AlbumPhotoScope = query.scope ?? 'all';
if (!(await this.permissions.canViewAlbum(user, albumId))) {
throw albumNotFound();
}
if (!(await this.permissions.canManageAlbum(user, albumId))) {
// manager=admin/owner:全量可见 + 隐藏照片可签发预览。scope=all(缺省)
// 仅 manager(协作者 403,既有行为不变);scope=own 任何有效成员可用。
const manager = await this.permissions.canManageAlbum(user, albumId);
if (scope === 'all' && !manager) {
throw albumForbidden('仅创建者或管理员可访问管理列表');
}
// admin 对不存在 id 的 canView/canManage 恒真(非 admin 的可见成员行本身
@@ -151,6 +177,9 @@ export class PhotosService {
...(query.categoryId !== undefined
? { categoryId: query.categoryId }
: {}),
// scope=own:服务端过滤(客户端传 uploaderId 不可信),与 canViewAlbum
// 同请求内裁决——权限撤销后下一次请求即刻失效。
...(scope === 'own' ? { uploaderId: user.id } : {}),
};
if (decoded !== null) {
Object.assign(where, keysetWhereDesc('uploadedAt', 'id', decoded));
@@ -173,23 +202,51 @@ export class PhotosService {
});
const userById = new Map(users.map((u) => [u.id, u]));
const items = page.flatMap((row) => {
// UI-01 preview:本页变体一次批量查询(photoId IN 页内 id 集,杜绝逐张
// N+1);裁决在 previewOf(presign 为 SigV4 本地计算,无网络/查询放大)。
const variantRows =
page.length === 0
? []
: await this.prisma.photo_variants.findMany({
where: { photoId: { in: page.map((p) => p.id) } },
select: { photoId: true, variant: true, objectKey: true },
});
const variantKeysByPhoto = new Map<
string,
{ thumb?: string; browse?: string }
>();
for (const row of variantRows) {
const bucket = variantKeysByPhoto.get(row.photoId) ?? {};
if (row.variant === 'thumb') {
bucket.thumb = row.objectKey;
} else if (row.variant === 'browse') {
bucket.browse = row.objectKey;
}
variantKeysByPhoto.set(row.photoId, bucket);
}
const items: AdminPhotoListResponse['items'] = [];
for (const row of page) {
const uploader = userById.get(row.uploaderId);
// 用户行缺失(P1a 无用户删除,仅可能来自手工改库):跳过而非 5xx(同
// 成员列表先例,单条脏数据不拖垮整页)。
return uploader === undefined
? []
: [
{
...toPhotoRecord(row),
uploader: {
userId: uploader.id,
account: uploader.account,
displayName: uploader.displayName,
},
},
];
});
if (uploader === undefined) {
continue;
}
items.push({
...toPhotoRecord(row),
uploader: {
userId: uploader.id,
account: uploader.account,
displayName: uploader.displayName,
},
preview: await this.previewOf(
row,
variantKeysByPhoto.get(row.id),
manager,
),
});
}
const last = page.at(-1);
const nextCursor =
@@ -203,6 +260,40 @@ export class PhotosService {
return { items, nextCursor, hasMore: nextCursor !== null };
}
/**
* UI-01 受权限管理的预览裁决:thumb 与 browse 变体齐备且未删除才签发;
* 隐藏照片仅 manager(admin/owner)签发——协作者即便自己上传(scope=own
* 可见记录)也不获得隐藏图 URL(「隐藏是运营动作」,与恢复发布同一权限
* 口径)。无变体(处理未完成/失败)与已删除返回 null,PhotoRecord 本身
* 不受影响(记录可见性与图片可见性分离)。签名短期有效,过期经列表重取。
*/
private async previewOf(
row: PhotoRow,
keys: { thumb?: string; browse?: string } | undefined,
manager: boolean,
): Promise<PhotoPreviewResponse | null> {
if (keys?.thumb === undefined || keys?.browse === undefined) {
return null;
}
if (row.visibility === 'deleted') {
return null;
}
if (row.visibility === 'hidden' && !manager) {
return null;
}
return {
thumb: await this.presign(keys.thumb),
browse: await this.presign(keys.browse),
};
}
/** 现签短期 GET 凭证(TTL 惰性读取;SigV4 本地计算无网络副作用)。 */
private async presign(objectKey: string): Promise<SignedUrlResponse> {
const ttl = signedUrlTtlSeconds();
const url = await this.storage.presignGet(objectKey, ttl);
return { url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() };
}
/**
* 批量隐藏(契约 hidePhotosBatch,P4a A16):逐项处理、部分成功——单项失败
* (不存在/已删除/状态不可切换)只标记该项 ok=false + code,不影响其余项。
+2 -1
View File
@@ -25,8 +25,9 @@ import { hashSessionToken } from '../src/auth/session.guard';
import { API_PREFIX } from '../src/shared/constants';
import { resetDb } from './reset';
/** 契约 AlbumSummary 的必返字段全集(列表条目形状精确断言用)。 */
/** 契约 AlbumSummary 的必返字段全集(列表条目形状精确断言用;UI-01 增 cover)。 */
const ALBUM_SUMMARY_KEYS = [
'cover',
'createdAt',
'defaultLocale',
'downloadEnabled',
+165
View File
@@ -22,6 +22,8 @@
// 版本直投生效(乱序收敛);
// ⑧ 限速:压小窗口(EMBED_WINDOW_REQUESTS 惰性 env)→ 超额 429
// RATE_LIMITED(304 也消耗预算)。
// ⑨ UI-01 配置字段全链路:新字段创建/校验(越界/非法 400)/旧形状归一化
// → slot-state 投影 → ov 公开 settings 全集;旧投影行读取侧默认值归一。
// 真实 MySQL×2 + MinIO(真实 JPEG 上传发布)+ HMAC 同步链路字节一致。
import { randomBytes } from 'node:crypto';
import { NestFactory } from '@nestjs/core';
@@ -281,12 +283,17 @@ describe('embed slots (e2e)', () => {
},
});
expect(second.status).toBe(201);
// UI-01:旧形状提交(无新字段)→ 响应为归一化全集(新字段按默认值)。
expect(second.body.settings).toEqual({
position: 'left',
size: 'sm',
intervalMs: 8000,
lang: 'zh-CN',
collapsedDefault: true,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
});
// 校验失败:settings 缺字段 / intervalMs 越界 / 空 name。
@@ -748,6 +755,164 @@ describe('embed slots (e2e)', () => {
}
}
});
it('⑨ UI-01 配置字段:cn 校验/归一化 → slot-state 投影 → ov 公开 settings', async () => {
// 清限速计数(⑧ 压窗残留不影响本用例;默认窗口 120/60s 内用不顶)。
const keys = await redisOv.keys('embed:window:*');
if (keys.length > 0) {
await redisOv.del(keys);
}
// 创建即带全量新字段 → cn 响应回显(admin 全集形状)。
const created = await request(cnBase)
.post('/v1/admin/embed-slots')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: 'UI01 配置位',
settings: {
position: 'left',
size: 'md',
intervalMs: 6000,
lang: 'auto',
collapsedDefault: true,
imageFit: 'cover',
offsetX: 40,
offsetBottom: 80,
zIndex: 5000,
},
});
expect(created.status).toBe(201);
const ui01SlotId = created.body.id as string;
const ui01Token = created.body.publicToken as string;
expect(created.body.settings).toEqual({
position: 'left',
size: 'md',
intervalMs: 6000,
lang: 'auto',
collapsedDefault: true,
imageFit: 'cover',
offsetX: 40,
offsetBottom: 80,
zIndex: 5000,
});
// 新字段校验:非法枚举/越界/非整数 → 400(老字段约束不回归)。
const validBase = {
position: 'left',
size: 'md',
intervalMs: 6000,
lang: 'auto',
collapsedDefault: true,
imageFit: 'cover',
offsetX: 40,
offsetBottom: 80,
zIndex: 5000,
} as Record<string, unknown>;
for (const bad of [
{ imageFit: 'fill' },
{ offsetX: 121 },
{ offsetX: -1 },
{ offsetX: 1.5 },
{ offsetBottom: 241 },
{ zIndex: 0 },
{ zIndex: 2147483001 },
]) {
const res = await request(cnBase)
.patch(`/v1/admin/embed-slots/${ui01SlotId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ settings: { ...validBase, ...bad } });
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
// 旧形状整体替换(无新字段)→ 归一化落库(新字段回默认值)+ configVersion+1。
const legacyShape = {
position: 'left',
size: 'sm',
intervalMs: 6000,
lang: 'auto',
collapsedDefault: false,
};
const patched = await request(cnBase)
.patch(`/v1/admin/embed-slots/${ui01SlotId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ settings: legacyShape });
expect(patched.status).toBe(200);
expect(patched.body.configVersion).toBe(2);
const normalizedFull = {
...legacyShape,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
};
expect(patched.body.settings).toEqual(normalizedFull);
// 新字段显式 PATCH → 回显显式值。
const explicit = await request(cnBase)
.patch(`/v1/admin/embed-slots/${ui01SlotId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
settings: {
...legacyShape,
imageFit: 'cover',
offsetX: 8,
offsetBottom: 120,
zIndex: 999,
},
});
expect(explicit.status).toBe(200);
const explicitFull = {
...legacyShape,
imageFit: 'cover',
offsetX: 8,
offsetBottom: 120,
zIndex: 999,
};
expect(explicit.body.settings).toEqual(explicitFull);
// 绑定相册 A → 排空发送器 → ov 公开响应 settings = 投影完整全集。
const bound = await request(cnBase)
.patch(`/v1/admin/embed-slots/${ui01SlotId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ albumId: albumAId });
expect(bound.status).toBe(200);
await drainSender();
const feed = await getEmbed(ui01Token);
expect(feed.status).toBe(200);
expect(feed.body.settings).toEqual(explicitFull);
// 既有字段不回归:album/photos 照常返回。
expect(feed.body.album.publicToken).toBe(albumAToken);
expect(feed.body.photos.length).toBeGreaterThan(0);
// 旧形状存量投影行(UI-01 前 ov 副本):直改副本 settings 模拟旧投影 →
// 公开响应按契约默认值归一(读取侧归一化,无需回填事件)。
await prismaOv.embed_slots_ov.update({
where: { slotToken: ui01Token },
data: {
settings: {
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false,
},
},
});
const staleFeed = await getEmbed(ui01Token);
expect(staleFeed.status).toBe(200);
expect(staleFeed.body.settings).toEqual({
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
});
});
});
/** ETag 第二元:副本最新已发布照片的 publishedAt 毫秒(缺行 0)。 */
+3 -1
View File
@@ -38,7 +38,8 @@ function makePublicToken(): string {
return token;
}
/** 契约 PhotoRecord 必返字段 + 管理列表内嵌 uploader(形状精确断言用)。 */
/** 契约 PhotoRecord 必返字段 + 管理列表内嵌 uploader(形状精确断言用;
* UI-01 增 preview——服务恒发(无权/无变体为 null),键恒在)。 */
const ADMIN_PHOTO_KEYS = [
'albumId',
'bytes',
@@ -46,6 +47,7 @@ const ADMIN_PHOTO_KEYS = [
'height',
'likeCount',
'photoId',
'preview',
'processingState',
'publishedAt',
'shotAt',
@@ -0,0 +1,627 @@
// services/api/test/ui01-contracts.e2e-spec.ts —— UI-01 Task 2 契约扩展 e2e:
// cover 封面摘要 / preview 受权限预览 / scope=own 数据范围(真实 app + MySQL +
// MinIO:签名 URL 实取回字节,N+1 用 Prisma 中间件计真实查询数)。覆盖:
// ① listAlbums cover:有封面 → url 短期签名且可取回真实字节;同 kind 多行
// 取最新 effective(createdAt/id DESC 首行);pending 行不计;无封面 →
// null;getAlbum 详情同形;批量组装无 N+1(6 相册单页,相册域 Prisma 查
// 询数恒定:admin=3 albums+translations+album_assets / 非 admin=4 另加成
// 员行查询,与封面相册数无关);
// ② preview 权限矩阵(scope=all,owner/admin):published 签发(thumb+browse
// 可取回字节);hidden 对 manager 签发;deleted / 无变体 → null(记录
// 本身仍如实返回);
// ③ scope=own:collab 仅见自己上传(服务端过滤,uploaderId 全等于本人);
// 自己的 hidden 行可见但 preview=null(隐藏签发仅 admin/owner);owner
// scope=own 过滤为自己上传;旧请求(无 scope)owner 全量行为不变;
// ④ 权限边界:collab scope=all(缺省与显式)→ 403;被移除成员 scope=own →
// 404(成员资格逐请求);非成员跨相册 → 404;scope 非法值 → 400。
import { randomBytes } from 'node:crypto';
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import {
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { PrismaClient } from '@photo/db-cn';
import { ErrorCodes } from '@photo/contracts';
import { AppCnModule } from '../src/app-cn.module';
import { hashSessionToken } from '../src/auth/session.guard';
import { PrismaService } from '../src/prisma/prisma.service';
import { API_PREFIX } from '../src/shared/constants';
import { resetDb } from './reset';
jest.setTimeout(120_000);
/** 契约 pattern ^[0-9a-z]{20,32}$(同 albums.service 生成式口径)。 */
function makePublicToken(): string {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
const bytes = randomBytes(24);
let token = '';
for (let i = 0; i < 24; i++) {
token += alphabet[bytes[i] % alphabet.length];
}
return token;
}
/** 固定时间基(分钟偏移构造确定性 uploadedAt/createdAt 序列)。 */
const T0 = Date.UTC(2026, 8, 20, 8, 0, 0);
const atMinute = (m: number): Date => new Date(T0 + m * 60_000);
describe('UI-01 契约扩展:cover / preview / scope=own (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let s3: S3Client;
let ownerId: string;
let collabId: string;
let adminId: string;
let outsiderId: string;
let ownerToken: string;
let collabToken: string;
let adminToken: string;
let outsiderToken: string;
/** 主相册 A(owner + collab):封面 + 全状态照片矩阵。 */
let albumAId: string;
/** 相册 B(owner 独有,无封面无照片):无封面对照 + 跨册 404。 */
let albumBId: string;
/** 其余 4 个带封面相册(N+1 批量断言用,owner 名下)。 */
const fillerIds: string[] = [];
let coverOldAssetId: string;
let coverNewAssetId: string;
const coverOldBytes = Buffer.from([0x11, 0x12, 0x13, 0x14]);
const coverNewBytes = Buffer.from([0x21, 0x22, 0x23, 0x24, 0x25]);
let phPubC: string; // published / collab / 有变体
let phPubO: string; // published / owner / 有变体(字节取回断言用)
let phHidC: string; // hidden / collab / 有变体
let phHidO: string; // hidden / owner / 有变体
let phDelO: string; // deleted / owner / 有变体
let phNovarC: string; // unpublished / collab / 无变体
const pubOBrowseBytes = Buffer.from([0x31, 0x32, 0x33, 0x34, 0x35, 0x36]);
/** MinIO 实写对象(封面/预览可取回性)。 */
async function putObject(key: string, body: Buffer): Promise<void> {
await s3.send(
new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: key,
Body: body,
ContentType: 'image/jpeg',
}),
);
}
async function fetchBody(url: string): Promise<Buffer> {
const res = await fetch(url);
expect(res.status).toBe(200);
return Buffer.from(await res.arrayBuffer());
}
function listAlbums(token: string, query?: Record<string, string | number>) {
return request(app.getHttpServer())
.get(`/${API_PREFIX}/albums`)
.set('Authorization', `Bearer ${token}`)
.query(query ?? {});
}
function listPhotos(
token: string,
albumId: string,
query?: Record<string, string | number>,
) {
return request(app.getHttpServer())
.get(`/${API_PREFIX}/albums/${albumId}/photos`)
.set('Authorization', `Bearer ${token}`)
.query(query ?? {});
}
beforeAll(async () => {
await resetDb();
const moduleRef = await Test.createTestingModule({
imports: [AppCnModule],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix(API_PREFIX);
await app.init();
prisma = new PrismaClient();
s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY as string,
secretAccessKey: process.env.S3_SECRET_KEY as string,
},
forcePathStyle: true,
});
const mkUser = (account: string, displayName: string, role = 'staff') =>
prisma.users.create({
data: { account, role, passwordHash: 'x', displayName },
select: { id: true },
});
ownerId = (await mkUser('u01-owner', '欧阳雯')).id;
collabId = (await mkUser('u01-collab', '程澜')).id;
adminId = (await mkUser('u01-admin', '艾管', 'admin')).id;
outsiderId = (await mkUser('u01-outsider', '韦外')).id;
const mkToken = async (userId: string): Promise<string> => {
const token = randomBytes(32).toString('hex');
await prisma.sessions.create({
data: {
userId,
tokenHash: hashSessionToken(token),
expiresAt: new Date(Date.now() + 3_600_000),
},
});
return token;
};
ownerToken = await mkToken(ownerId);
collabToken = await mkToken(collabId);
adminToken = await mkToken(adminId);
outsiderToken = await mkToken(outsiderId);
// 相册 A:owner + collab;zh-CN 翻译。
const albumA = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
albumAId = albumA.id;
await prisma.album_translations.create({
data: { albumId: albumAId, locale: 'zh-CN', title: 'UI01 契约相册' },
});
await prisma.album_members.createMany({
data: [
{ albumId: albumAId, userId: ownerId, role: 'owner', status: 'active' },
{
albumId: albumAId,
userId: collabId,
role: 'collaborator',
status: 'active',
},
],
});
// 相册 B:owner 独有(无 collab)——无封面对照 + 跨册 404。
const albumB = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
albumBId = albumB.id;
await prisma.album_translations.create({
data: { albumId: albumBId, locale: 'zh-CN', title: 'UI01 无封面相册' },
});
await prisma.album_members.create({
data: { albumId: albumBId, userId: ownerId, role: 'owner', status: 'active' },
});
// 4 个补充相册(带封面):N+1 批量断言(单页 6 相册,5 有封面)。
for (let i = 0; i < 4; i++) {
const filler = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
fillerIds.push(filler.id);
await prisma.album_translations.create({
data: { albumId: filler.id, locale: 'zh-CN', title: `UI01 补充 ${i}` },
});
await prisma.album_members.create({
data: {
albumId: filler.id,
userId: ownerId,
role: 'owner',
status: 'active',
},
});
const assetId = `u01assetfill${i}0000000000000`;
const key = `photo-live/dev/albums/${filler.id}/assets/${assetId}.jpg`;
await prisma.album_assets.create({
data: {
id: assetId,
albumId: filler.id,
kind: 'cover',
locale: null,
objectKey: key,
state: 'effective',
expectedBytes: 4,
contentType: 'image/jpeg',
},
});
await putObject(key, Buffer.from([0x41, 0x42, 0x43]));
}
// 相册 A 封面矩阵:pending 行(不计)→ 旧 effective 行 → 新 effective 行
//(最新生效胜出)。
coverOldAssetId = 'u01assetold00000000000000000';
coverNewAssetId = 'u01assetnew00000000000000000';
const coverOldKey = `photo-live/dev/albums/${albumAId}/assets/${coverOldAssetId}.jpg`;
const coverNewKey = `photo-live/dev/albums/${albumAId}/assets/${coverNewAssetId}.jpg`;
await prisma.album_assets.createMany({
data: [
{
id: 'u01assetpending0000000000000',
albumId: albumAId,
kind: 'cover',
locale: null,
objectKey: `photo-live/dev/albums/${albumAId}/assets/u01assetpending0000000000000.jpg`,
state: 'pending',
expectedBytes: 4,
contentType: 'image/jpeg',
createdAt: atMinute(50), // 最新但 pending → 不计
},
{
id: coverOldAssetId,
albumId: albumAId,
kind: 'cover',
locale: null,
objectKey: coverOldKey,
state: 'effective',
expectedBytes: 4,
contentType: 'image/jpeg',
createdAt: atMinute(10),
},
{
id: coverNewAssetId,
albumId: albumAId,
kind: 'cover',
locale: null,
objectKey: coverNewKey,
state: 'effective',
expectedBytes: 5,
contentType: 'image/jpeg',
createdAt: atMinute(20), // 最新 effective → 胜出
},
{
id: 'u01assetbanner000000000000',
albumId: albumAId,
kind: 'banner', // 非 cover kind → 不计
locale: null,
objectKey: `photo-live/dev/albums/${albumAId}/assets/u01assetbanner000000000000.jpg`,
state: 'effective',
expectedBytes: 4,
contentType: 'image/jpeg',
createdAt: atMinute(30),
},
],
});
await putObject(coverOldKey, coverOldBytes);
await putObject(coverNewKey, coverNewBytes);
// 照片矩阵(album A;uploadedAt 递减稳定序)。
const seedPhoto = async (spec: {
id: string;
uploaderId: string;
visibility: string;
processingState: string;
uploadedAt: Date;
withVariants: boolean;
}): Promise<void> => {
await prisma.photos.create({
data: {
id: spec.id,
albumId: albumAId,
uploaderId: spec.uploaderId,
objectKey: `photo-live/dev/albums/${albumAId}/originals/${spec.id}.jpg`,
bytes: 9999,
width: 1600,
height: 1000,
uploadedAt: spec.uploadedAt,
publishedAt:
spec.visibility === 'unpublished' ? null : new Date(spec.uploadedAt),
processingState: spec.processingState,
visibility: spec.visibility,
entityVersion: 2,
},
});
if (!spec.withVariants) {
return;
}
await prisma.photo_variants.createMany({
data: (['thumb', 'browse'] as const).map((variant, i) => ({
photoId: spec.id,
variant,
objectKey: `photo-live/dev/albums/${albumAId}/variants/${spec.id}/1/${variant}.jpg`,
bytes: 100 + i,
version: 1,
width: variant === 'thumb' ? 320 : 1600,
height: variant === 'thumb' ? 200 : 1000,
})),
});
};
phPubC = 'u01-photo-pub-c';
phPubO = 'u01-photo-pub-o';
phHidC = 'u01-photo-hid-c';
phHidO = 'u01-photo-hid-o';
phDelO = 'u01-photo-del-o';
phNovarC = 'u01-photo-novar-c';
await seedPhoto({
id: phPubC,
uploaderId: collabId,
visibility: 'published',
processingState: 'ready',
uploadedAt: atMinute(60),
withVariants: true,
});
await seedPhoto({
id: phPubO,
uploaderId: ownerId,
visibility: 'published',
processingState: 'ready',
uploadedAt: atMinute(50),
withVariants: true,
});
await seedPhoto({
id: phHidC,
uploaderId: collabId,
visibility: 'hidden',
processingState: 'ready',
uploadedAt: atMinute(40),
withVariants: true,
});
await seedPhoto({
id: phHidO,
uploaderId: ownerId,
visibility: 'hidden',
processingState: 'ready',
uploadedAt: atMinute(30),
withVariants: true,
});
await seedPhoto({
id: phDelO,
uploaderId: ownerId,
visibility: 'deleted',
processingState: 'ready',
uploadedAt: atMinute(20),
withVariants: true,
});
await seedPhoto({
id: phNovarC,
uploaderId: collabId,
visibility: 'unpublished',
processingState: 'processing_failed',
uploadedAt: atMinute(10),
withVariants: false,
});
// 预览字节取回断言用:phPubO 的 browse 对象实写 MinIO。
await putObject(
`photo-live/dev/albums/${albumAId}/variants/${phPubO}/1/browse.jpg`,
pubOBrowseBytes,
);
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
await s3.destroy();
});
it('① listAlbums cover:最新 effective 胜出、pending/非 cover 不计、URL 可取回字节', async () => {
const res = await listAlbums(ownerToken);
expect(res.status).toBe(200);
const items = res.body.items as {
id: string;
cover: { url: string; expiresAt: string } | null;
}[];
expect(items).toHaveLength(6); // A + B + 4 filler(owner 全部可见)
const byId = new Map(items.map((item) => [item.id, item]));
// A:cover 非 null,签名 URL 实取回新 effective 行字节(非旧行/pending)。
const coverA = byId.get(albumAId)?.cover;
expect(coverA).not.toBeNull();
expect(coverA?.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
expect(await fetchBody(coverA!.url)).toEqual(coverNewBytes);
// B:无封面 → null(键在,值为 null;旧字段不受影响)。
expect(byId.get(albumBId)?.cover).toBeNull();
// filler:全部非 null。
for (const id of fillerIds) {
expect(byId.get(id)?.cover).not.toBeNull();
}
});
it('① getAlbum 详情:cover 同形(批量路径与单查路径一致)', async () => {
const res = await request(app.getHttpServer())
.get(`/${API_PREFIX}/albums/${albumAId}`)
.set('Authorization', `Bearer ${ownerToken}`);
expect(res.status).toBe(200);
expect(res.body.cover).not.toBeNull();
expect(await fetchBody(res.body.cover.url as string)).toEqual(coverNewBytes);
const resB = await request(app.getHttpServer())
.get(`/${API_PREFIX}/albums/${albumBId}`)
.set('Authorization', `Bearer ${ownerToken}`);
expect(resB.status).toBe(200);
expect(resB.body.cover).toBeNull();
});
it('① 批量无 N+1:6 相册(5 封面)单页列表 = 恒定 Prisma 查询数', async () => {
// Prisma 5 $use 无卸载钩子:只挂一次(纯计数透传,不改变行为),以
// 计数器清零分隔两次测量。只计相册域模型——SessionGuard 的 sessions
// 查询等基础设施流量不入断言(防守卫演进误伤本断言)。
const prismaService = app.get(PrismaService);
const ALBUM_DOMAIN_MODELS = new Set([
'albums',
'album_translations',
'album_assets',
'album_members',
]);
let ops = 0;
prismaService.$use((params, next) => {
if (ALBUM_DOMAIN_MODELS.has(params.model as string)) {
ops += 1;
}
return next(params);
});
try {
const res = await listAlbums(ownerToken);
expect(res.status).toBe(200);
expect((res.body.items as unknown[]).length).toBe(6);
// owner:成员行 1 + albums 1 + translations 1 + album_assets 1 = 4。
// 封面相册数(5)不进入查询数——若退化为逐相册查封面(N+1)至少 +5。
expect(ops).toBe(4);
// admin 对照:无成员行查询,恒 3(与封面相册数无关)。
ops = 0;
const adminRes = await listAlbums(adminToken);
expect(adminRes.status).toBe(200);
expect((adminRes.body.items as unknown[]).length).toBe(6);
expect(ops).toBe(3);
} finally {
// 中间件无法卸载(Prisma 5),保留纯计数透传不影响后续用例;置回 0
// 仅为语义整洁。
ops = 0;
}
});
it('② owner scope=all(缺省):preview 矩阵 + browse 字节取回', async () => {
const res = await listPhotos(ownerToken, albumAId);
expect(res.status).toBe(200);
const byId = new Map(
(res.body.items as { photoId: string; preview: unknown }[]).map(
(item) => [item.photoId, item.preview],
),
);
expect(res.body.items).toHaveLength(6);
// published/hidden(含他人上传)对 manager 签发;deleted/无变体 null。
expect(byId.get(phPubC)).not.toBeNull();
expect(byId.get(phPubO)).not.toBeNull();
expect(byId.get(phHidC)).not.toBeNull();
expect(byId.get(phHidO)).not.toBeNull();
expect(byId.get(phDelO)).toBeNull();
expect(byId.get(phNovarC)).toBeNull();
// 签名 URL 实取回字节(thumb/browse 双通道,preview.browse 为浏览图)。
const previewO = byId.get(phPubO) as {
thumb: { url: string };
browse: { url: string };
};
expect(await fetchBody(previewO.browse.url)).toEqual(pubOBrowseBytes);
});
it('② admin scope=all:全部签发(他人 hidden 亦签)+ preview 形状', async () => {
const res = await listPhotos(adminToken, albumAId, { scope: 'all' });
expect(res.status).toBe(200);
const items = res.body.items as {
photoId: string;
preview: { thumb: { url: string; expiresAt: string }; browse: { url: string } } | null;
}[];
const byId = new Map(items.map((item) => [item.photoId, item.preview]));
expect(byId.get(phPubC)).not.toBeNull();
expect(byId.get(phHidC)).not.toBeNull(); // 他人 hidden 对 admin 签发
expect(byId.get(phHidO)).not.toBeNull();
expect(byId.get(phDelO)).toBeNull();
expect(byId.get(phNovarC)).toBeNull();
for (const item of items) {
if (item.preview !== null) {
expect(Object.keys(item.preview).sort()).toEqual(['browse', 'thumb']);
expect(item.preview.thumb.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
}
}
});
it('③ collab scope=own:仅自己上传(服务端过滤)+ 自己 hidden 行 preview=null', async () => {
const res = await listPhotos(collabToken, albumAId, { scope: 'own' });
expect(res.status).toBe(200);
const items = res.body.items as {
photoId: string;
uploaderId: string;
preview: unknown;
}[];
expect(items.map((i) => i.photoId)).toEqual([phPubC, phHidC, phNovarC]);
for (const item of items) {
expect(item.uploaderId).toBe(collabId); // 服务端过滤,无 owner 照片
}
// 自己的 published/未完成处理照常签发;hidden 仅 admin/owner 签发 → null
//(记录仍可见,图片不可见)。
const byId = new Map(items.map((i) => [i.photoId, i.preview]));
expect(byId.get(phPubC)).not.toBeNull();
expect(byId.get(phHidC)).toBeNull();
expect(byId.get(phNovarC)).toBeNull();
});
it('③ owner scope=own:过滤为自己上传(collab 照片不入列表)', async () => {
const res = await listPhotos(ownerToken, albumAId, { scope: 'own' });
expect(res.status).toBe(200);
const ids = (res.body.items as { photoId: string }[]).map((i) => i.photoId);
expect(ids).toEqual([phPubO, phHidO, phDelO]);
// 组合过滤:scope=own + visibility=published。
const combined = await listPhotos(ownerToken, albumAId, {
scope: 'own',
visibility: 'published',
});
expect(combined.status).toBe(200);
expect(
(combined.body.items as { photoId: string }[]).map((i) => i.photoId),
).toEqual([phPubO]);
});
it('④ collab scope=all(缺省与显式)→ 403;非成员任何 scope → 404;非法 scope → 400', async () => {
const implicit = await listPhotos(collabToken, albumAId);
expect(implicit.status).toBe(403);
expect(implicit.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const explicit = await listPhotos(collabToken, albumAId, { scope: 'all' });
expect(explicit.status).toBe(403);
expect(explicit.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
// 非成员(outsider 对 A)任何 scope → 404(防枚举)。
for (const scope of ['own', 'all', undefined]) {
const res = await listPhotos(outsiderToken, albumAId, scope === undefined ? {} : { scope });
expect(res.status).toBe(404);
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
}
// collab 对非成员相册 B → 404。
const cross = await listPhotos(collabToken, albumBId, { scope: 'own' });
expect(cross.status).toBe(404);
expect(cross.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
// scope 非法值 → 400;空串视为缺省(collab → 403,同缺省口径)。
const garbage = await listPhotos(ownerToken, albumAId, { scope: 'mine' });
expect(garbage.status).toBe(400);
expect(garbage.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const emptyScope = await listPhotos(collabToken, albumAId, { scope: '' });
expect(emptyScope.status).toBe(403);
});
it('④ 被移除成员 scope=own → 404(成员资格逐请求检查)', async () => {
// 基线:collab scope=own 可用(③ 已证,此处再验一次后移除)。
expect(
(await listPhotos(collabToken, albumAId, { scope: 'own' })).status,
).toBe(200);
await prisma.album_members.deleteMany({
where: { albumId: albumAId, userId: collabId },
});
const removed = await listPhotos(collabToken, albumAId, { scope: 'own' });
expect(removed.status).toBe(404);
expect(removed.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
// 恢复成员(后续若有 spec 复用;本 spec 至此为止)。
await prisma.album_members.create({
data: {
albumId: albumAId,
userId: collabId,
role: 'collaborator',
status: 'active',
},
});
});
});