Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
123 lines
4.3 KiB
TypeScript
123 lines
4.3 KiB
TypeScript
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?.()
|
||
}
|
||
}
|