feat(api): idempotent upload session init with presigned credential

POST /v1/uploads (Task 18, A03): @@unique(userId, clientTaskId) idempotency
anchor — same params return the original session (201, no duplicate photo);
param drift (album/bytes/contentType/extension) -> 409 UPLOAD_SESSION_CONFLICT.
photos.objectKey is set to the FINAL originals key at init while the session
holds the staging key; IDs come from an app-side cuid-like generator so both
self-referencing keys are written in one transaction. Credential is a real
MinIO presigned PUT (SIGNED_URL_TTL_SECONDS, lazily read). Permission order:
album invisible 404 -> member 403 -> uploadEnabled 409 -> size/ext 400 ->
category ownership 400. No audit on init (audit anchors on complete).

Pre-step for Task 19: QUEUE_PHOTO_PROCESS contract constant + lazy-safe
BullMQ queue producer (Redis down at boot must not crash the app; enqueue
failure only logs — sweep compensates). e2e reset now also wipes the MinIO
photo-live/dev prefix; jest maps @photo/storage to TS source.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 22:42:13 +08:00
parent e04e1f3bdf
commit a2a3ccd340
15 changed files with 1252 additions and 1 deletions
+3
View File
@@ -27,6 +27,9 @@ const config: Config = {
moduleNameMapper: {
'^@photo/contracts$': '<rootDir>/../../packages/contracts/src/index.ts',
'^@photo/db-cn$': '<rootDir>/../../packages/db-cn/src/index.ts',
// Task 18 起:@photo/storage 同样映射到 TS 源码(其依赖 @aws-sdk/* 与
// @photo/contracts 分别从 storage 包 node_modules / 上面的映射解析)。
'^@photo/storage$': '<rootDir>/../../packages/storage/src/index.ts',
'^(\\.{1,2}/.*)\\.js$': '$1',
},
// test/ 下的 *.spec.ts 为直连真实 DB 的集成型单测(seed.spec.ts、
+4
View File
@@ -20,8 +20,11 @@
"@node-rs/argon2": "^2.2.1",
"@photo/contracts": "workspace:*",
"@photo/db-cn": "workspace:*",
"@photo/storage": "workspace:^",
"bullmq": "^6.3.6",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"ioredis": "^6.0.0",
"nestjs-pino": "^4.6.1",
"pino": "^10.0.0",
"pino-http": "^11.0.0",
@@ -29,6 +32,7 @@
"rxjs": "^7.8.1"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.700.0",
"@nestjs/testing": "^10.4.6",
"@types/jest": "^29.5.13",
"@types/node": "^22.10.0",
+3
View File
@@ -21,6 +21,7 @@ import { AuthModule } from './auth/auth.module';
import { HealthController } from './health.controller';
import { PrismaModule } from './prisma/prisma.module';
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
import { UploadsModule } from './uploads/uploads.module';
import { resolveLogLevel } from './shared/log-level';
import type { ServerRequestWithId } from './shared/request-with-id';
@@ -31,6 +32,8 @@ import type { ServerRequestWithId } from './shared/request-with-id';
AuthModule,
// 相册域(Task 14 起步:PermissionsService;控制器自 Task 15 加入)。
AlbumsModule,
// 上传域(Task 18 init;Task 19 renew/complete/history)。
UploadsModule,
LoggerModule.forRoot({
pinoHttp: {
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
+50
View File
@@ -0,0 +1,50 @@
// services/api/src/uploads/dto.ts —— 上传端点入参 DTO(Task 18)。校验约束对齐
// 契约 UploadInitRequest:
// · contentType 限白名单枚举(image/heic 暂定,G2 真机验证);
// · bytes ≥ 1(契约 minimum);上限 UPLOAD_MAX_BYTES 是环境相关量,逐请求
// 惰性读取,故在服务层校验(同 filename 扩展名白名单与 categoryId 属主校验);
// · clientTaskId/filename/albumId/categoryId 列宽对齐 db-cn schema
// (VarChar 128/255/64/64)。
import {
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
import { UPLOAD_CONTENT_TYPES, type UploadContentType } from './support';
export class UploadInitRequestDto {
@IsString()
@IsNotEmpty()
@MaxLength(64)
albumId!: string;
/** 缺省为相册默认分类(P1a 无默认分类概念,落 null)。 */
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(64)
categoryId?: string;
/** 客户端任务幂等键(A03:同任务重试复用会话与照片 ID)。 */
@IsString()
@IsNotEmpty()
@MaxLength(128)
clientTaskId!: string;
/** 原始文件名,仅元数据;扩展名白名单校验在服务层(对象键后缀派生)。 */
@IsString()
@IsNotEmpty()
@MaxLength(255)
filename!: string;
@IsInt()
@Min(1)
bytes!: number;
@IsIn(UPLOAD_CONTENT_TYPES)
contentType!: UploadContentType;
}
@@ -0,0 +1,86 @@
// services/api/src/uploads/queue.provider.ts —— photo-process 队列生产者(Task 19
// 入队;队列名常量 QUEUE_PHOTO_PROCESS 由 @photo/contracts 导出,worker(Task 20/21)
// 消费同一队列)。lazy-safe 约定:
// · Queue 实例(含 ioredis 连接)在首次 enqueue 时才创建——Redis 不可用时
// 应用启动/请求管线均不受影响(BullMQ 连接仅在入队时使用);
// · 入队失败(连接拒绝/超时等)只记日志不抛错:complete 事务已提交,
// photo_jobs(pending) 行是持久事实源,Task 21 的补偿扫描会认领孤儿任务
// (总纲 §5.6「complete 后入队失败」的补偿路径);
// · ioredis 的 'error' 事件挂 no-op 监听:重连风暴期间未监听的 error 事件
// 会让进程崩溃,这里吞掉并交给 enqueue 的 catch 记日志。
import { Injectable, Logger, type OnModuleDestroy } from '@nestjs/common';
import { Queue } from 'bullmq';
import IORedis from 'ioredis';
import { QUEUE_PHOTO_PROCESS } from '@photo/contracts';
/** DI token。 */
export const PHOTO_PROCESS_QUEUE = Symbol('PHOTO_PROCESS_QUEUE');
/** 生产者窄接口:服务只依赖 enqueue,便于测试替身。 */
export interface PhotoProcessQueueProducer {
enqueuePhotoProcess(photoId: string): Promise<void>;
}
/** BullMQ job 名称(与 photo_jobs.type 同值)。 */
const PHOTO_PROCESS_JOB_NAME = 'process';
@Injectable()
export class BullMqPhotoProcessQueue
implements PhotoProcessQueueProducer, OnModuleDestroy
{
private readonly logger = new Logger(BullMqPhotoProcessQueue.name);
private queue: Queue | null = null;
/** 首次使用时构造;连接错误经 no-op 监听吞掉(见文件头注释)。 */
private getQueue(): Queue {
if (this.queue === null) {
const url = process.env.REDIS_URL_CN ?? 'redis://localhost:6379';
const connection = new IORedis(url, {
// BullMQ 对阻塞命令的要求:禁用 per-request 重试上限(等待 Worker 语义)。
maxRetriesPerRequest: null,
});
connection.on('error', (err: Error) => {
this.logger.warn(
`photo-process 队列连接异常(将由 ioredis 自动重连): ${err.message}`,
);
});
this.queue = new Queue(QUEUE_PHOTO_PROCESS, { connection });
}
return this.queue;
}
async enqueuePhotoProcess(photoId: string): Promise<void> {
try {
await this.getQueue().add(
PHOTO_PROCESS_JOB_NAME,
{ photoId },
{
// jobId 以 photoId 派生:并发 complete/补偿扫描重复入队天然去重(A03)。
jobId: `${PHOTO_PROCESS_JOB_NAME}:${photoId}`,
// Task 21 消费侧重试策略:3 次指数退避。
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
},
);
} catch (err) {
// 不抛错:photo_jobs(pending) 已是持久事实源,Task 21 sweep 补偿。
this.logger.error(
`photo-process 入队失败(sweep 将补偿)photoId=${photoId}: ${
err instanceof Error ? err.message : String(err)
}`,
);
}
}
async onModuleDestroy(): Promise<void> {
if (this.queue !== null) {
await this.queue.close().catch(() => undefined);
this.queue = null;
}
}
}
export const photoProcessQueueProvider = {
provide: PHOTO_PROCESS_QUEUE,
useClass: BullMqPhotoProcessQueue,
};
@@ -0,0 +1,13 @@
// services/api/src/uploads/storage.provider.ts —— StoragePort 的 DI 装配(Task 18)。
// api 只依赖存储端口抽象(packages/storage),测试/本地指向 MinIO、生产指向 OSS,
// 由 createStorageFromEnv() 依 S3_* 环境变量构造。构造期无网络请求(S3Client
// 懒连接),缺 env 时快速失败(storage 包哲学:禁止静默回退默认值)。
import { createStorageFromEnv, type StoragePort } from '@photo/storage';
/** DI token:photos/public 等后续模块经模块导出复用同一实例。 */
export const STORAGE_PORT = Symbol('STORAGE_PORT');
export const storageProvider = {
provide: STORAGE_PORT,
useFactory: (): StoragePort => createStorageFromEnv(),
};
+147
View File
@@ -0,0 +1,147 @@
// services/api/src/uploads/support.ts —— 上传域共用小工具(Task 18 起)。
// 对象键布局(契约 UploadInitResponse.objectKey / docs/03 §4 对象键规范):
// staging: photo-live/{env}/staging/{uploadId}/{photoId}.{ext}
// originals: photo-live/{env}/albums/{albumId}/originals/{photoId}.{ext}
// P1a 单环境(env=dev)硬编码前缀;多环境前缀化属 P5 部署事项。
import { randomBytes } from 'node:crypto';
import { HttpStatus } from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts';
import { ApiException } from '../api-exception';
import { albumNotFound } from '../albums/support';
/** 对象键根(与 test/reset.ts 的 MinIO 清理前缀一致)。 */
export const OBJECT_KEY_ROOT = 'photo-live/dev';
/** UPLOAD_MAX_BYTES 缺省 50MB(与 .env.example 同步)。 */
export const DEFAULT_UPLOAD_MAX_BYTES = 52_428_800;
/** SIGNED_URL_TTL_SECONDS 缺省 15 分钟(与 .env.example 同步)。 */
export const DEFAULT_SIGNED_URL_TTL_SECONDS = 900;
/**
* 逐请求惰性读取 UPLOAD_MAX_BYTES:配置不驻内存,测试可在进程内改 env 验证
* 边界(无需重启应用);运行时由根 dotenv-cli 注入。
*/
export function uploadMaxBytes(): number {
const parsed = Number.parseInt(process.env.UPLOAD_MAX_BYTES ?? '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_UPLOAD_MAX_BYTES;
}
/** 同上:凭证/会话 TTL 惰性读取(秒)。 */
export function signedUrlTtlSeconds(): number {
const parsed = Number.parseInt(process.env.SIGNED_URL_TTL_SECONDS ?? '', 10);
return Number.isFinite(parsed) && parsed > 0
? parsed
: DEFAULT_SIGNED_URL_TTL_SECONDS;
}
/** 契约 contentType 白名单(image/heic 为暂定项,解码能力待 G2 真机验证)。 */
export const UPLOAD_CONTENT_TYPES = [
'image/jpeg',
'image/png',
'image/heic',
] as const;
export type UploadContentType = (typeof UPLOAD_CONTENT_TYPES)[number];
/** filename 扩展名白名单(jpeg 归一为 jpg,避免同格式双扩展名键漂移)。 */
const EXTENSION_WHITELIST: Readonly<Record<string, string>> = Object.freeze({
jpg: 'jpg',
jpeg: 'jpg',
png: 'png',
heic: 'heic',
});
/**
* 从原始文件名取归一化扩展名;不在白名单返回 null(服务层 400)。
* filename 仅元数据(契约语义),扩展名唯一的结构化用途是对象键后缀。
*/
export function extOfFilename(filename: string): string | null {
const dot = filename.lastIndexOf('.');
if (dot < 0 || dot === filename.length - 1) {
return null;
}
return EXTENSION_WHITELIST[filename.slice(dot + 1).toLowerCase()] ?? null;
}
/** staging 对象键(会话 objectKey;uploadId 前置使会话与键一一对应)。 */
export function stagingObjectKey(
uploadId: string,
photoId: string,
ext: string,
): string {
return `${OBJECT_KEY_ROOT}/staging/${uploadId}/${photoId}.${ext}`;
}
/** originals 正式键(photos.objectKey,init 即写入,complete 受控复制目标)。 */
export function originalsObjectKey(
albumId: string,
photoId: string,
ext: string,
): string {
return `${OBJECT_KEY_ROOT}/albums/${albumId}/originals/${photoId}.${ext}`;
}
/** 取对象键尾缀扩展名(幂等冲突比对用:filename 未持久化,以扩展名近似锚定)。 */
export function extOfObjectKey(objectKey: string): string | null {
const dot = objectKey.lastIndexOf('.');
if (dot < 0) {
return null;
}
return objectKey.slice(dot + 1).toLowerCase();
}
const CUID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
/**
* cuid 形状的应用侧 ID 生成('c'+base36 时间戳+随机,25 字符):对象键包含
* 自引用键段(staging 键含 uploadId、photos.objectKey 含 photoId),须先有 ID
* 才能一次 insert 成形(避免「先建行再回填键」的双写)。随机段 36^16≈8e24,
* 碰撞概率可忽略;真撞 @id 唯一索引按 5xx 兜底(同 publicToken 先例不做重试)。
*/
export function cuidLikeId(): string {
let id = `c${Date.now().toString(36)}`;
const bytes = randomBytes(16);
for (let i = 0; i < bytes.length; i++) {
id += CUID_ALPHABET[bytes[i] % CUID_ALPHABET.length];
}
return id;
}
// ── 统一错误出口 ──
export { albumNotFound };
/** 409:相册已关闭上传(相册级运营状态,owner 亦受限)。 */
export function albumUploadDisabled(): ApiException {
return new ApiException(
ErrorCodes.ALBUM_UPLOAD_DISABLED,
'相册已关闭上传',
HttpStatus.CONFLICT,
);
}
/** 409:同 clientTaskId 已存在且参数不一致。 */
export function uploadSessionConflict(message: string): ApiException {
return new ApiException(
ErrorCodes.UPLOAD_SESSION_CONFLICT,
message,
HttpStatus.CONFLICT,
);
}
/** 400:完成核验失败(对象缺失/字节数不符/完整性信息不可用)。 */
export function uploadVerifyFailed(message: string): ApiException {
return new ApiException(
ErrorCodes.UPLOAD_VERIFY_FAILED,
message,
HttpStatus.BAD_REQUEST,
);
}
/** 403:无权操作该会话或会话不存在(不区分,防 uploadId 枚举;契约语义)。 */
export function uploadSessionForbidden(): ApiException {
return new ApiException(
ErrorCodes.AUTH_FORBIDDEN,
'无权操作该上传会话',
HttpStatus.FORBIDDEN,
);
}
@@ -0,0 +1,31 @@
// services/api/src/uploads/uploads.controller.ts —— /uploads 端点(Task 18 起)。
// 方法名与契约 operationId 一一对应(initUpload;Task 19 增补 renewUploadCredential/
// completeUpload/listUploads)。控制器级 SessionGuard:端点全需认证。职责:
// 透传认证用户 + 查询参数规整(parseLimit/parseCursor 复用相册控制器导出)。
import {
Body,
Controller,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { userOf } from '../albums/albums.controller';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { UploadsService } from './uploads.service';
import type { UploadInitResponse } from './uploads.service';
import { UploadInitRequestDto } from './dto';
@Controller('uploads')
@UseGuards(SessionGuard)
export class UploadsController {
constructor(private readonly uploadsService: UploadsService) {}
/** 契约 201(幂等重复调用返回原会话,同 201)。 */
@Post()
initUpload(
@Body() dto: UploadInitRequestDto,
@Req() req: AuthenticatedRequest,
): Promise<UploadInitResponse> {
return this.uploadsService.init(userOf(req), dto);
}
}
@@ -0,0 +1,21 @@
// services/api/src/uploads/uploads.module.ts —— 上传模块(Task 18 起步,Task 19
// 增补 renew/complete/history)。装配:PermissionsService(经 AlbumsModule 导入
// 复用 A02 逐次权限判定)、StoragePort(packages/storage env 工厂)、
// photo-process 队列生产者(lazy-safe,见 queue.provider.ts)。
import { Module } from '@nestjs/common';
import { AlbumsModule } from '../albums/albums.module';
import { UploadsController } from './uploads.controller';
import { UploadsService } from './uploads.service';
import { storageProvider, STORAGE_PORT } from './storage.provider';
import {
photoProcessQueueProvider,
PHOTO_PROCESS_QUEUE,
} from './queue.provider';
@Module({
imports: [AlbumsModule],
controllers: [UploadsController],
providers: [UploadsService, storageProvider, photoProcessQueueProvider],
exports: [STORAGE_PORT, PHOTO_PROCESS_QUEUE],
})
export class UploadsModule {}
+205
View File
@@ -0,0 +1,205 @@
// services/api/src/uploads/uploads.service.ts —— 上传域核心逻辑(Task 18 起)。
// 契约权威:packages/contracts/openapi.yaml tags [uploads]。要点:
// · initUpload 幂等初始化(A03):@@unique([userId, clientTaskId]) 为幂等锚,
// 同键同参返回原会话(201,不重复建照片);同键异参 409 UPLOAD_SESSION_CONFLICT。
// 并发同键插入由 P2002 捕获后回读既有会话裁决(MySQL 约束冲突不中止事务)。
// · 双键分离:photos.objectKey 在 init 即写为最终 originals 键,
// upload_sessions.objectKey 为 staging 键——杜绝「一个键两种含义」的二义。
// 键段含自引用 ID(staging 含 uploadId、originals 含 photoId),ID 由应用侧
// cuid 形状生成器产出(见 support.cuidLikeId),保证事务内一次 insert 成形。
// · 校验顺序(契约错误优先级):相册不可见 404 → 非成员 403(P1a 不可达,
// canView ⊆ canUpload,为角色扩展预留)→ uploadEnabled 409 → bytes/扩展名
// 400 → categoryId 属主 400 → 幂等裁决。
// · init 不落 audit(高频低风险;审计锚点在 complete,A06 前置约定)。
// · UPLOAD_MAX_BYTES/SIGNED_URL_TTL_SECONDS 逐请求惰性读取(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 { PermissionsService } from '../albums/permissions.service';
import {
albumForbidden,
albumNotFound,
isUniqueViolationOn,
validationFailed,
} from '../albums/support';
import { PrismaService } from '../prisma/prisma.service';
import { STORAGE_PORT } from './storage.provider';
import type { UploadInitRequestDto } from './dto';
import {
albumUploadDisabled,
cuidLikeId,
extOfFilename,
extOfObjectKey,
originalsObjectKey,
signedUrlTtlSeconds,
stagingObjectKey,
uploadMaxBytes,
uploadSessionConflict,
} from './support';
export type UploadInitResponse =
components['schemas']['UploadInitResponse'];
type SessionRow = {
id: string;
photoId: string;
objectKey: string;
expectedBytes: number;
contentType: string;
albumId: string;
};
@Injectable()
export class UploadsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
) {}
/**
* 幂等初始化:权限/开关/参数校验 → 事务(幂等裁决 + 双行创建)→ presign。
* 返回的 credential 每次调用现签(幂等重发也拿新凭证,旧凭证不受影响)。
*/
async init(user: AuthUser, dto: UploadInitRequestDto): Promise<UploadInitResponse> {
// ① 相册可见性:不可见(含不存在)统一 404,防 id 枚举(同相册域语义)。
if (!(await this.permissions.canViewAlbum(user, dto.albumId))) {
throw albumNotFound();
}
// ② 上传成员权(P1a:可见即成员,此分支不可达;viewer 角色出现时启用 403)。
if (!(await this.permissions.canUploadToAlbum(user, dto.albumId))) {
throw albumForbidden('无该相册的上传权限');
}
// ③ 相册行(admin 对不存在 id 权限恒真,须由空值兜底同码 404)+ 上传开关。
const album = await this.prisma.albums.findUnique({
where: { id: dto.albumId },
select: { uploadEnabled: true },
});
if (album === null) {
throw albumNotFound();
}
if (!album.uploadEnabled) {
throw albumUploadDisabled();
}
// ④ 大小限额(惰性读 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)',
);
}
// ⑤ categoryId 属主:须存在且属于目标相册(跨册分类不可挂入)。
if (dto.categoryId !== undefined) {
const category = await this.prisma.categories.findUnique({
where: { id: dto.categoryId },
select: { albumId: true },
});
if (category === null || category.albumId !== dto.albumId) {
throw validationFailed('categoryId 不属于该相册');
}
}
// ⑥ 事务:幂等裁决(锚 @@unique([userId, clientTaskId]))+ 双行创建。
const session = await this.prisma.$transaction(async (tx) => {
const existing = await tx.upload_sessions.findUnique({
where: {
userId_clientTaskId: { userId: user.id, clientTaskId: dto.clientTaskId },
},
});
if (existing !== null) {
if (this.paramsMatch(existing, dto, ext)) {
return existing;
}
throw uploadSessionConflict(
'clientTaskId 已存在且参数不一致(albumId/bytes/contentType/扩展名)',
);
}
try {
const photoId = cuidLikeId();
const uploadId = cuidLikeId();
await tx.photos.create({
data: {
id: photoId,
albumId: dto.albumId,
uploaderId: user.id,
categoryId: dto.categoryId ?? null,
objectKey: originalsObjectKey(dto.albumId, photoId, ext),
// bytes=0/processingState=pending_upload/visibility=unpublished 走默认。
},
});
const created = await tx.upload_sessions.create({
data: {
id: uploadId,
userId: user.id,
albumId: dto.albumId,
clientTaskId: dto.clientTaskId,
photoId,
objectKey: stagingObjectKey(uploadId, photoId, ext),
expectedBytes: dto.bytes,
contentType: dto.contentType,
expiresAt: new Date(Date.now() + signedUrlTtlSeconds() * 1000),
},
});
return created;
} catch (err) {
// 并发同键:唯一索引竞争的败方回读既有会话再做幂等裁决(MySQL 约束
// 冲突不中止事务,可继续查询)。
if (isUniqueViolationOn(err, 'userId_clientTaskId')) {
const winner = await tx.upload_sessions.findUnique({
where: {
userId_clientTaskId: {
userId: user.id,
clientTaskId: dto.clientTaskId,
},
},
});
if (winner !== null && this.paramsMatch(winner, dto, ext)) {
return winner;
}
throw uploadSessionConflict(
'clientTaskId 已存在且参数不一致(albumId/bytes/contentType/扩展名)',
);
}
throw err;
}
});
// ⑦ presign(SigV4 本地计算,无网络副作用):幂等返回亦现签新凭证。
const credential = await this.storage.presignPut(
session.objectKey,
session.expectedBytes,
session.contentType,
signedUrlTtlSeconds(),
);
return {
uploadId: session.id,
photoId: session.photoId,
objectKey: session.objectKey,
credential,
};
}
/**
* 幂等参数一致性:对「会话行可持久化的事实」逐项比对——albumId/expectedBytes/
* contentType,外加 staging 键扩展名 vs 新 filename 扩展名(filename 本身不落库,
* schema 无该列;同扩展名改名视为同一任务意图,返回原会话)。
*/
private paramsMatch(
session: SessionRow,
dto: UploadInitRequestDto,
ext: string,
): boolean {
return (
session.albumId === dto.albumId &&
session.expectedBytes === dto.bytes &&
session.contentType === dto.contentType &&
extOfObjectKey(session.objectKey) === ext
);
}
}
+68 -1
View File
@@ -6,7 +6,14 @@
// 表序仍按 FK 安全顺序排列(子表在前),关检查失败时也不至于中途卡死。
// 顺序 await 在 Prisma 连接池上复用同一空闲连接,会话级 FOREIGN_KEY_CHECKS
// 对后续 TRUNCATE 生效;finally 恢复检查并断开,失败也不泄漏连接。
// TODO(Task 11): 增加 MinIO `photo-live/dev` 前缀对象清理(同一函数内追加)。
// Task 18 起 e2e 会向 MinIO 写入真实对象(staging/originals):同一函数内追加
// `photo-live/dev` 前缀对象清理(reset.ts 文件头 TODO 的落实——只清本前缀,
// packages/storage 的 test/<ts>-<uuid>/ 前缀对象不受影响,两包测试可并行)。
import {
DeleteObjectsCommand,
ListObjectsV2Command,
S3Client,
} from '@aws-sdk/client-s3';
import { PrismaClient } from '@photo/db-cn';
export const RESET_TABLES: readonly string[] = [
@@ -31,7 +38,67 @@ export const RESET_TABLES: readonly string[] = [
'users',
];
/** e2e 对象隔离边界:与 src/uploads/support.ts 的 OBJECT_KEY_ROOT 一致。 */
const RESET_OBJECT_PREFIX = 'photo-live/dev/';
/** 删除 MinIO 中 RESET_OBJECT_PREFIX 前缀的全部对象(分页列举 + 批量删除)。 */
async function resetMinioObjects(): Promise<void> {
const required = [
'S3_ENDPOINT',
'S3_REGION',
'S3_ACCESS_KEY',
'S3_SECRET_KEY',
'S3_BUCKET',
] as const;
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
throw new Error(`resetMinioObjects: 缺少环境变量 ${missing.join(', ')}`);
}
const client = 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 bucket = process.env.S3_BUCKET as string;
try {
for (;;) {
const listed = await client.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: RESET_OBJECT_PREFIX,
MaxKeys: 1000,
}),
);
const objects = (listed.Contents ?? []).filter(
(obj) => typeof obj.Key === 'string',
);
if (objects.length === 0) {
break;
}
await client.send(
new DeleteObjectsCommand({
Bucket: bucket,
Delete: {
Objects: objects.map((obj) => ({ Key: obj.Key as string })),
Quiet: true,
},
}),
);
if (!listed.IsTruncated) {
break;
}
}
} finally {
await client.destroy();
}
}
export async function resetDb(): Promise<void> {
await resetMinioObjects();
const prisma = new PrismaClient();
try {
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
+426
View File
@@ -0,0 +1,426 @@
// services/api/test/uploads.e2e-spec.ts —— Task 18/19 上传域 e2e(先写后实现)。
// 认证同 Task 15:Prisma 直种 sessions 行换 Bearer 令牌;相册/成员 fixture 经
// Prisma 直写。真实 MinIO(compose photo-live-minio-1):presign PUT 实际写入、
// head 核验、copyIfAbsent 受控复制;真实 Redis(BullMQ photo-process 队列 peek)。
// 覆盖(A03 幂等 / A02 逐次权限):
// init:成员初始化→201 有效凭证(PUT 实写 MinIO head 可见)+ DB 双行
// (photos.objectKey=正式 originals 键;upload_sessions.objectKey=staging 键);
// 同 clientTaskId 同参→201 复用同一 uploadId+photoId(不重复建行);
// 同 clientTaskId 异参(album/bytes/contentType/扩展名)→409 UPLOAD_SESSION_CONFLICT;
// 不同 clientTaskId 同名文件→两会话两照片(同名不覆盖);
// 非成员(含他册成员)→404 ALBUM_NOT_FOUND;uploadEnabled=false→409
// (owner 亦拒);超限/类型/缺字段/坏扩展名/异册 categoryId→400;
// init 不落 audit(高频低风险;审计在 complete)。
// renew/complete/history(Task 19,见下方 describe)。
import { randomBytes } from 'node:crypto';
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
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';
/** 契约 UploadInitResponse 字段全集(形状精确断言用)。 */
const INIT_KEYS = ['uploadId', 'photoId', 'objectKey', 'credential'].sort();
const CREDENTIAL_KEYS = ['mode', 'url', 'method', 'headers', 'expiresAt'].sort();
/** 测试隔离边界内的对象键根(与 reset.ts 的 MinIO 清理前缀一致)。 */
const KEY_ROOT = 'photo-live/dev';
describe('uploads init (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let ownerId: string;
let memberId: string;
/** 仅属于 otherAlbum 的成员(「他册成员对本册不可见」用例)。 */
let otherMemberId: string;
let outsiderId: string;
let ownerToken: string;
let memberToken: string;
let otherMemberToken: string;
let outsiderToken: string;
/** T1 幂等锚会话 id(首个用例捕获,后续用例复用断言)。 */
let t1UploadId: string;
let albumId: string;
let otherAlbumId: string;
/** album 内合法分类(Prisma 直建)。 */
let categoryId: string;
/** otherAlbum 的分类(跨册 400 用例)。 */
let foreignCategoryId: string;
const postInit = (token: string, body: object) =>
request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads`)
.set('Authorization', `Bearer ${token}`)
.send(body);
const initBody = (overrides: Partial<Record<string, unknown>> = {}) => ({
albumId,
clientTaskId: 'T1',
filename: 'a.jpg',
bytes: 1000,
contentType: 'image/jpeg',
...overrides,
});
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('up-owner')).id;
memberId = (await mkUser('up-member')).id;
otherMemberId = (await mkUser('up-other-member')).id;
outsiderId = (await mkUser('up-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);
memberToken = await mkToken(memberId);
otherMemberToken = await mkToken(otherMemberId);
outsiderToken = await mkToken(outsiderId);
// 相册 fixture:owner 建 A(API 建册自动落 owner 成员行),member 为协作者;
// 相册 B 属 owner(Prisma 直建 + owner 成员行),member 亦加入 B(跨册幂等
// 冲突用例需要同一用户在两册均可见),otherMember 仅在 B(他册成员 404 用例)。
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/albums`)
.set('Authorization', `Bearer ${ownerToken}`)
.send({
translations: [
{ locale: 'zh-CN', title: '上传相册' },
{ locale: 'en', title: 'Uploads' },
],
defaultLocale: 'zh-CN',
startAt: '2026-12-01T02:00:00.000Z',
});
expect(res.status).toBe(201);
albumId = res.body.id as string;
await prisma.album_members.create({
data: { albumId, userId: memberId, role: 'collaborator' },
});
const other = await prisma.albums.create({
data: {
publicToken: 'otheralbum0000000000000a',
ownerId,
startAt: new Date(),
defaultLocale: 'zh-CN',
},
select: { id: true },
});
otherAlbumId = other.id;
await prisma.album_members.createMany({
data: [
{ albumId: otherAlbumId, userId: ownerId, role: 'owner' },
{ albumId: otherAlbumId, userId: memberId, role: 'collaborator' },
{ albumId: otherAlbumId, userId: otherMemberId, role: 'collaborator' },
],
});
categoryId = (
await prisma.categories.create({
data: { albumId, sortOrder: 0 },
select: { id: true },
})
).id;
await prisma.category_translations.create({
data: { categoryId, locale: 'zh-CN', name: '默认' },
});
foreignCategoryId = (
await prisma.categories.create({
data: { albumId: otherAlbumId, sortOrder: 0 },
select: { id: true },
})
).id;
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
it('成员初始化 → 201;凭证可实写 MinIO;DB:staging 会话行 + 正式键 photos 行', async () => {
const res = await postInit(memberToken, initBody());
expect(res.status).toBe(201);
expect(Object.keys(res.body).sort()).toEqual(INIT_KEYS);
expect(Object.keys(res.body.credential).sort()).toEqual(CREDENTIAL_KEYS);
expect(res.body.credential).toMatchObject({
mode: 'presigned',
method: 'PUT',
headers: { 'Content-Type': 'image/jpeg' },
});
expect(new Date(res.body.credential.expiresAt).getTime()).toBeGreaterThan(
Date.now(),
);
const { uploadId, photoId, objectKey } = res.body as {
uploadId: string;
photoId: string;
objectKey: string;
};
t1UploadId = uploadId;
expect(objectKey).toBe(`${KEY_ROOT}/staging/${uploadId}/${photoId}.jpg`);
// 凭证真实可用:按凭证 PUT 后 head 可见精确字节数(fetch 全局可用,Node 22)。
const body = Buffer.alloc(1000, 1);
const put = await fetch(res.body.credential.url, {
method: res.body.credential.method,
headers: res.body.credential.headers,
body: new Uint8Array(body),
});
expect(put.ok).toBe(true);
// DB:会话行(pending、expiresAt≈now+SIGNED_URL_TTL_SECONDS)。
const ttl = Number.parseInt(
process.env.SIGNED_URL_TTL_SECONDS ?? '900',
10,
);
const session = await prisma.upload_sessions.findUnique({
where: { id: uploadId },
});
expect(session).toMatchObject({
userId: memberId,
albumId,
clientTaskId: 'T1',
photoId,
objectKey,
expectedBytes: 1000,
contentType: 'image/jpeg',
state: 'pending',
});
const drift =
(session?.expiresAt.getTime() ?? Number.NaN) - (Date.now() + ttl * 1000);
expect(Math.abs(drift)).toBeLessThan(10_000);
// DB:photos 行——objectKey 是 FINAL originals 键(init 即写入),未发布待传。
const photo = await prisma.photos.findUnique({ where: { id: photoId } });
expect(photo).toMatchObject({
albumId,
uploaderId: memberId,
categoryId: null,
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`,
bytes: 0,
processingState: 'pending_upload',
visibility: 'unpublished',
});
});
it('同 clientTaskId 同参重发 → 201 复用同一 uploadId+photoId+objectKey(A03,无第二行)', async () => {
const first = await postInit(memberToken, initBody());
expect(first.status).toBe(201);
const again = await postInit(memberToken, initBody());
expect(again.status).toBe(201);
expect(again.body.uploadId).toBe(first.body.uploadId);
expect(again.body.photoId).toBe(first.body.photoId);
expect(again.body.objectKey).toBe(first.body.objectKey);
expect(await prisma.upload_sessions.count({ where: { userId: memberId } })).toBe(1);
expect(await prisma.photos.count({ where: { albumId } })).toBe(1);
});
it('同 clientTaskId 异参(album/bytes/contentType/扩展名)→ 409 UPLOAD_SESSION_CONFLICT', async () => {
// T1 锚定 albumId/1000/image/jpeg/a.jpg(前述用例)。
const albumConflict = await postInit(
memberToken,
initBody({ albumId: otherAlbumId }),
);
expect(albumConflict.status).toBe(409);
expect(albumConflict.body.code).toBe(ErrorCodes.UPLOAD_SESSION_CONFLICT);
const bytesConflict = await postInit(memberToken, initBody({ bytes: 2000 }));
expect(bytesConflict.status).toBe(409);
expect(bytesConflict.body.code).toBe(ErrorCodes.UPLOAD_SESSION_CONFLICT);
const typeConflict = await postInit(
memberToken,
initBody({ filename: 'a.png', contentType: 'image/png', bytes: 2000 }),
);
expect(typeConflict.status).toBe(409);
// filename 未持久化(schema 无该列):以扩展名近似锚定——同 clientTaskId 换
// 扩展名视为参数漂移 → 409。
const extConflict = await postInit(
memberToken,
initBody({ filename: 'b.png' }),
);
expect(extConflict.status).toBe(409);
expect(extConflict.body.code).toBe(ErrorCodes.UPLOAD_SESSION_CONFLICT);
// 对应地:同扩展名改名(a2.jpg)不构成漂移 → 仍复用原会话(幂等意图)。
const rename = await postInit(memberToken, initBody({ filename: 'a2.jpg' }));
expect(rename.status).toBe(201);
expect(rename.body.uploadId).toBe(t1UploadId);
expect(rename.body.photoId).toBe(
(await prisma.upload_sessions.findUnique({ where: { id: t1UploadId } }))
?.photoId,
);
});
it('不同 clientTaskId 同名文件 → 两会话两照片(同名不覆盖,A03)', async () => {
const one = await postInit(memberToken, initBody({ clientTaskId: 'same-name-1' }));
const two = await postInit(memberToken, initBody({ clientTaskId: 'same-name-2' }));
expect(one.status).toBe(201);
expect(two.status).toBe(201);
expect(one.body.uploadId).not.toBe(two.body.uploadId);
expect(one.body.photoId).not.toBe(two.body.photoId);
expect(one.body.objectKey).not.toBe(two.body.objectKey);
expect(await prisma.upload_sessions.count({ where: { userId: memberId } })).toBe(3);
expect(await prisma.photos.count({ where: { albumId } })).toBe(3);
});
it('非成员 → 404 ALBUM_NOT_FOUND(他册成员同)', async () => {
const res = await postInit(outsiderToken, initBody({ clientTaskId: 'T-out' }));
expect(res.status).toBe(404);
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
// otherMember(B 册成员)对 A 不可见 → 同码 404。
const foreign = await request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads`)
.set('Authorization', `Bearer ${otherMemberToken}`)
.send(initBody({ clientTaskId: 'T-foreign' }));
expect(foreign.status).toBe(404);
expect(foreign.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
});
it('uploadEnabled=false → 409 ALBUM_UPLOAD_DISABLED(owner 亦拒)', async () => {
await prisma.albums.update({
where: { id: albumId },
data: { uploadEnabled: false },
});
try {
const res = await postInit(ownerToken, initBody({ clientTaskId: 'T-off' }));
expect(res.status).toBe(409);
expect(res.body.code).toBe(ErrorCodes.ALBUM_UPLOAD_DISABLED);
// 未创建任何行(开关是相册级运营状态,先于参数与建行)。
expect(
await prisma.upload_sessions.count({ where: { clientTaskId: 'T-off' } }),
).toBe(0);
} finally {
await prisma.albums.update({
where: { id: albumId },
data: { uploadEnabled: true },
});
}
});
it('bytes 超 UPLOAD_MAX_BYTES(env 临时下调,逐请求惰性读取)→ 400', async () => {
const previous = process.env.UPLOAD_MAX_BYTES;
process.env.UPLOAD_MAX_BYTES = '500';
try {
const res = await postInit(memberToken, initBody({ clientTaskId: 'T-big' }));
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// 边界内(=500)通过。
const edge = await postInit(
memberToken,
initBody({ clientTaskId: 'T-edge', bytes: 500 }),
);
expect(edge.status).toBe(201);
} finally {
if (previous === undefined) {
delete process.env.UPLOAD_MAX_BYTES;
} else {
process.env.UPLOAD_MAX_BYTES = previous;
}
}
});
it('contentType 白名单外 / 缺字段 / bytes<1 / 坏扩展名 → 400 VALIDATION_FAILED', async () => {
const badType = await postInit(
memberToken,
initBody({ clientTaskId: 'T-plain', contentType: 'text/plain' }),
);
expect(badType.status).toBe(400);
expect(badType.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const missing = await postInit(memberToken, {
albumId,
clientTaskId: 'T-missing',
bytes: 100,
contentType: 'image/jpeg',
});
expect(missing.status).toBe(400);
expect(missing.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const zeroBytes = await postInit(
memberToken,
initBody({ clientTaskId: 'T-zero', bytes: 0 }),
);
expect(zeroBytes.status).toBe(400);
const badExt = await postInit(
memberToken,
initBody({ clientTaskId: 'T-ext', filename: 'virus.exe' }),
);
expect(badExt.status).toBe(400);
expect(badExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('categoryId:未知/异册 → 400;本册合法 → 201 且 photos.categoryId 落库', async () => {
const unknown = await postInit(
memberToken,
initBody({ clientTaskId: 'T-cat-unknown', categoryId: 'no-such-category' }),
);
expect(unknown.status).toBe(400);
expect(unknown.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const foreign = await postInit(
memberToken,
initBody({ clientTaskId: 'T-cat-foreign', categoryId: foreignCategoryId }),
);
expect(foreign.status).toBe(400);
expect(foreign.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const ok = await postInit(
memberToken,
initBody({ clientTaskId: 'T-cat-ok', categoryId }),
);
expect(ok.status).toBe(201);
const photo = await prisma.photos.findUnique({
where: { id: ok.body.photoId },
});
expect(photo?.categoryId).toBe(categoryId);
});
it('init 不落 audit(高频低风险;审计在 complete,A06 前置约定)', async () => {
const audits = await prisma.audit_logs.count({
where: { action: { startsWith: 'upload.' } },
});
expect(audits).toBe(0);
});
it('未认证 → 401', async () => {
const res = await request(app.getHttpServer())
.post(`/${API_PREFIX}/uploads`)
.send(initBody({ clientTaskId: 'T-anon' }));
expect(res.status).toBe(401);
expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
});