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 <noreply@anthropic.com>
This commit is contained in:
@@ -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';
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<AssetInitResponse> {
|
||||
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<AssetCompleteResponse> {
|
||||
return this.assetsService.complete(userOf(req), albumId, assetId);
|
||||
}
|
||||
}
|
||||
@@ -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<AssetInitResponse> {
|
||||
// ① 相册可见性:不可见(含不存在)统一 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<AssetCompleteResponse> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('.');
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
|
||||
/** 按凭证实写对象(contentType 可覆盖以构造核验失败用例)。 */
|
||||
async function putByCredential(
|
||||
credential: Credential,
|
||||
body: Buffer,
|
||||
contentType?: string,
|
||||
): Promise<boolean> {
|
||||
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<string, string>) =>
|
||||
request(app.getHttpServer())
|
||||
.get(`/${API_PREFIX}/public/albums/${token}`)
|
||||
.query(query ?? {});
|
||||
|
||||
/** 完整链路 helper:init → PUT 真图 → complete,返回锚点与响应。 */
|
||||
const uploadAsset = async (
|
||||
jpeg: Buffer,
|
||||
overrides: Record<string, unknown> = {},
|
||||
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<string> => {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await prisma.sessions.create({
|
||||
data: {
|
||||
userId,
|
||||
tokenHash: hashSessionToken(token),
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
},
|
||||
});
|
||||
return token;
|
||||
};
|
||||
ownerToken = await mkToken(ownerId);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user