import { randomUUID } from "node:crypto"; import { Injectable, InternalServerErrorException, NotFoundException, PayloadTooLargeException } 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 { DataEncryptionService } from "../security/data-encryption.service"; import { CompleteAttachmentDto } from "./dto/complete-attachment.dto"; import { PresignAttachmentDto } from "./dto/presign-attachment.dto"; type QuotaInfo = { totalBytes: bigint; usedBytes: bigint; }; export type PresignAttachmentResponse = { method: "PUT"; uploadUrl: string; bucket: string; objectKey: string; objectUrl: string; expiresInSeconds: number; quota: { totalBytes: string; usedBytes: string; remainingBytes: string; }; headers: Record; }; 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, private readonly dataEncryptionService: DataEncryptionService ) {} async presignAttachment( userId: string, body: PresignAttachmentDto ): Promise { const quotaInfo = await this.getQuotaSnapshot(userId); this.assertQuotaAvailable(quotaInfo.totalBytes, quotaInfo.usedBytes, body.fileSize); if (body.taskId) { await this.ensureTaskOwnership(userId, body.taskId); } const bucket = this.getDefaultBucket(); const objectKey = this.generateObjectKey(body.fileName); const objectUrl = this.resolveObjectUrl(bucket, objectKey); const expiresInSeconds = this.getPresignExpiresInSeconds(); const serverSideEncryption = this.getServerSideEncryptionMode(); const command = new PutObjectCommand({ Bucket: bucket, Key: objectKey, ContentType: body.mimeType, ContentLength: body.fileSize, ServerSideEncryption: serverSideEncryption }); const uploadUrl = await getSignedUrl(this.getS3Client(), command, { expiresIn: expiresInSeconds }); return { method: "PUT", uploadUrl, bucket, objectKey, objectUrl, expiresInSeconds, quota: { totalBytes: quotaInfo.totalBytes.toString(), usedBytes: quotaInfo.usedBytes.toString(), remainingBytes: (quotaInfo.totalBytes - quotaInfo.usedBytes).toString() }, headers: this.buildUploadHeaders(body.mimeType, serverSideEncryption) }; } async completeAttachment( userId: string, body: CompleteAttachmentDto ): Promise { 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.$transaction(async (tx) => { const quotaInfo = await this.getQuotaSnapshot(userId, tx); this.assertQuotaAvailable(quotaInfo.totalBytes, quotaInfo.usedBytes, body.fileSize); const uploadBytes = BigInt(body.fileSize); const maxUsedBeforeUpload = quotaInfo.totalBytes - uploadBytes; const updatedUser = await tx.user.updateMany({ where: { id: userId, usedStorageBytes: { lte: maxUsedBeforeUpload } }, data: { usedStorageBytes: { increment: uploadBytes } } }); if (updatedUser.count === 0) { throw new PayloadTooLargeException("存储配额不足"); } return tx.attachment.create({ data: { userId, taskId: body.taskId ?? null, type: body.type ?? this.resolveAttachmentType(body.mimeType), url: this.encryptRequiredString(objectUrl), mimeType: body.mimeType, fileName: this.encryptNullableString(body.fileName), fileSize: body.fileSize, width: body.width ?? null, height: body.height ?? null, durationMs: body.durationMs ?? null, checksum: this.encryptNullableString(body.checksum) } }); }); return { id: attachment.id, taskId: attachment.taskId, type: attachment.type, url: this.readDecryptedString(attachment.url) ?? objectUrl, mimeType: attachment.mimeType, fileName: this.readDecryptedString(attachment.fileName), fileSize: attachment.fileSize, width: attachment.width, height: attachment.height, durationMs: attachment.durationMs, checksum: this.readDecryptedString(attachment.checksum), createdAt: attachment.createdAt.toISOString(), updatedAt: attachment.updatedAt.toISOString() }; } private getS3Client(): S3Client { if (this.s3Client) { return this.s3Client; } const endpoint = this.configService.get("S3_ENDPOINT") ?? "http://127.0.0.1:9000"; const region = this.configService.get("S3_REGION") ?? "us-east-1"; const forcePathStyle = this.configService.get("S3_FORCE_PATH_STYLE")?.toLowerCase() !== "false"; this.s3Client = new S3Client({ endpoint, region, forcePathStyle, credentials: { accessKeyId: this.configService.get("S3_ACCESS_KEY_ID") ?? "minioadmin", secretAccessKey: this.configService.get("S3_SECRET_ACCESS_KEY") ?? "minioadmin" } }); return this.s3Client; } private getDefaultBucket(): string { return this.configService.get("S3_BUCKET") ?? "todolist"; } private getPresignExpiresInSeconds(): number { const configValue = Number(this.configService.get("S3_PRESIGN_EXPIRES_SECONDS") ?? 900); if (!Number.isFinite(configValue) || configValue <= 0) { return 900; } return Math.min(configValue, 604800); } private generateObjectKey(fileName: string): string { const datePrefix = new Date().toISOString().slice(0, 10); return `attachments/${datePrefix}/${randomUUID()}${this.extractFileExtension(fileName)}`; } private resolveObjectUrl(bucket: string, objectKey: string): string { const publicBaseUrl = this.configService.get("S3_PUBLIC_BASE_URL"); if (publicBaseUrl) { return `${publicBaseUrl.replace(/\/+$/, "")}/${bucket}/${objectKey}`; } const endpoint = this.configService.get("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 buildUploadHeaders( mimeType: string, serverSideEncryption: "AES256" | undefined ): Record { const headers: Record = { "Content-Type": mimeType }; if (serverSideEncryption) { headers["x-amz-server-side-encryption"] = serverSideEncryption; } return headers; } private getServerSideEncryptionMode(): "AES256" | undefined { const configValue = this.configService.get("S3_SERVER_SIDE_ENCRYPTION")?.trim().toUpperCase() ?? "AES256"; if (configValue === "NONE" || configValue === "DISABLED") { return undefined; } return "AES256"; } private extractFileExtension(fileName: string): string { const match = /\.[a-zA-Z0-9]{1,16}$/.exec(fileName); return match?.[0]?.toLowerCase() ?? ""; } private async ensureTaskOwnership(userId: string, taskId: string): Promise { const task = await this.prismaService.task.findFirst({ where: { id: taskId, userId }, select: { id: true } }); if (!task) { throw new NotFoundException("任务不存在"); } } private async getQuotaSnapshot( userId: string, tx: Pick = this.prismaService ): Promise { const user = await tx.user.findUnique({ where: { id: userId }, select: { id: true, defaultStorageQuotaMb: true, usedStorageBytes: true } }); if (!user) { throw new NotFoundException("用户不存在"); } return { totalBytes: BigInt(user.defaultStorageQuotaMb) * 1024n * 1024n, usedBytes: user.usedStorageBytes }; } private assertQuotaAvailable(totalBytes: bigint, usedBytes: bigint, fileSize: number): void { const uploadBytes = BigInt(fileSize); if (uploadBytes > totalBytes || usedBytes + uploadBytes > totalBytes) { throw new PayloadTooLargeException("存储配额不足"); } } private encryptRequiredString(value: string): string { const encryptedValue = this.dataEncryptionService.encryptString(value); if (!encryptedValue) { throw new InternalServerErrorException("附件元数据加密失败"); } return encryptedValue; } private encryptNullableString(value: string | null | undefined): string | null | undefined { return this.dataEncryptionService.encryptString(value); } private readDecryptedString(value: string | null): string | null { const decryptedValue = this.dataEncryptionService.decryptString(value); return typeof decryptedValue === "string" ? decryptedValue : null; } }