feat(api-attachment): add minio presigned upload flow

This commit is contained in:
2026-04-05 00:05:39 +08:00
parent 8f6ff38a32
commit bd3241504f
9 changed files with 1905 additions and 1 deletions
+3 -1
View File
@@ -1,5 +1,6 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AttachmentModule } from "./attachment/attachment.module";
import { AuthModule } from "./auth/auth.module";
import { PrismaModule } from "./prisma/prisma.module";
import { TaskModule } from "./task/task.module";
@@ -12,7 +13,8 @@ import { TaskModule } from "./task/task.module";
}),
PrismaModule,
AuthModule,
TaskModule
TaskModule,
AttachmentModule
]
})
export class AppModule {}
@@ -0,0 +1,38 @@
import { Body, Controller, Headers, Post, UnauthorizedException } from "@nestjs/common";
import {
AttachmentResponse,
AttachmentService,
PresignAttachmentResponse
} from "./attachment.service";
import { CompleteAttachmentDto } from "./dto/complete-attachment.dto";
import { PresignAttachmentDto } from "./dto/presign-attachment.dto";
@Controller("attachments")
export class AttachmentController {
constructor(private readonly attachmentService: AttachmentService) {}
@Post("presign")
async presignAttachment(
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
@Body() body: PresignAttachmentDto
): Promise<PresignAttachmentResponse> {
return this.attachmentService.presignAttachment(this.resolveUserId(userIdHeader), body);
}
@Post("complete")
async completeAttachment(
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
@Body() body: CompleteAttachmentDto
): Promise<AttachmentResponse> {
return this.attachmentService.completeAttachment(this.resolveUserId(userIdHeader), body);
}
private resolveUserId(userIdHeader: string | string[] | undefined): string {
const userId = Array.isArray(userIdHeader) ? userIdHeader[0] : userIdHeader;
if (!userId) {
throw new UnauthorizedException("缺少用户上下文");
}
return userId;
}
}
@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module";
import { AttachmentController } from "./attachment.controller";
import { AttachmentService } from "./attachment.service";
@Module({
imports: [PrismaModule],
controllers: [AttachmentController],
providers: [AttachmentService]
})
export class AttachmentModule {}
@@ -0,0 +1,207 @@
import { randomUUID } from "node:crypto";
import { Injectable, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { AttachmentType } from "../../generated/prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { CompleteAttachmentDto } from "./dto/complete-attachment.dto";
import { PresignAttachmentDto } from "./dto/presign-attachment.dto";
export type PresignAttachmentResponse = {
method: "PUT";
uploadUrl: string;
bucket: string;
objectKey: string;
objectUrl: string;
expiresInSeconds: number;
headers: {
"Content-Type": string;
};
};
export type AttachmentResponse = {
id: string;
taskId: string | null;
type: AttachmentType;
url: string;
mimeType: string | null;
fileName: string | null;
fileSize: number;
width: number | null;
height: number | null;
durationMs: number | null;
checksum: string | null;
createdAt: string;
updatedAt: string;
};
@Injectable()
export class AttachmentService {
private s3Client: S3Client | null = null;
constructor(
private readonly configService: ConfigService,
private readonly prismaService: PrismaService
) {}
async presignAttachment(
userId: string,
body: PresignAttachmentDto
): Promise<PresignAttachmentResponse> {
if (body.taskId) {
await this.ensureTaskOwnership(userId, body.taskId);
}
const bucket = this.getDefaultBucket();
const objectKey = this.generateObjectKey(userId, body.fileName);
const objectUrl = this.resolveObjectUrl(bucket, objectKey);
const expiresInSeconds = this.getPresignExpiresInSeconds();
const command = new PutObjectCommand({
Bucket: bucket,
Key: objectKey,
ContentType: body.mimeType,
ContentLength: body.fileSize
});
const uploadUrl = await getSignedUrl(this.getS3Client(), command, {
expiresIn: expiresInSeconds
});
return {
method: "PUT",
uploadUrl,
bucket,
objectKey,
objectUrl,
expiresInSeconds,
headers: {
"Content-Type": body.mimeType
}
};
}
async completeAttachment(
userId: string,
body: CompleteAttachmentDto
): Promise<AttachmentResponse> {
if (body.taskId) {
await this.ensureTaskOwnership(userId, body.taskId);
}
const bucket = body.bucket ?? this.getDefaultBucket();
const objectUrl = this.resolveObjectUrl(bucket, body.objectKey);
const attachment = await this.prismaService.attachment.create({
data: {
userId,
taskId: body.taskId ?? null,
type: body.type ?? this.resolveAttachmentType(body.mimeType),
url: objectUrl,
mimeType: body.mimeType,
fileName: body.fileName,
fileSize: body.fileSize,
width: body.width ?? null,
height: body.height ?? null,
durationMs: body.durationMs ?? null,
checksum: body.checksum ?? null
}
});
return {
id: attachment.id,
taskId: attachment.taskId,
type: attachment.type,
url: attachment.url,
mimeType: attachment.mimeType,
fileName: attachment.fileName,
fileSize: attachment.fileSize,
width: attachment.width,
height: attachment.height,
durationMs: attachment.durationMs,
checksum: attachment.checksum,
createdAt: attachment.createdAt.toISOString(),
updatedAt: attachment.updatedAt.toISOString()
};
}
private getS3Client(): S3Client {
if (this.s3Client) {
return this.s3Client;
}
const endpoint = this.configService.get<string>("S3_ENDPOINT") ?? "http://127.0.0.1:9000";
const region = this.configService.get<string>("S3_REGION") ?? "us-east-1";
const forcePathStyle =
this.configService.get<string>("S3_FORCE_PATH_STYLE")?.toLowerCase() !== "false";
this.s3Client = new S3Client({
endpoint,
region,
forcePathStyle,
credentials: {
accessKeyId: this.configService.get<string>("S3_ACCESS_KEY_ID") ?? "minioadmin",
secretAccessKey: this.configService.get<string>("S3_SECRET_ACCESS_KEY") ?? "minioadmin"
}
});
return this.s3Client;
}
private getDefaultBucket(): string {
return this.configService.get<string>("S3_BUCKET") ?? "todolist";
}
private getPresignExpiresInSeconds(): number {
const configValue = Number(this.configService.get<string>("S3_PRESIGN_EXPIRES_SECONDS") ?? 900);
if (!Number.isFinite(configValue) || configValue <= 0) {
return 900;
}
return Math.min(configValue, 604800);
}
private generateObjectKey(userId: string, fileName: string): string {
const safeFileName = fileName.replace(/[^\w.-]+/g, "_");
const datePrefix = new Date().toISOString().slice(0, 10);
return `${userId}/${datePrefix}/${randomUUID()}-${safeFileName}`;
}
private resolveObjectUrl(bucket: string, objectKey: string): string {
const publicBaseUrl = this.configService.get<string>("S3_PUBLIC_BASE_URL");
if (publicBaseUrl) {
return `${publicBaseUrl.replace(/\/+$/, "")}/${bucket}/${objectKey}`;
}
const endpoint = this.configService.get<string>("S3_ENDPOINT") ?? "http://127.0.0.1:9000";
return `${endpoint.replace(/\/+$/, "")}/${bucket}/${objectKey}`;
}
private resolveAttachmentType(mimeType: string): AttachmentType {
if (mimeType.startsWith("image/")) {
return AttachmentType.IMAGE;
}
if (mimeType.startsWith("video/")) {
return AttachmentType.VIDEO;
}
return AttachmentType.FILE;
}
private async ensureTaskOwnership(userId: string, taskId: string): Promise<void> {
const task = await this.prismaService.task.findFirst({
where: {
id: taskId,
userId
},
select: {
id: true
}
});
if (!task) {
throw new NotFoundException("任务不存在");
}
}
}
@@ -0,0 +1,89 @@
import { Transform, Type } from "class-transformer";
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength
} from "class-validator";
import { AttachmentType } from "../../../generated/prisma/client";
function normalizeString(value: unknown): unknown {
if (typeof value !== "string") {
return value;
}
return value.trim();
}
export class CompleteAttachmentDto {
@Transform(({ value }) => normalizeString(value))
@IsString()
@MinLength(1)
@MaxLength(255)
objectKey!: string;
@Transform(({ value }) => normalizeString(value))
@IsOptional()
@IsString()
@MaxLength(100)
bucket?: string;
@Transform(({ value }) => normalizeString(value))
@IsString()
@MinLength(1)
@MaxLength(255)
fileName!: string;
@Transform(({ value }) => normalizeString(value))
@IsString()
@MinLength(1)
@MaxLength(255)
mimeType!: string;
@Type(() => Number)
@IsInt()
@Min(1)
@Max(1073741824)
fileSize!: number;
@IsOptional()
@IsEnum(AttachmentType)
type?: AttachmentType;
@Transform(({ value }) => normalizeString(value))
@IsOptional()
@IsString()
@MaxLength(255)
taskId?: string;
@Transform(({ value }) => normalizeString(value))
@IsOptional()
@IsString()
@MaxLength(128)
checksum?: string;
@Type(() => Number)
@IsOptional()
@IsInt()
@Min(1)
@Max(100000)
width?: number;
@Type(() => Number)
@IsOptional()
@IsInt()
@Min(1)
@Max(100000)
height?: number;
@Type(() => Number)
@IsOptional()
@IsInt()
@Min(1)
@Max(86400000)
durationMs?: number;
}
@@ -0,0 +1,35 @@
import { Transform } from "class-transformer";
import { IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from "class-validator";
function normalizeString(value: unknown): unknown {
if (typeof value !== "string") {
return value;
}
return value.trim();
}
export class PresignAttachmentDto {
@Transform(({ value }) => normalizeString(value))
@IsString()
@MinLength(1)
@MaxLength(255)
fileName!: string;
@Transform(({ value }) => normalizeString(value))
@IsString()
@MinLength(1)
@MaxLength(255)
mimeType!: string;
@IsInt()
@Min(1)
@Max(1073741824)
fileSize!: number;
@Transform(({ value }) => normalizeString(value))
@IsOptional()
@IsString()
@MaxLength(255)
taskId?: string;
}