chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

123 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import { existsSync } from 'fs'
import { sanitizeFileName } from '@/lib/path-security'
import { canManageSkuMaterial } from '../authorization'
import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard'
import {
boundedMultipartRequestLimiter,
getBoundedMultipartFailure,
MULTIPART_ROUTE_LIMITS,
} from '@/lib/multipart-route-limits'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const maxDuration = 10800
// SKU文件上传API
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
let releaseRequestSlot: (() => void) | null = null
try {
const session = await getServerSession(authOptions)
if (!session?.user || !(session.user as any).id) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const materialId = params.id
// 检查素材是否存在且是标准模型类型
const material = await prisma.material.findUnique({
where: { id: materialId, deletedAt: null },
select: { id: true, materialType: true, userId: true }
})
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
if (material.materialType !== '标准模型') {
return NextResponse.json({ error: '只有标准模型类型才能上传 SKU' }, { status: 400 })
}
if (!(await canManageSkuMaterial(session.user as any, material))) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 })
}
assertBoundedMultipartHeaders(request.headers, MULTIPART_ROUTE_LIMITS.skuArchiveRequestBytes)
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire()
const formData = await request.formData()
assertMultipartFiles(formData, {
fields: {
file: {
maxCount: 1,
maxFileBytes: MULTIPART_ROUTE_LIMITS.skuArchiveFileBytes,
},
},
maxFiles: 1,
maxTotalBytes: MULTIPART_ROUTE_LIMITS.skuArchiveFileBytes,
})
const skuName = formData.get('skuName') as string
const file = formData.get('file') as File
if (!skuName || !file) {
return NextResponse.json({ error: '缺少必填字段' }, { status: 400 })
}
const archiveExtension = path.extname(path.basename(file.name)).toLowerCase()
if (!['.zip', '.rar', '.7z'].includes(archiveExtension)) {
return NextResponse.json({ error: '模型压缩包只支持 ZIP、RAR 或 7Z 格式' }, { status: 415 })
}
// 创建上传目录
const uploadDir = path.join(process.cwd(), 'uploads', 'skus', materialId)
if (!existsSync(uploadDir)) {
await mkdir(uploadDir, { recursive: true })
}
// 生成文件名
const timestamp = Date.now()
const ext = path.extname(path.basename(file.name))
const fileName = `${sanitizeFileName(skuName)}_${timestamp}${ext}`
const filePath = path.join(uploadDir, fileName)
// 保存文件
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
await writeFile(filePath, buffer)
// 相对路径(用于数据库存储)
const relativePath = path.join('skus', materialId, fileName).replace(/\\/g, '/') // 统一使用正斜杠
// 只返回文件信息,不创建SKU记录(SKU记录由 /api/materials/[id]/skus POST 接口创建)
return NextResponse.json({
fileName: file.name,
filePath: relativePath,
fileSize: file.size
}, { status: 200 })
} catch (error) {
console.error('上传 SKU 失败:', error)
const boundedFailure = getBoundedMultipartFailure(error)
if (boundedFailure) {
return NextResponse.json(
{ error: boundedFailure.message, code: boundedFailure.code },
{
status: boundedFailure.status,
...(boundedFailure.retryAfterSeconds
? { headers: { 'Retry-After': String(boundedFailure.retryAfterSeconds) } }
: {}),
},
)
}
return NextResponse.json({ error: '上传 SKU 失败' }, { status: 500 })
} finally {
releaseRequestSlot?.()
}
}