From 8f4ad11bd522ca9164068d0bf740aa0e3bda6aec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 21 Sep 2026 14:09:35 +0800 Subject: [PATCH] feat(contracts,api): UI-01 cover, preview, scope=own and embed config extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 契约扩展(全部可选/可空,旧客户端零感知)+ 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 --- apps/mobile/lib/features/albums/models.dart | 35 +- apps/mobile/lib/features/photos/models.dart | 66 +- apps/mobile/test/core/api_client_test.dart | 1 + .../test/features/albums_models_test.dart | 21 + .../test/features/photos_models_test.dart | 32 + apps/mobile/test/helpers/fakes.dart | 2 + packages/contracts/openapi.yaml | 95 ++- packages/contracts/src/events.ts | 11 +- packages/contracts/src/generated/openapi.d.ts | 73 +- services/api/src/albums/albums.service.ts | 71 +- services/api/src/embed-ov/embed-ov.service.ts | 6 + services/api/src/embed/dto.ts | 39 ++ services/api/src/embed/embed.service.ts | 44 +- .../api/src/photos/album-photos.controller.ts | 10 +- services/api/src/photos/dto.ts | 27 +- services/api/src/photos/photos.module.ts | 11 +- services/api/src/photos/photos.service.ts | 125 +++- services/api/test/albums.e2e-spec.ts | 3 +- services/api/test/embed.e2e-spec.ts | 165 +++++ services/api/test/photos-admin.e2e-spec.ts | 4 +- services/api/test/ui01-contracts.e2e-spec.ts | 627 ++++++++++++++++++ 21 files changed, 1428 insertions(+), 40 deletions(-) create mode 100644 services/api/test/ui01-contracts.e2e-spec.ts diff --git a/apps/mobile/lib/features/albums/models.dart b/apps/mobile/lib/features/albums/models.dart index b0d5aed..a844d47 100644 --- a/apps/mobile/lib/features/albums/models.dart +++ b/apps/mobile/lib/features/albums/models.dart @@ -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 json) => SignedUrl( + url: json['url'] as String, + expiresAt: DateTime.parse(json['expiresAt'] as String), + ); + + /// 签名访问 URL。 + final String url; + + /// URL 失效时间。 + final DateTime expiresAt; + + Map 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 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), ); /// 相册 ID。 @@ -176,6 +205,9 @@ class AlbumSummary { /// 统计副本最近同步时间(P2 A27);null 表示尚无回传应用。 final DateTime? statsSyncedAt; + /// 相册封面签名 URL(UI-01);未配置封面或旧响应缺键为 null。 + final SignedUrl? cover; + Map 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(), }; } diff --git a/apps/mobile/lib/features/photos/models.dart b/apps/mobile/lib/features/photos/models.dart index 195d640..65a8f29 100644 --- a/apps/mobile/lib/features/photos/models.dart +++ b/apps/mobile/lib/features/photos/models.dart @@ -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 json) => PhotoPreview( + thumb: SignedUrl.fromJson(json['thumb'] as Map), + browse: SignedUrl.fromJson(json['browse'] as Map), + ); + + /// 缩略图签名 URL。 + final SignedUrl thumb; + + /// 浏览图签名 URL。 + final SignedUrl browse; + + Map 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 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), ); /// 上传人脱敏信息。 final AdminPhotoUploader uploader; + /// 受权限管理的照片预览(UI-01);无权/无变体/已删除为 null。 + final PhotoPreview? preview; + @override - Map toJson() => {...super.toJson(), 'uploader': uploader.toJson()}; + Map toJson() => { + ...super.toJson(), + 'uploader': uploader.toJson(), + 'preview': preview?.toJson(), + }; } diff --git a/apps/mobile/test/core/api_client_test.dart b/apps/mobile/test/core/api_client_test.dart index b67af44..dc04373 100644 --- a/apps/mobile/test/core/api_client_test.dart +++ b/apps/mobile/test/core/api_client_test.dart @@ -128,6 +128,7 @@ Map albumBody() => { 'createdAt': '2026-09-10T08:00:00Z', 'updatedAt': '2026-09-10T08:00:00Z', 'statsSyncedAt': null, + 'cover': null, 'categories': [], }; diff --git a/apps/mobile/test/features/albums_models_test.dart b/apps/mobile/test/features/albums_models_test.dart index 8a5128c..b286b8c 100644 --- a/apps/mobile/test/features/albums_models_test.dart +++ b/apps/mobile/test/features/albums_models_test.dart @@ -48,6 +48,7 @@ final Map albumSummaryFixture = { 'createdAt': '2026-09-10T08:00:00.000Z', 'updatedAt': '2026-09-10T08:00:00.000Z', 'statsSyncedAt': null, + 'cover': null, }; final Map categoryFixture = { @@ -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); diff --git a/apps/mobile/test/features/photos_models_test.dart b/apps/mobile/test/features/photos_models_test.dart index d7e6742..2630ed5 100644 --- a/apps/mobile/test/features/photos_models_test.dart +++ b/apps/mobile/test/features/photos_models_test.dart @@ -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', diff --git a/apps/mobile/test/helpers/fakes.dart b/apps/mobile/test/helpers/fakes.dart index aee8342..d572f86 100644 --- a/apps/mobile/test/helpers/fakes.dart +++ b/apps/mobile/test/helpers/fakes.dart @@ -211,6 +211,7 @@ Map albumJson({ 'createdAt': '2026-09-01T00:00:00Z', 'updatedAt': '2026-09-01T00:00:00Z', 'statsSyncedAt': null, + 'cover': null, }; /// 契约 `Category` 响应体。 @@ -285,6 +286,7 @@ Map adminPhotoJson({ 'account': 'staff02', 'displayName': 'Staff Two', }, + 'preview': null, }; /// 契约游标分页响应体(hasMore 与 nextCursor 成对)。 diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 4f72a8a..2406042 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -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: diff --git a/packages/contracts/src/events.ts b/packages/contracts/src/events.ts index e783692..f263027 100644 --- a/packages/contracts/src/events.ts +++ b/packages/contracts/src/events.ts @@ -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; diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index 83c5ec5..9e00bc8 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -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; diff --git a/services/api/src/albums/albums.service.ts b/services/api/src/albums/albums.service.ts index d3b4350..3fe076b 100644 --- a/services/api/src/albums/albums.service.ts +++ b/services/api/src/albums/albums.service.ts @@ -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(); + for (const row of covers) { + if (!coverKeyByAlbum.has(row.albumId)) { + coverKeyByAlbum.set(row.albumId, row.objectKey); + } + } + const coverByAlbum = new Map(); + 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 { + 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 { + const ttl = signedUrlTtlSeconds(); + const url = await this.storage.presignGet(objectKey, ttl); + return { url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() }; + } } diff --git a/services/api/src/embed-ov/embed-ov.service.ts b/services/api/src/embed-ov/embed-ov.service.ts index 3a1b3b8..59347f0 100644 --- a/services/api/src/embed-ov/embed-ov.service.ts +++ b/services/api/src/embed-ov/embed-ov.service.ts @@ -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, diff --git a/services/api/src/embed/dto.ts b/services/api/src/embed/dto.ts index 0178de4..97abcdf 100644 --- a/services/api/src/embed/dto.ts +++ b/services/api/src/embed/dto.ts @@ -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 { diff --git a/services/api/src/embed/embed.service.ts b/services/api/src/embed/embed.service.ts index e25d6a6..16fdd23 100644 --- a/services/api/src/embed/embed.service.ts +++ b/services/api/src/embed/embed.service.ts @@ -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; + 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(), }; } diff --git a/services/api/src/photos/album-photos.controller.ts b/services/api/src/photos/album-photos.controller.ts index bfe5e65..628fe64 100644 --- a/services/api/src/photos/album-photos.controller.ts +++ b/services/api/src/photos/album-photos.controller.ts @@ -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), }); diff --git a/services/api/src/photos/dto.ts b/services/api/src/photos/dto.ts index aa816d4..48fdd35 100644 --- a/services/api/src/photos/dto.ts +++ b/services/api/src/photos/dto.ts @@ -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; +} diff --git a/services/api/src/photos/photos.module.ts b/services/api/src/photos/photos.module.ts index 141964b..95f5fea 100644 --- a/services/api/src/photos/photos.module.ts +++ b/services/api/src/photos/photos.module.ts @@ -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 {} diff --git a/services/api/src/photos/photos.service.ts b/services/api/src/photos/photos.service.ts index 64748ef..df7a2f8 100644 --- a/services/api/src/photos/photos.service.ts +++ b/services/api/src/photos/photos.service.ts @@ -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; @@ -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 { + 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 { + 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 { + 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,不影响其余项。 diff --git a/services/api/test/albums.e2e-spec.ts b/services/api/test/albums.e2e-spec.ts index 537ab2a..2608388 100644 --- a/services/api/test/albums.e2e-spec.ts +++ b/services/api/test/albums.e2e-spec.ts @@ -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', diff --git a/services/api/test/embed.e2e-spec.ts b/services/api/test/embed.e2e-spec.ts index d71cd47..5c83fa1 100644 --- a/services/api/test/embed.e2e-spec.ts +++ b/services/api/test/embed.e2e-spec.ts @@ -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; + 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)。 */ diff --git a/services/api/test/photos-admin.e2e-spec.ts b/services/api/test/photos-admin.e2e-spec.ts index bc3c877..ade1f82 100644 --- a/services/api/test/photos-admin.e2e-spec.ts +++ b/services/api/test/photos-admin.e2e-spec.ts @@ -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', diff --git a/services/api/test/ui01-contracts.e2e-spec.ts b/services/api/test/ui01-contracts.e2e-spec.ts new file mode 100644 index 0000000..40b6c71 --- /dev/null +++ b/services/api/test/ui01-contracts.e2e-spec.ts @@ -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 { + await s3.send( + new PutObjectCommand({ + Bucket: process.env.S3_BUCKET, + Key: key, + Body: body, + ContentType: 'image/jpeg', + }), + ); + } + + async function fetchBody(url: string): Promise { + const res = await fetch(url); + expect(res.status).toBe(200); + return Buffer.from(await res.arrayBuffer()); + } + + function listAlbums(token: string, query?: Record) { + return request(app.getHttpServer()) + .get(`/${API_PREFIX}/albums`) + .set('Authorization', `Bearer ${token}`) + .query(query ?? {}); + } + + function listPhotos( + token: string, + albumId: string, + query?: Record, + ) { + 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 => { + 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 => { + 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', + }, + }); + }); +});