feat(api-attachment): add minio presigned upload flow
This commit is contained in:
@@ -17,3 +17,11 @@ OAUTH_WECHAT_CLIENT_SECRET="wechat-client-secret"
|
|||||||
OAUTH_WECHAT_CALLBACK_URL="http://localhost:3000/auth/oauth/wechat/callback"
|
OAUTH_WECHAT_CALLBACK_URL="http://localhost:3000/auth/oauth/wechat/callback"
|
||||||
OAUTH_WECHAT_AUTH_URL="https://open.weixin.qq.com/connect/qrconnect"
|
OAUTH_WECHAT_AUTH_URL="https://open.weixin.qq.com/connect/qrconnect"
|
||||||
OAUTH_WECHAT_TOKEN_URL="https://api.weixin.qq.com/sns/oauth2/access_token"
|
OAUTH_WECHAT_TOKEN_URL="https://api.weixin.qq.com/sns/oauth2/access_token"
|
||||||
|
S3_ENDPOINT="http://127.0.0.1:9000"
|
||||||
|
S3_REGION="us-east-1"
|
||||||
|
S3_BUCKET="todolist"
|
||||||
|
S3_ACCESS_KEY_ID="minioadmin"
|
||||||
|
S3_SECRET_ACCESS_KEY="minioadmin"
|
||||||
|
S3_FORCE_PATH_STYLE="true"
|
||||||
|
S3_PRESIGN_EXPIRES_SECONDS="900"
|
||||||
|
S3_PUBLIC_BASE_URL="http://127.0.0.1:9000"
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.1024.0",
|
||||||
|
"@aws-sdk/s3-request-presigner": "^3.1024.0",
|
||||||
"@nestjs/common": "^11.1.18",
|
"@nestjs/common": "^11.1.18",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.1.18",
|
"@nestjs/core": "^11.1.18",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { ConfigModule } from "@nestjs/config";
|
import { ConfigModule } from "@nestjs/config";
|
||||||
|
import { AttachmentModule } from "./attachment/attachment.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { PrismaModule } from "./prisma/prisma.module";
|
import { PrismaModule } from "./prisma/prisma.module";
|
||||||
import { TaskModule } from "./task/task.module";
|
import { TaskModule } from "./task/task.module";
|
||||||
@@ -12,7 +13,8 @@ import { TaskModule } from "./task/task.module";
|
|||||||
}),
|
}),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
TaskModule
|
TaskModule,
|
||||||
|
AttachmentModule
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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;
|
||||||
|
}
|
||||||
Generated
+1512
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user