From 361a72f300d9201f4f82af4e9a2a4d715386beaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Fri, 18 Sep 2026 08:40:32 +0800 Subject: [PATCH] feat(api): album asset upload chain P1b Task 3: initAlbumAssetUpload / completeAlbumAssetUpload (tags [assets]) over album_assets with in-row one-shot sessions. - migration asset_state: album_assets + state (pending|effective), expectedBytes, contentType (utf8mb4_0900_bin); pre-existing rows backfilled to effective. Complete head-verifies bytes AND contentType vs the init declaration (P1a Task 11 server-side fallback), then conditionally flips pending->effective with the winner writing the album.asset.add audit (resourceId=albumId, metadata assetId/kind/locale). Effective rows re-complete idempotently (re-signed URL, no re-verify, survives object cleanup). - object key is the final key (albums/{albumId}/assets/{assetId}.{ext}); the pending row is the logical staging - no copy step, keys embed assetId so re-init can never overwrite another row's object. - permissions: owner/admin only (collaborator 403, outsider 404); uploadEnabled does NOT gate assets (switch governs photo uploads per 4.9 - both endpoints carry no ALBUM_UPLOAD_DISABLED). - public banner now locale-aware (req 4.7): effective rows resolved as display-locale row -> global (locale NULL) row -> null, latest wins per tier (same-kind re-upload replaces, old rows retained). - e2e: test/assets.e2e-spec.ts (14 cases, real jpegs via sharp, MinIO round-trips); public spec seed marked effective. Co-Authored-By: Claude Code --- .../20260918003151_asset_state/migration.sql | 11 + packages/db-cn/prisma/schema.prisma | 15 +- services/api/src/albums/albums.module.ts | 29 +- services/api/src/albums/assets.controller.ts | 51 ++ services/api/src/albums/assets.service.ts | 199 ++++++ services/api/src/albums/dto.ts | 43 +- services/api/src/public/public.service.ts | 25 +- services/api/src/uploads/support.ts | 14 + services/api/test/assets.e2e-spec.ts | 598 ++++++++++++++++++ services/api/test/public.e2e-spec.ts | 13 +- 10 files changed, 974 insertions(+), 24 deletions(-) create mode 100644 packages/db-cn/prisma/migrations/20260918003151_asset_state/migration.sql create mode 100644 services/api/src/albums/assets.controller.ts create mode 100644 services/api/src/albums/assets.service.ts create mode 100644 services/api/test/assets.e2e-spec.ts diff --git a/packages/db-cn/prisma/migrations/20260918003151_asset_state/migration.sql b/packages/db-cn/prisma/migrations/20260918003151_asset_state/migration.sql new file mode 100644 index 0000000..6d1ca08 --- /dev/null +++ b/packages/db-cn/prisma/migrations/20260918003151_asset_state/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +-- P1b Task 3:album_assets 行内一次性上传会话——state(pending→effective)+ +-- 申明值 expectedBytes/contentType(complete head 核验依据)。 +-- VARCHAR 列显式 utf8mb4_0900_bin(沿用库级约定:--create-only 后手工定校对)。 +ALTER TABLE `album_assets` ADD COLUMN `contentType` VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL DEFAULT '', + ADD COLUMN `expectedBytes` INTEGER NOT NULL DEFAULT 0, + ADD COLUMN `state` VARCHAR(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin NOT NULL DEFAULT 'pending'; + +-- 存量行(状态概念引入前已直种的对象)视为已生效:公开查询自此过滤 +-- state='effective',不回填会把迁移前的资产整体藏掉。 +UPDATE `album_assets` SET `state` = 'effective'; diff --git a/packages/db-cn/prisma/schema.prisma b/packages/db-cn/prisma/schema.prisma index 9d61b1d..dfb2aed 100644 --- a/packages/db-cn/prisma/schema.prisma +++ b/packages/db-cn/prisma/schema.prisma @@ -117,12 +117,15 @@ model category_translations { } model album_assets { - id String @id @default(cuid()) - albumId String @db.VarChar(64) - kind String @db.VarChar(16) // banner|cover|logo - locale String? @db.VarChar(16) - objectKey String @db.VarChar(255) - createdAt DateTime @default(now()) @db.DateTime(6) + id String @id @default(cuid()) + albumId String @db.VarChar(64) + kind String @db.VarChar(16) // banner|cover|logo + locale String? @db.VarChar(16) + objectKey String @db.VarChar(255) + state String @default("pending") @db.VarChar(16) // pending|effective(行内一次性上传会话,P1b Task 3) + expectedBytes Int @default(0) // init 申明字节数(complete head 核验依据) + contentType String @default("") @db.VarChar(64) // init 申明 MIME(complete head 核验依据) + createdAt DateTime @default(now()) @db.DateTime(6) @@index([albumId, kind]) } diff --git a/services/api/src/albums/albums.module.ts b/services/api/src/albums/albums.module.ts index b7b885d..742e523 100644 --- a/services/api/src/albums/albums.module.ts +++ b/services/api/src/albums/albums.module.ts @@ -1,20 +1,39 @@ // services/api/src/albums/albums.module.ts —— 相册模块(Task 14 起步,Task 15 -// 补全,Task 16 增补 PATCH,Task 17 增补成员/分类)。PermissionsService(A02 -// 权限基石)+ AlbumsService/MembersService/CategoriesService 与对应控制器。 +// 补全,Task 16 增补 PATCH,Task 17 增补成员/分类,P1b Task 3 增补资产上传)。 +// PermissionsService(A02 权限基石)+ AlbumsService/MembersService/ +// CategoriesService/AssetsService 与对应控制器。 // export PermissionsService 供 uploads/photos 等后续模块经模块间 DI 复用同一 -// 份判定逻辑;AlbumsService 等暂无外部消费者不 export。 +// 份权限判定逻辑;AlbumsService 等暂无外部消费者不 export。 +// StoragePort 装配说明(P1b Task 3):UploadsModule 为 PermissionsService 反向 +// import 本模块,本模块若再 import UploadsModule 取 STORAGE_PORT 会成环; +// 故直接复用同一 provider 工厂(env 构造、S3Client 懒连接,双实例零成本)。 import { Module } from '@nestjs/common'; import { AlbumsController } from './albums.controller'; import { AlbumsService } from './albums.service'; +import { AssetsController } from './assets.controller'; +import { AssetsService } from './assets.service'; import { CategoriesController } from './categories.controller'; import { CategoriesService } from './categories.service'; import { MembersController } from './members.controller'; import { MembersService } from './members.service'; import { PermissionsService } from './permissions.service'; +import { storageProvider } from '../uploads/storage.provider'; @Module({ - controllers: [AlbumsController, MembersController, CategoriesController], - providers: [PermissionsService, AlbumsService, MembersService, CategoriesService], + controllers: [ + AlbumsController, + MembersController, + CategoriesController, + AssetsController, + ], + providers: [ + PermissionsService, + AlbumsService, + MembersService, + CategoriesService, + AssetsService, + storageProvider, + ], exports: [PermissionsService], }) export class AlbumsModule {} diff --git a/services/api/src/albums/assets.controller.ts b/services/api/src/albums/assets.controller.ts new file mode 100644 index 0000000..0a99e6e --- /dev/null +++ b/services/api/src/albums/assets.controller.ts @@ -0,0 +1,51 @@ +// services/api/src/albums/assets.controller.ts —— /albums/{albumId}/assets +// 端点(P1b Task 3)。方法名与契约 operationId 一一对应(initAlbumAssetUpload/ +// completeAlbumAssetUpload)。控制器级 SessionGuard:两端点全需认证。控制器只做 +// 认证用户与路径参数透传;权限与 HTTP 映射由 AssetsService 抛 ApiException +// (404 ALBUM_NOT_FOUND / 403 AUTH_FORBIDDEN / 400 VALIDATION_FAILED 或 +// UPLOAD_VERIFY_FAILED)。 +import { + Body, + Controller, + HttpCode, + HttpStatus, + Param, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard'; +import { userOf } from './albums.controller'; +import { AssetInitRequestDto } from './dto'; +import { + AssetsService, + type AssetCompleteResponse, + type AssetInitResponse, +} from './assets.service'; + +@Controller('albums/:albumId/assets') +@UseGuards(SessionGuard) +export class AssetsController { + constructor(private readonly assetsService: AssetsService) {} + + /** 契约 201:会话行已建(每次 init 新行新键,无幂等键)。 */ + @Post() + initAlbumAssetUpload( + @Param('albumId') albumId: string, + @Body() dto: AssetInitRequestDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.assetsService.init(userOf(req), albumId, dto); + } + + /** 完成核验:幂等(已生效行现签新 URL 返回,即签名刷新入口)。 */ + @Post(':assetId/complete') + @HttpCode(HttpStatus.OK) + completeAlbumAssetUpload( + @Param('albumId') albumId: string, + @Param('assetId') assetId: string, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.assetsService.complete(userOf(req), albumId, assetId); + } +} diff --git a/services/api/src/albums/assets.service.ts b/services/api/src/albums/assets.service.ts new file mode 100644 index 0000000..e59841a --- /dev/null +++ b/services/api/src/albums/assets.service.ts @@ -0,0 +1,199 @@ +// services/api/src/albums/assets.service.ts —— 相册资产上传链路(P1b Task 3)。 +// 契约权威:packages/contracts/openapi.yaml tags [assets]。设计要点: +// · 行内一次性会话:init 即建 album_assets 行(state='pending'),assetId 就是 +// 会话寻址凭据——无 clientTaskId 幂等键(契约:失败重试重新 init 即可, +// 新行新键,旧行随下一次成功上传自然沉底)。为支撑 complete 核验,行内 +// 持久化申明值(expectedBytes/contentType,migration asset_state)。 +// · 对象键即最终键:photo-live/dev/albums/{albumId}/assets/{assetId}.{ext} +// (契约 AssetInitResponse.objectKey)。与照片域不同,资产无 staging→ +// originals 受控复制——键含 assetId 保证不覆盖他行对象,行 state='pending' +// 期间公开查询不可见,即「逻辑 staging」;complete 核验通过置 effective。 +// · 权限:资产是相册配置(非照片上传)——owner/admin 专属(canManageAlbum); +// 协作者可见但无权 → 403 AUTH_FORBIDDEN;局外人不可见 → 404 +// ALBUM_NOT_FOUND(同码防枚举)。uploadEnabled 开关不拦截资产:开关语义 +// 是 §4.9 照片直传的相册级运营状态,banner/cover/logo 配置不受「收图」 +// 影响(契约 assets 两端点均无 ALBUM_UPLOAD_DISABLED 错误码)。 +// · 校验顺序(契约错误优先级,同 uploads 域):相册不可见 404 → 非属主 403 +// → bytes/扩展名 400(contentType 白名单在 DTO 层)。 +// · complete 核验(P1a Task 11 服务端兜底沿用):head 对象——缺失 400 +// UPLOAD_VERIFY_FAILED、bytes 与申明不符 400、contentType 与申明不符 400 +// (presign 不签名 content-type);失败是请求级错误,行保持 pending 可重试。 +// 通过后事务内条件更新 state pending→effective(updateMany 抢占,并发 +// complete 恰一人胜出)+ 胜者独占 audit(album.asset.add:actorId=操作者、 +// resourceId=albumId、metadata 含 assetId/kind/locale——同 album.category.add +// 锚式)。幂等:effective 行重复 complete 不再 head/不重复 audit(对象已被 +// 清理也不受影响),仅现签新 URL 返回——本端点即签名刷新入口。 +// · 替换语义:同 (kind, locale) 再上传 → 新行生效、旧行保留(P1b 无删除 +// 端点);公开响应取最新 effective 行(public.service 按 locale 偏好解析)。 +// · UPLOAD_MAX_BYTES/SIGNED_URL_TTL_SECONDS 逐请求惰性读取(uploads/support); +// presign 为 SigV4 本地计算,无网络副作用。 +import { Inject, Injectable } from '@nestjs/common'; +import type { components } from '@photo/contracts'; +import type { StoragePort } from '@photo/storage'; +import type { AuthUser } from '../auth/session.guard'; +import { STORAGE_PORT } from '../uploads/storage.provider'; +import { + assetsObjectKey, + cuidLikeId, + extOfFilename, + signedUrlTtlSeconds, + uploadMaxBytes, + uploadVerifyFailed, +} from '../uploads/support'; +import { PrismaService } from '../prisma/prisma.service'; +import { + albumForbidden, + albumNotFound, + validationFailed, +} from './support'; +import { PermissionsService } from './permissions.service'; +import type { AssetInitRequestDto } from './dto'; + +export type AssetInitResponse = components['schemas']['AssetInitResponse']; +export type AssetCompleteResponse = + components['schemas']['AssetCompleteResponse']; + +@Injectable() +export class AssetsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + @Inject(STORAGE_PORT) private readonly storage: StoragePort, + ) {} + + /** + * 初始化:权限/参数校验 → 建行(state=pending)→ presign。 + * 每次 init 都是新行新键——无幂等裁决,天然无并发冲突锚。 + */ + async init( + user: AuthUser, + albumId: string, + dto: AssetInitRequestDto, + ): Promise { + // ① 相册可见性:不可见(含不存在)统一 404,防 id 枚举(同相册域语义)。 + if (!(await this.permissions.canViewAlbum(user, albumId))) { + throw albumNotFound(); + } + // ② 资产管理权:owner/admin 专属(协作者可见但无权 → 403)。 + if (!(await this.permissions.canManageAlbum(user, albumId))) { + throw albumForbidden('仅创建者或管理员可管理资产'); + } + // ③ 相册行存在性:admin 对任意 id 权限恒真,须由空值兜底同码 404。 + // 注意不查 uploadEnabled——开关只管照片直传(§4.9),不管资产配置。 + const album = await this.prisma.albums.findUnique({ + where: { id: albumId }, + select: { id: true }, + }); + if (album === null) { + throw albumNotFound(); + } + // ④ 大小限额(惰性读 env)与 filename 扩展名白名单(对象键后缀派生)。 + const maxBytes = uploadMaxBytes(); + if (dto.bytes > maxBytes) { + throw validationFailed(`文件超过大小限额(limit=${maxBytes})`); + } + const ext = extOfFilename(dto.filename); + if (ext === null) { + throw validationFailed( + 'filename 扩展名不在白名单(jpg/jpeg/png/heic)', + ); + } + + // ⑤ 建行:assetId 应用侧先生成(对象键含自引用键段,一次 insert 成形)。 + const assetId = cuidLikeId(); + const objectKey = assetsObjectKey(albumId, assetId, ext); + await this.prisma.album_assets.create({ + data: { + id: assetId, + albumId, + kind: dto.kind, + locale: dto.locale ?? null, + objectKey, + state: 'pending', + expectedBytes: dto.bytes, + contentType: dto.contentType, + }, + }); + + const credential = await this.storage.presignPut( + objectKey, + dto.bytes, + dto.contentType, + signedUrlTtlSeconds(), + ); + return { assetId, objectKey, credential }; + } + + /** + * 完成核验:head 核验 → 事务(条件置 effective + 胜者 audit)→ 现签 URL。 + * 幂等:已生效行直接现签返回(不 head——对象清理后仍是合法刷新入口)。 + */ + async complete( + user: AuthUser, + albumId: string, + assetId: string, + ): Promise { + if (!(await this.permissions.canViewAlbum(user, albumId))) { + throw albumNotFound(); + } + if (!(await this.permissions.canManageAlbum(user, albumId))) { + throw albumForbidden('仅创建者或管理员可管理资产'); + } + const asset = await this.prisma.album_assets.findUnique({ + where: { id: assetId }, + }); + // 资产不存在或不在目标相册:与无权同码 403(契约:不区分以避免枚举)。 + if (asset === null || asset.albumId !== albumId) { + throw albumForbidden('无权操作该资产'); + } + + if (asset.state !== 'effective') { + // ① 事务外存储核验:存在性/字节数/contentType(申明值行内持久化)。 + const head = await this.storage.head(asset.objectKey); + if (head === null) { + throw uploadVerifyFailed('资产对象不存在或尚未上传'); + } + if (head.bytes !== asset.expectedBytes) { + throw uploadVerifyFailed( + `字节数与申明不符(expected=${asset.expectedBytes}, actual=${head.bytes})`, + ); + } + if (head.contentType !== asset.contentType) { + throw uploadVerifyFailed( + `Content-Type 与申明不符(expected=${asset.contentType}, actual=${head.contentType})`, + ); + } + + // ② 事务:条件更新抢占(并发 complete 恰一人胜出)+ 胜者独占 audit。 + await this.prisma.$transaction(async (tx) => { + const race = await tx.album_assets.updateMany({ + where: { id: asset.id, state: 'pending' }, + data: { state: 'effective' }, + }); + if (race.count === 1) { + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'album.asset.add', + resourceId: albumId, + metadata: { + assetId: asset.id, + kind: asset.kind, + locale: asset.locale, + }, + }, + }); + } + }); + } + + // ③ 现签短期 GET(幂等路径亦现签:本端点即签名刷新入口)。 + const ttl = signedUrlTtlSeconds(); + const url = await this.storage.presignGet(asset.objectKey, ttl); + return { + assetId: asset.id, + url, + expiresAt: new Date(Date.now() + ttl * 1000).toISOString(), + }; + } +} diff --git a/services/api/src/albums/dto.ts b/services/api/src/albums/dto.ts index 3b37705..133596d 100644 --- a/services/api/src/albums/dto.ts +++ b/services/api/src/albums/dto.ts @@ -1,7 +1,8 @@ // services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版; -// Task 17 增补成员/分类)。校验约束对齐契约 +// Task 17 增补成员/分类;P1b Task 3 增补资产)。校验约束对齐契约 // AlbumCreateRequest / AlbumUpdateRequest / MemberAddRequest / -// CategoryCreateRequest / CategoryUpdateRequest / AlbumTranslation: +// CategoryCreateRequest / CategoryUpdateRequest / AlbumTranslation / +// AssetInitRequest: // · translations 至少 1 条(minItems: 1),locale 限 P1a 双语枚举; // · defaultLocale 必须有对应翻译条目(跨字段校验,契约 required 语义); // · endAt 可空(契约 [string,'null']),给值时须晚于 startAt(跨字段校验); @@ -29,6 +30,10 @@ import { type ValidatorConstraintInterface, type ValidationArguments, } from 'class-validator'; +import { + UPLOAD_CONTENT_TYPES, + type UploadContentType, +} from '../uploads/support'; /** 契约 Locale 枚举(P1a 双语)。 */ export const ALBUM_LOCALES = ['zh-CN', 'en'] as const; @@ -294,3 +299,37 @@ export class CategoryUpdateRequestDto { @Type(() => CategoryTranslationDto) translations?: CategoryTranslationDto[]; } + +// ── 资产(P1b Task 3)── + +/** 契约 AssetKind:banner(观众页宣传图,PublicAlbum.banner 消费方);cover/logo 建链就绪,展示消费随后续阶段。 */ +export const ASSET_KINDS = ['banner', 'cover', 'logo'] as const; +export type AssetKind = (typeof ASSET_KINDS)[number]; + +/** + * 契约 AssetInitRequest。大小限额 UPLOAD_MAX_BYTES 与 filename 扩展名白名单是 + * 环境相关/派生校验,在服务层做(同 UploadInitRequestDto 约定);contentType + * 与照片直传共用白名单(image/heic 暂定,G2 真机验证)。 + */ +export class AssetInitRequestDto { + @IsIn(ASSET_KINDS) + kind!: AssetKind; + + /** 可选语言维度:同 (kind, locale) 再上传即替换(新行生效);语言无关资产(如 logo)不传。 */ + @IsOptional() + @IsIn(ALBUM_LOCALES) + locale?: AlbumLocale; + + /** 原始文件名,仅元数据;扩展名白名单校验在服务层(对象键后缀派生)。 */ + @IsString() + @IsNotEmpty() + @MaxLength(255) + filename!: string; + + @IsInt() + @Min(1) + bytes!: number; + + @IsIn(UPLOAD_CONTENT_TYPES) + contentType!: UploadContentType; +} diff --git a/services/api/src/public/public.service.ts b/services/api/src/public/public.service.ts index ee3b432..1251654 100644 --- a/services/api/src/public/public.service.ts +++ b/services/api/src/public/public.service.ts @@ -9,8 +9,10 @@ // 决策),与缺省同样走回退;resolvedLocale 向客户端指示实际返回语言。 // · 分类名称同语言解析:resolved locale → defaultLocale → 任一既有条目 // (确定性:locale 升序取首);分类创建必附翻译,末级分支纯防御。 -// · banner:album_assets(kind=banner) 多行时取最新一条(createdAt DESC, -// id DESC 决胜);P1a 无按语言选封面概念(locale 列为 P1b 上传链路预留)。 +// · banner(P1b Task 3 起对接上传链路):album_assets(kind=banner, +// state='effective') 中按语言偏好解析(需求 4.7)——resolved locale 行 → +// 全局行(locale NULL)→ null;同级取最新(createdAt,id DESC 决胜,同 +// (kind, locale) 再上传即替换语义)。 // · 签名 URL 一律现签(SIGNED_URL_TTL_SECONDS 惰性读取,SigV4 本地计算无 // 网络副作用);单图定位端点是契约规定的过期刷新入口。 // · viewCount:P1a 占位 null(§7.1 /visits 端点后续提供)。 @@ -100,15 +102,22 @@ export class PublicService { bucket.set(row.locale, row.name); } - // banner:最新 banner 资产行现签(无行 → null)。 - const bannerAsset = await this.prisma.album_assets.findFirst({ - where: { albumId: album.id, kind: 'banner' }, + // banner(P1b Task 3 起对接上传链路):仅 state='effective' 行参与;语言 + // 偏好解析(需求 4.7)——resolved locale 行 → 全局行(locale NULL)→ null。 + // 以「实际展示语言」(translation 解析结果)为偏好驱动:banner 跟随观众 + // 看到的文案语言,而非原始请求值(请求 fr 回退 zh 时配 zh banner)。 + // 同级多行取最新(createdAt,id DESC 决胜):同 (kind, locale) 再上传即替换 + // 语义——新行生效、旧行保留,此处取最新 effective 行。 + const bannerAssets = await this.prisma.album_assets.findMany({ + where: { albumId: album.id, kind: 'banner', state: 'effective' }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], }); + const bannerRow = + bannerAssets.find((row) => row.locale === resolved.locale) ?? + bannerAssets.find((row) => row.locale === null) ?? + null; const banner = - bannerAsset === null - ? null - : await this.presign(bannerAsset.objectKey); + bannerRow === null ? null : await this.presign(bannerRow.objectKey); return { publicToken: album.publicToken, diff --git a/services/api/src/uploads/support.ts b/services/api/src/uploads/support.ts index 636cb84..c1a5a5c 100644 --- a/services/api/src/uploads/support.ts +++ b/services/api/src/uploads/support.ts @@ -80,6 +80,20 @@ export function originalsObjectKey( return `${OBJECT_KEY_ROOT}/albums/${albumId}/originals/${photoId}.${ext}`; } +/** + * assets 正式键(album_assets.objectKey,P1b Task 3)。与照片域不同,资产无 + * staging→originals 受控复制:键含 assetId(每次 init 新键,不存在覆盖他行 + * 对象的风险),行 state='pending' 期间公开查询不可见即「逻辑 staging」, + * complete 核验通过置 effective 后同一键即最终键。 + */ +export function assetsObjectKey( + albumId: string, + assetId: string, + ext: string, +): string { + return `${OBJECT_KEY_ROOT}/albums/${albumId}/assets/${assetId}.${ext}`; +} + /** 取对象键尾缀扩展名(幂等冲突比对用:filename 未持久化,以扩展名近似锚定)。 */ export function extOfObjectKey(objectKey: string): string | null { const dot = objectKey.lastIndexOf('.'); diff --git a/services/api/test/assets.e2e-spec.ts b/services/api/test/assets.e2e-spec.ts new file mode 100644 index 0000000..2f41e4e --- /dev/null +++ b/services/api/test/assets.e2e-spec.ts @@ -0,0 +1,598 @@ +// services/api/test/assets.e2e-spec.ts —— P1b Task 3 相册资产上传链路 e2e +// (先写后实现)。认证同 uploads spec:Prisma 直种 sessions 行换 Bearer 令牌。 +// 真实 MinIO:presign PUT 实写 head 可核验;真实 JPEG 经 sharp 生成(无二进制 +// 入库)。覆盖: +// init/complete 主链路:owner init banner(zh-CN) → 201 {assetId, objectKey, +// credential}(键形 photo-live/dev/albums/{albumId}/assets/{assetId}.jpg, +// DB 行 state=pending + 申明值 expectedBytes/contentType 行内持久化); +// PUT 真图 → complete 200 {assetId, url, expiresAt},签名 URL 可取回 +// 真实字节;行置 effective;audit album.asset.add(actor=操作者、 +// resourceId=albumId、metadata assetId/kind/locale)。 +// 公开消费 + 语言回退(需求 4.7):GET /public/albums/{token}?locale=zh-CN +// banner=该资产签名 URL;en 请求无 en 行且无全局行 → null;补全局行 +// (locale 缺省)后 en 取全局、zh-CN 取 zh-CN;仅全局行的相册 zh-CN +// 请求 → 全局(回退默认图)。 +// 替换语义:同 kind(zh-CN banner)二次上传 → 公开取新资产行,旧行保留。 +// 权限:协作者 init/complete → 403 AUTH_FORBIDDEN(资产是相册配置,owner/ +// admin 专属);局外人 → 404 ALBUM_NOT_FOUND(不可见同码防枚举); +// uploadEnabled=false 不拦截资产(开关只管照片直传,§4.9)。 +// 校验:bytes 超 UPLOAD_MAX_BYTES / contentType 白名单外 → 400;complete 前 +// 未 PUT → 400 UPLOAD_VERIFY_FAILED(行保持 pending);字节数不符 → 400; +// contentType 不符(presign 不签名 content-type,服务端兜底)→ 400。 +// 幂等:重复 complete → 200 同 assetId(URL 现签),audit 不重复;对象已 +// 删除后重复 complete → 仍 200(已生效,不依赖 staging 核验)。 +// cover/logo 建链就绪:logo 冒烟一例。 +import { randomBytes } from 'node:crypto'; +import { type INestApplication } from '@nestjs/common'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import sharp from 'sharp'; +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 { API_PREFIX } from '../src/shared/constants'; +import { resetDb } from './reset'; + +jest.setTimeout(120_000); + +/** 契约 AssetInitResponse / AssetCompleteResponse 字段全集(形状精确断言用)。 */ +const INIT_KEYS = ['assetId', 'objectKey', 'credential'].sort(); +const CREDENTIAL_KEYS = ['mode', 'url', 'method', 'headers', 'expiresAt'].sort(); +const COMPLETE_KEYS = ['assetId', 'url', 'expiresAt'].sort(); + +const KEY_ROOT = 'photo-live/dev'; + +/** 测试内生成真实 JPEG(尺寸/配色区分内容:新旧资产字节各不相同)。 */ +const makeJpeg = ( + width: number, + height: number, + rgb: { r: number; g: number; b: number }, +) => + sharp({ + create: { width, height, channels: 3, background: rgb }, + }) + .jpeg({ quality: 85 }) + .toBuffer(); + +type Credential = { + url: string; + method: string; + headers: Record; +}; + +/** 按凭证实写对象(contentType 可覆盖以构造核验失败用例)。 */ +async function putByCredential( + credential: Credential, + body: Buffer, + contentType?: string, +): Promise { + const res = await fetch(credential.url, { + method: credential.method, + headers: + contentType === undefined + ? credential.headers + : { 'Content-Type': contentType }, + body: new Uint8Array(body), + }); + return res.ok; +} + +describe('album assets upload (e2e)', () => { + let app: INestApplication; + let prisma: PrismaClient; + + let ownerId: string; + let collaboratorId: string; + let outsiderId: string; + let ownerToken: string; + let collaboratorToken: string; + let outsiderToken: string; + + let albumId: string; + let albumToken: string; + /** 仅全局 banner 的第二相册(zh-CN 请求回退全局用例)。 */ + let globalOnlyAlbumId: string; + let globalOnlyToken: string; + + /** T1 主链路资产锚点(幂等/对象删除用例复用)。 */ + let t1Asset: { assetId: string; objectKey: string; jpeg: Buffer }; + + const initAsset = (token: string, album: string, body: object) => + request(app.getHttpServer()) + .post(`/${API_PREFIX}/albums/${album}/assets`) + .set('Authorization', `Bearer ${token}`) + .send(body); + + const completeAsset = (token: string, album: string, assetId: string) => + request(app.getHttpServer()) + .post(`/${API_PREFIX}/albums/${album}/assets/${assetId}/complete`) + .set('Authorization', `Bearer ${token}`); + + const getPublic = (token: string, query?: Record) => + request(app.getHttpServer()) + .get(`/${API_PREFIX}/public/albums/${token}`) + .query(query ?? {}); + + /** 完整链路 helper:init → PUT 真图 → complete,返回锚点与响应。 */ + const uploadAsset = async ( + jpeg: Buffer, + overrides: Record = {}, + album: string = albumId, + ) => { + const initRes = await initAsset(ownerToken, album, { + kind: 'banner', + locale: 'zh-CN', + filename: 'b.jpg', + bytes: jpeg.length, + contentType: 'image/jpeg', + ...overrides, + }); + expect(initRes.status).toBe(201); + const { assetId, objectKey, credential } = initRes.body as { + assetId: string; + objectKey: string; + credential: Credential; + }; + expect(await putByCredential(credential, jpeg)).toBe(true); + const completeRes = await completeAsset(ownerToken, album, assetId); + expect(completeRes.status).toBe(200); + return { + assetId, + objectKey, + jpeg, + complete: completeRes.body as { assetId: string; url: string }, + }; + }; + + /** 取公开 banner 并断言字节与预期一致(签名 URL 可取回真实对象)。 */ + const expectPublicBannerBytes = async ( + token: string, + locale: string, + jpeg: Buffer, + ) => { + const res = await getPublic(token, { locale }); + expect(res.status).toBe(200); + const banner = res.body.banner as { url: string; expiresAt: string }; + expect(banner).not.toBeNull(); + expect(Date.parse(banner.expiresAt)).toBeGreaterThan(Date.now()); + const fetched = await fetch(banner.url); + expect(fetched.status).toBe(200); + expect(fetched.headers.get('content-type')).toBe('image/jpeg'); + expect(Buffer.from(await fetched.arrayBuffer())).toEqual(jpeg); + }; + + /** 经 API 建册(owner),返回 {id, publicToken}(publicEnabled 默认 true)。 */ + const createAlbum = async (title: string): Promise<{ + id: string; + publicToken: string; + }> => { + const res = await request(app.getHttpServer()) + .post(`/${API_PREFIX}/albums`) + .set('Authorization', `Bearer ${ownerToken}`) + .send({ + translations: [ + { locale: 'zh-CN', title }, + { locale: 'en', title: `${title} (en)` }, + ], + defaultLocale: 'zh-CN', + startAt: '2026-12-01T02:00:00.000Z', + }); + expect(res.status).toBe(201); + const album = await prisma.albums.findUnique({ + where: { id: res.body.id as string }, + select: { publicToken: true }, + }); + expect(album).not.toBeNull(); + return { id: res.body.id as string, publicToken: album!.publicToken }; + }; + + 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(); + + const mkUser = (account: string) => + prisma.users.create({ + data: { account, passwordHash: 'x', displayName: account }, + select: { id: true }, + }); + ownerId = (await mkUser('as-owner')).id; + collaboratorId = (await mkUser('as-collab')).id; + outsiderId = (await mkUser('as-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); + collaboratorToken = await mkToken(collaboratorId); + outsiderToken = await mkToken(outsiderId); + + const main = await createAlbum('资产相册'); + albumId = main.id; + albumToken = main.publicToken; + await prisma.album_members.create({ + data: { albumId, userId: collaboratorId, role: 'collaborator' }, + }); + + const globalOnly = await createAlbum('仅全局资产相册'); + globalOnlyAlbumId = globalOnly.id; + globalOnlyToken = globalOnly.publicToken; + }); + + afterAll(async () => { + await app.close(); + await prisma.$disconnect(); + }); + + it('owner 主链路:init → 201 行内会话;PUT 真图;complete → 200 可取回;DB effective + audit', async () => { + const jpeg = await makeJpeg(64, 40, { r: 96, g: 130, b: 182 }); + const initRes = await initAsset(ownerToken, albumId, { + kind: 'banner', + locale: 'zh-CN', + filename: 'b.jpg', + bytes: jpeg.length, + contentType: 'image/jpeg', + }); + expect(initRes.status).toBe(201); + expect(Object.keys(initRes.body).sort()).toEqual(INIT_KEYS); + expect(Object.keys(initRes.body.credential).sort()).toEqual(CREDENTIAL_KEYS); + expect(initRes.body.credential).toMatchObject({ + mode: 'presigned', + method: 'PUT', + headers: { 'Content-Type': 'image/jpeg' }, + }); + expect( + new Date(initRes.body.credential.expiresAt).getTime(), + ).toBeGreaterThan(Date.now()); + + const { assetId, objectKey, credential } = initRes.body as { + assetId: string; + objectKey: string; + credential: Credential; + }; + t1Asset = { assetId, objectKey, jpeg }; + // 对象键:photo-live/dev/albums/{albumId}/assets/{assetId}.jpg(最终键即键)。 + expect(objectKey).toBe(`${KEY_ROOT}/albums/${albumId}/assets/${assetId}.jpg`); + + // DB:init 即建行,state=pending,申明值行内持久化(complete 核验依据)。 + const pendingRow = await prisma.album_assets.findUnique({ + where: { id: assetId }, + }); + expect(pendingRow).toMatchObject({ + albumId, + kind: 'banner', + locale: 'zh-CN', + objectKey, + state: 'pending', + expectedBytes: jpeg.length, + contentType: 'image/jpeg', + }); + + expect(await putByCredential(credential, jpeg)).toBe(true); + + const completeRes = await completeAsset(ownerToken, albumId, assetId); + expect(completeRes.status).toBe(200); + expect(Object.keys(completeRes.body).sort()).toEqual(COMPLETE_KEYS); + expect(completeRes.body.assetId).toBe(assetId); + expect(Date.parse(completeRes.body.expiresAt)).toBeGreaterThan(Date.now()); + + // 签名 URL 直取 MinIO:真实字节 + JPEG content-type。 + const fetched = await fetch(completeRes.body.url); + expect(fetched.status).toBe(200); + expect(fetched.headers.get('content-type')).toBe('image/jpeg'); + expect(Buffer.from(await fetched.arrayBuffer())).toEqual(jpeg); + + // DB:行置 effective(挂载生效)。 + const effectiveRow = await prisma.album_assets.findUnique({ + where: { id: assetId }, + }); + expect(effectiveRow?.state).toBe('effective'); + + // audit album.asset.add:actor/resourceId=albumId/metadata 三元组。 + const audits = await prisma.audit_logs.findMany({ + where: { action: 'album.asset.add', resourceId: albumId }, + }); + expect(audits).toHaveLength(1); + expect(audits[0]).toMatchObject({ + actorId: ownerId, + metadata: { assetId, kind: 'banner', locale: 'zh-CN' }, + }); + + // 公开消费:GET /public/albums/{token}?locale=zh-CN banner=该资产。 + await expectPublicBannerBytes(albumToken, 'zh-CN', jpeg); + }); + + it('需求 4.7 语言回退:en 请求无 en 行且无全局行 → banner null', async () => { + const res = await getPublic(albumToken, { locale: 'en' }); + expect(res.status).toBe(200); + expect(res.body.resolvedLocale).toBe('en'); + expect(res.body.banner).toBeNull(); + }); + + it('全局行(locale 缺省):en 取全局、zh-CN 仍取 zh-CN 行', async () => { + const globalJpeg = await makeJpeg(80, 50, { r: 182, g: 96, b: 130 }); + const uploaded = await uploadAsset(globalJpeg, { locale: undefined }); + + // DB:全局行 locale=NULL。 + const row = await prisma.album_assets.findUnique({ + where: { id: uploaded.assetId }, + }); + expect(row?.locale).toBeNull(); + + await expectPublicBannerBytes(albumToken, 'en', globalJpeg); + await expectPublicBannerBytes(albumToken, 'zh-CN', t1Asset.jpeg); + }); + + it('仅全局行的相册:zh-CN 请求 → 全局(回退默认图,需求 4.7)', async () => { + const jpeg = await makeJpeg(48, 30, { r: 130, g: 182, b: 96 }); + await uploadAsset(jpeg, { locale: undefined }, globalOnlyAlbumId); + await expectPublicBannerBytes(globalOnlyToken, 'zh-CN', jpeg); + }); + + it('替换语义:同 kind 二次上传 → 公开取新资产;旧行保留', async () => { + const newer = await uploadAsset(await makeJpeg(96, 60, { r: 200, g: 80, b: 80 })); + + // 公开 zh-CN 取新资产行(最新 effective)。 + await expectPublicBannerBytes(albumToken, 'zh-CN', newer.jpeg); + + // 旧行保留(P1b 无删除端点;zh-CN 行数=2:T1 + 本次)。 + const rows = await prisma.album_assets.findMany({ + where: { albumId, kind: 'banner', locale: 'zh-CN' }, + }); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.state)).toEqual(['effective', 'effective']); + }); + + it('协作者 init/complete → 403 AUTH_FORBIDDEN;局外人 → 404 ALBUM_NOT_FOUND', async () => { + const collabInit = await initAsset(collaboratorToken, albumId, { + kind: 'banner', + locale: 'zh-CN', + filename: 'c.jpg', + bytes: 1000, + contentType: 'image/jpeg', + }); + expect(collabInit.status).toBe(403); + expect(collabInit.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + // 协作者对既有资产 complete 亦 403(可见但无管理权)。 + const collabComplete = await completeAsset( + collaboratorToken, + albumId, + t1Asset.assetId, + ); + expect(collabComplete.status).toBe(403); + expect(collabComplete.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const outsiderInit = await initAsset(outsiderToken, albumId, { + kind: 'banner', + filename: 'o.jpg', + bytes: 1000, + contentType: 'image/jpeg', + }); + expect(outsiderInit.status).toBe(404); + expect(outsiderInit.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + const outsiderComplete = await completeAsset( + outsiderToken, + albumId, + t1Asset.assetId, + ); + expect(outsiderComplete.status).toBe(404); + expect(outsiderComplete.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); + + // 幽灵资产 id:可见册内不存在 → 403 AUTH_FORBIDDEN(契约:不区分防枚举)。 + const ghost = await completeAsset(ownerToken, albumId, 'no-such-asset'); + expect(ghost.status).toBe(403); + expect(ghost.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + }); + + it('uploadEnabled=false 不拦截资产(开关只管照片直传,§4.9)', async () => { + await prisma.albums.update({ + where: { id: albumId }, + data: { uploadEnabled: false }, + }); + try { + const jpeg = await makeJpeg(32, 32, { r: 10, g: 10, b: 10 }); + const uploaded = await uploadAsset(jpeg, { kind: 'cover' }); + expect(uploaded.complete.assetId).toBe(uploaded.assetId); + const row = await prisma.album_assets.findUnique({ + where: { id: uploaded.assetId }, + }); + expect(row).toMatchObject({ kind: 'cover', state: 'effective' }); + } finally { + await prisma.albums.update({ + where: { id: albumId }, + data: { uploadEnabled: true }, + }); + } + }); + + it('bytes 超 UPLOAD_MAX_BYTES(env 临时下调)→ 400;contentType 白名单外 → 400', async () => { + const previous = process.env.UPLOAD_MAX_BYTES; + process.env.UPLOAD_MAX_BYTES = '500'; + try { + const tooBig = await initAsset(ownerToken, albumId, { + kind: 'banner', + filename: 'big.jpg', + bytes: 501, + contentType: 'image/jpeg', + }); + expect(tooBig.status).toBe(400); + expect(tooBig.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + } finally { + if (previous === undefined) { + delete process.env.UPLOAD_MAX_BYTES; + } else { + process.env.UPLOAD_MAX_BYTES = previous; + } + } + + const badType = await initAsset(ownerToken, albumId, { + kind: 'banner', + filename: 't.txt', + bytes: 100, + contentType: 'text/plain', + }); + expect(badType.status).toBe(400); + expect(badType.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + // 扩展名白名单与 kind 枚举(DTO 层)。 + const badExt = await initAsset(ownerToken, albumId, { + kind: 'banner', + filename: 'virus.exe', + bytes: 100, + contentType: 'image/jpeg', + }); + expect(badExt.status).toBe(400); + const badKind = await initAsset(ownerToken, albumId, { + kind: 'mascot', + filename: 'm.jpg', + bytes: 100, + contentType: 'image/jpeg', + }); + expect(badKind.status).toBe(400); + }); + + it('complete 前未 PUT → 400 UPLOAD_VERIFY_FAILED,行保持 pending', async () => { + const initRes = await initAsset(ownerToken, albumId, { + kind: 'logo', + filename: 'l.png', + bytes: 800, + contentType: 'image/png', + }); + expect(initRes.status).toBe(201); + const { assetId } = initRes.body as { assetId: string }; + + const res = await completeAsset(ownerToken, albumId, assetId); + expect(res.status).toBe(400); + expect(res.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED); + const row = await prisma.album_assets.findUnique({ + where: { id: assetId }, + }); + expect(row?.state).toBe('pending'); + }); + + it('核验兜底:字节数不符 → 400;contentType 不符 → 400(行均保持 pending)', async () => { + const jpeg = await makeJpeg(40, 30, { r: 1, g: 2, b: 3 }); + + // ① 申明 bytes 与实传不符。 + const wrongBytes = await initAsset(ownerToken, albumId, { + kind: 'banner', + filename: 'wb.jpg', + bytes: jpeg.length + 1000, + contentType: 'image/jpeg', + }); + expect(wrongBytes.status).toBe(201); + const wb = wrongBytes.body as { assetId: string; credential: Credential }; + expect(await putByCredential(wb.credential, jpeg)).toBe(true); + const bytesRes = await completeAsset(ownerToken, albumId, wb.assetId); + expect(bytesRes.status).toBe(400); + expect(bytesRes.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED); + + // ② 申明 image/jpeg 实传 application/octet-stream(presign 不签名 content-type)。 + const wrongType = await initAsset(ownerToken, albumId, { + kind: 'banner', + filename: 'wt.jpg', + bytes: jpeg.length, + contentType: 'image/jpeg', + }); + const wt = wrongType.body as { assetId: string; credential: Credential }; + expect( + await putByCredential(wt.credential, jpeg, 'application/octet-stream'), + ).toBe(true); + const typeRes = await completeAsset(ownerToken, albumId, wt.assetId); + expect(typeRes.status).toBe(400); + expect(typeRes.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED); + + for (const id of [wb.assetId, wt.assetId]) { + const row = await prisma.album_assets.findUnique({ + where: { id }, + }); + expect(row?.state).toBe('pending'); + } + }); + + it('重复 complete 幂等 → 200 同 assetId 且 URL 可取回;audit 不重复', async () => { + const again = await completeAsset(ownerToken, albumId, t1Asset.assetId); + expect(again.status).toBe(200); + expect(Object.keys(again.body).sort()).toEqual(COMPLETE_KEYS); + expect(again.body.assetId).toBe(t1Asset.assetId); + const fetched = await fetch(again.body.url); + expect(fetched.status).toBe(200); + expect(Buffer.from(await fetched.arrayBuffer())).toEqual(t1Asset.jpeg); + + // audit 锚 resourceId=albumId:T1 + 全局 + 替换 + cover(uploadDisabled 用例) + // 各一条,幂等重复不新增。 + expect( + await prisma.audit_logs.count({ + where: { action: 'album.asset.add', resourceId: albumId }, + }), + ).toBe(4); + }); + + it('对象删除后重复 complete → 仍 200(已生效,不依赖 staging 核验)', async () => { + const { DeleteObjectCommand, S3Client } = await import('@aws-sdk/client-s3'); + const 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, + }); + try { + await s3.send( + new DeleteObjectCommand({ + Bucket: process.env.S3_BUCKET as string, + Key: t1Asset.objectKey, + }), + ); + } finally { + await s3.destroy(); + } + + const res = await completeAsset(ownerToken, albumId, t1Asset.assetId); + expect(res.status).toBe(200); + expect(res.body.assetId).toBe(t1Asset.assetId); + }); + + it('logo 冒烟:init+PUT+complete → 200;DB kind=logo 行生效', async () => { + const jpeg = await makeJpeg(24, 24, { r: 5, g: 5, b: 5 }); + const uploaded = await uploadAsset(jpeg, { + kind: 'logo', + locale: undefined, + filename: 'logo.jpg', + }); + const row = await prisma.album_assets.findUnique({ + where: { id: uploaded.assetId }, + }); + expect(row).toMatchObject({ kind: 'logo', locale: null, state: 'effective' }); + }); + + it('未认证 → 401(init 与 complete)', async () => { + const initRes = await request(app.getHttpServer()) + .post(`/${API_PREFIX}/albums/${albumId}/assets`) + .send({ kind: 'banner', filename: 'a.jpg', bytes: 10, contentType: 'image/jpeg' }); + expect(initRes.status).toBe(401); + + const completeRes = await request(app.getHttpServer()) + .post(`/${API_PREFIX}/albums/${albumId}/assets/${t1Asset.assetId}/complete`); + expect(completeRes.status).toBe(401); + }); +}); diff --git a/services/api/test/public.e2e-spec.ts b/services/api/test/public.e2e-spec.ts index 8052353..0deab27 100644 --- a/services/api/test/public.e2e-spec.ts +++ b/services/api/test/public.e2e-spec.ts @@ -314,8 +314,9 @@ describe('public (e2e)', () => { }); disabledToken = disabled.publicToken; - // banner 相册:album_assets(kind=banner) + 真实 MinIO 对象(fixture 直种, - // 上传链路属 P1b)。 + // banner 相册:album_assets(kind=banner, state=effective) + 真实 MinIO + // 对象(fixture 直种;P1b Task 3 起公开查询过滤 state='effective',且 + // 走语言偏好解析——此行为全局(locale NULL),任何 resolved locale 都回退到它)。 const banner = await seedAlbum({ alias: 'pub-banner', translations: [ @@ -327,7 +328,13 @@ describe('public (e2e)', () => { const bannerKey = `${KEY_ROOT}/albums/${banner.id}/assets/banner.jpg`; await storage.put(bannerKey, await makeJpeg(1200, 400), 'image/jpeg'); await prisma.album_assets.create({ - data: { albumId: banner.id, kind: 'banner', objectKey: bannerKey }, + data: { + albumId: banner.id, + kind: 'banner', + locale: null, + objectKey: bannerKey, + state: 'effective', + }, }); const noBanner = await seedAlbum({