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:
@@ -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、
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user