From 681e3c7493df7e83c566470f96431b56f54b8482 Mon Sep 17 00:00:00 2001 From: feie9454 Date: Sun, 17 May 2026 18:42:19 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20fileid=E7=B4=A2?= =?UTF-8?q?=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/screenshots/[fileId]/route.ts | 106 +++++++++++++++--- lib/fileStorage.ts | 35 ++++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + 4 files changed, 131 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20260517000000_add_screenshot_file_id_index/migration.sql diff --git a/app/screenshots/[fileId]/route.ts b/app/screenshots/[fileId]/route.ts index a5214d2..f02e4ba 100644 --- a/app/screenshots/[fileId]/route.ts +++ b/app/screenshots/[fileId]/route.ts @@ -1,8 +1,86 @@ import { NextRequest, NextResponse } from 'next/server' -import { getFileByObjectName } from '@/lib/fileStorage' +import { Readable } from 'stream' +import { getFileStreamByObjectName } from '@/lib/fileStorage' import { prisma } from '@/lib/prisma' import { withCors } from '@/lib/middleware' +export const runtime = 'nodejs' + +interface ScreenshotFileMetadata { + objectName: string + filename: string + contentType: string + fileSize: number +} + +const MAX_SCREENSHOT_METADATA_CACHE_SIZE = 5000 +const screenshotMetadataCache = new Map() + +function cacheScreenshotMetadata(fileId: string, metadata: ScreenshotFileMetadata) { + if (screenshotMetadataCache.size >= MAX_SCREENSHOT_METADATA_CACHE_SIZE) { + const oldestFileId = screenshotMetadataCache.keys().next().value + if (oldestFileId) { + screenshotMetadataCache.delete(oldestFileId) + } + } + + screenshotMetadataCache.set(fileId, metadata) +} + +async function findScreenshotMetadata(fileId: string): Promise { + const cached = screenshotMetadataCache.get(fileId) + if (cached) { + return cached + } + + const screenshot = await prisma.screenshot.findFirst({ + where: { fileId }, + select: { + objectName: true, + filename: true, + contentType: true, + fileSize: true + } + }) + + if (screenshot) { + cacheScreenshotMetadata(fileId, screenshot) + } + + return screenshot +} + +function createScreenshotHeaders(fileId: string, screenshot: ScreenshotFileMetadata) { + const headers = new Headers({ + 'Content-Type': resolveScreenshotContentType(screenshot), + 'Cache-Control': 'public, max-age=31536000, immutable', + 'ETag': `"${fileId}"`, + 'X-Content-Type-Options': 'nosniff' + }) + + if (screenshot.fileSize > 0) { + headers.set('Content-Length', String(screenshot.fileSize)) + } + + return headers +} + +function resolveScreenshotContentType(screenshot: ScreenshotFileMetadata) { + const storedContentType = screenshot.contentType?.trim() + const filePath = `${screenshot.filename} ${screenshot.objectName}`.toLowerCase() + + if (storedContentType && storedContentType !== 'image/webp') { + return storedContentType + } + + if (filePath.includes('.avif')) return 'image/avif' + if (filePath.includes('.webp')) return 'image/webp' + if (filePath.includes('.png')) return 'image/png' + if (filePath.includes('.jpg') || filePath.includes('.jpeg')) return 'image/jpeg' + + return storedContentType || 'image/webp' +} + async function handleScreenshotFile(req: NextRequest) { try { const pathSegments = req.nextUrl.pathname.split('/') @@ -13,27 +91,29 @@ async function handleScreenshotFile(req: NextRequest) { return NextResponse.json({ error: '缺少文件ID' }, { status: 400 }) } - // 从数据库查找 objectName - const screenshot = await prisma.screenshot.findFirst({ - where: { fileId }, - select: { objectName: true } - }) + const screenshot = await findScreenshotMetadata(fileId) if (!screenshot) { return NextResponse.json({ error: '截图不存在' }, { status: 404 }) } - const file = await getFileByObjectName(screenshot.objectName) + const headers = createScreenshotHeaders(fileId, screenshot) + if (req.headers.get('if-none-match') === headers.get('ETag')) { + headers.delete('Content-Length') + return new NextResponse(null, { status: 304, headers }) + } + + const file = await getFileStreamByObjectName(screenshot.objectName, { + contentType: resolveScreenshotContentType(screenshot), + filename: screenshot.filename, + size: screenshot.fileSize + }) + if (!file) { return NextResponse.json({ error: '文件不存在' }, { status: 404 }) } - return new NextResponse(file.buffer, { - headers: { - 'Content-Type': file.contentType || 'image/webp', - 'Cache-Control': 'public, max-age=31536000', - } - }) + return new NextResponse(Readable.toWeb(file.stream) as unknown as BodyInit, { headers }) } catch (error) { console.error('获取截图失败:', error) diff --git a/lib/fileStorage.ts b/lib/fileStorage.ts index 236d20b..653a5c8 100644 --- a/lib/fileStorage.ts +++ b/lib/fileStorage.ts @@ -1,5 +1,6 @@ import * as Minio from 'minio' import { randomUUID } from 'crypto' +import { Readable } from 'stream' import { minioClient, BUCKET_NAME, initializeMinIO } from './minioClient' export interface StoredFile { @@ -20,6 +21,19 @@ export interface FileMetadata { objectName: string } +export interface StoredFileStream { + stream: Readable + contentType: string + filename: string + size?: number +} + +export interface StoredFileStreamMetadata { + contentType?: string | null + filename?: string | null + size?: number | null +} + // 生成对象名称,使用分层结构优化性能 function generateObjectName(type: 'screenshot' | 'version' | 'other', hostname?: string): string { const now = new Date() @@ -168,6 +182,27 @@ export async function getFileByObjectName(objectName: string): Promise<{ buffer: } } +export async function getFileStreamByObjectName( + objectName: string, + metadata: StoredFileStreamMetadata = {} +): Promise { + try { + await ensureMinIOReady() + + const stream = await minioClient.getObject(BUCKET_NAME, objectName) + + return { + stream, + contentType: metadata.contentType || 'application/octet-stream', + filename: metadata.filename || objectName.split('/').pop() || 'unknown', + size: metadata.size ?? undefined + } + } catch (error) { + console.error('❌ 获取文件流失败:', error) + return null + } +} + export async function deleteFile(objectName: string): Promise { try { await ensureMinIOReady() diff --git a/prisma/migrations/20260517000000_add_screenshot_file_id_index/migration.sql b/prisma/migrations/20260517000000_add_screenshot_file_id_index/migration.sql new file mode 100644 index 0000000..8b0fea7 --- /dev/null +++ b/prisma/migrations/20260517000000_add_screenshot_file_id_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "screenshots_fileId_idx" ON "screenshots"("fileId"); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c58bc2f..4a9997c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -75,6 +75,7 @@ model Screenshot { record Record @relation(fields: [recordId], references: [id], onDelete: Cascade) @@index([recordId]) + @@index([fileId]) @@index([objectName]) @@map("screenshots") }