fix: 修复 fileid索引

This commit is contained in:
feie9454 2026-05-17 18:42:19 +08:00
parent fea99d37fe
commit 681e3c7493
4 changed files with 131 additions and 13 deletions

View File

@ -1,8 +1,86 @@
import { NextRequest, NextResponse } from 'next/server' 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 { prisma } from '@/lib/prisma'
import { withCors } from '@/lib/middleware' 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<string, ScreenshotFileMetadata>()
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<ScreenshotFileMetadata | null> {
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) { async function handleScreenshotFile(req: NextRequest) {
try { try {
const pathSegments = req.nextUrl.pathname.split('/') const pathSegments = req.nextUrl.pathname.split('/')
@ -13,27 +91,29 @@ async function handleScreenshotFile(req: NextRequest) {
return NextResponse.json({ error: '缺少文件ID' }, { status: 400 }) return NextResponse.json({ error: '缺少文件ID' }, { status: 400 })
} }
// 从数据库查找 objectName const screenshot = await findScreenshotMetadata(fileId)
const screenshot = await prisma.screenshot.findFirst({
where: { fileId },
select: { objectName: true }
})
if (!screenshot) { if (!screenshot) {
return NextResponse.json({ error: '截图不存在' }, { status: 404 }) 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) { if (!file) {
return NextResponse.json({ error: '文件不存在' }, { status: 404 }) return NextResponse.json({ error: '文件不存在' }, { status: 404 })
} }
return new NextResponse(file.buffer, { return new NextResponse(Readable.toWeb(file.stream) as unknown as BodyInit, { headers })
headers: {
'Content-Type': file.contentType || 'image/webp',
'Cache-Control': 'public, max-age=31536000',
}
})
} catch (error) { } catch (error) {
console.error('获取截图失败:', error) console.error('获取截图失败:', error)

View File

@ -1,5 +1,6 @@
import * as Minio from 'minio' import * as Minio from 'minio'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { Readable } from 'stream'
import { minioClient, BUCKET_NAME, initializeMinIO } from './minioClient' import { minioClient, BUCKET_NAME, initializeMinIO } from './minioClient'
export interface StoredFile { export interface StoredFile {
@ -20,6 +21,19 @@ export interface FileMetadata {
objectName: string 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 { function generateObjectName(type: 'screenshot' | 'version' | 'other', hostname?: string): string {
const now = new Date() 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<StoredFileStream | null> {
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<boolean> { export async function deleteFile(objectName: string): Promise<boolean> {
try { try {
await ensureMinIOReady() await ensureMinIOReady()

View File

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "screenshots_fileId_idx" ON "screenshots"("fileId");

View File

@ -75,6 +75,7 @@ model Screenshot {
record Record @relation(fields: [recordId], references: [id], onDelete: Cascade) record Record @relation(fields: [recordId], references: [id], onDelete: Cascade)
@@index([recordId]) @@index([recordId])
@@index([fileId])
@@index([objectName]) @@index([objectName])
@@map("screenshots") @@map("screenshots")
} }