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

178 lines
5.9 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import {
STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME,
STANDARD_MODEL_WHITE_BG_VIEWS,
type StandardModelWhiteBgKey,
} from '@/lib/standard-model-white-background'
import { normalizeStandardModelWhiteBackgroundSourceType } from '@/lib/standard-model-white-background-metadata'
import { uploadStandardModelWhiteBackground } from '@/lib/standard-model-white-background-service'
import {
assertWhiteBackgroundUploadFiles,
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS,
WhiteBackgroundUploadValidationError,
} from '@/lib/standard-model-white-background-upload-guard'
import {
canManageMaterial,
type MaterialAuthorizationUser,
} from '@/lib/material-authorization'
import {
boundedMultipartRequestLimiter,
getBoundedMultipartFailure,
} from '@/lib/multipart-route-limits'
export const runtime = 'nodejs'
const MAX_WHITE_BACKGROUND_REQUEST_BYTES =
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxTotalBytes + 5 * 1024 * 1024
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 userId = (session.user as any).id
const material = await prisma.material.findUnique({
where: { id: materialId, deletedAt: null },
select: {
id: true,
materialType: true,
userId: true,
productCategoryName: true,
categoryName: true,
},
})
if (!material) {
return NextResponse.json({ error: '素材不存在' }, { status: 404 })
}
if (material.materialType !== '标准模型') {
return NextResponse.json({ error: '只有标准模型类型支持上传白底图' }, { status: 400 })
}
if (!(await canManageMaterial(session.user as MaterialAuthorizationUser, material))) {
return NextResponse.json({ error: '无权限操作此素材' }, { status: 403 })
}
const contentLength = Number(request.headers.get('content-length'))
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return NextResponse.json({ error: '缺少有效的请求大小' }, { status: 411 })
}
if (contentLength > MAX_WHITE_BACKGROUND_REQUEST_BYTES) {
return NextResponse.json({ error: '白底图上传请求过大' }, { status: 413 })
}
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire()
const formData = await request.formData()
const skuId = ((formData.get('skuId') as string) || '').trim()
const skuName = ((formData.get('skuName') as string) || '').trim()
const sourceType = normalizeStandardModelWhiteBackgroundSourceType(
(formData.get('sourceType') as string) || '',
)
if (!skuId) {
return NextResponse.json({ error: '缺少SKU ID' }, { status: 400 })
}
if (!sourceType) {
return NextResponse.json({ error: '白底图出图方式必填,且只能是 实拍、渲染、AI' }, { status: 400 })
}
const sku = await prisma.materialSku.findFirst({
where: {
id: skuId,
materialId,
deletedAt: null,
},
select: { id: true, skuName: true },
})
if (!sku) {
return NextResponse.json({ error: 'SKU不存在或已被删除' }, { status: 404 })
}
for (const view of STANDARD_MODEL_WHITE_BG_VIEWS) {
const entries = formData.getAll(view.fieldName)
if (entries.length > 1) {
return NextResponse.json(
{ error: `${view.label}只能上传 1 张图片` },
{ status: 400 },
)
}
const entry = entries[0]
if (entry !== undefined && typeof entry === 'string') {
return NextResponse.json(
{ error: `${view.label}不是有效图片文件` },
{ status: 400 },
)
}
}
const whiteBackgroundImages = STANDARD_MODEL_WHITE_BG_VIEWS.reduce((acc, view) => {
const entry = formData.get(view.fieldName)
acc[view.key] = (entry as File | undefined) || null
return acc
}, {} as Partial<Record<StandardModelWhiteBgKey, File | null>>)
const otherImageEntries = formData.getAll(STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME)
if (otherImageEntries.some((entry) => typeof entry === 'string')) {
return NextResponse.json({ error: '其他图片包含无效文件' }, { status: 400 })
}
const otherImages = otherImageEntries as File[]
try {
await assertWhiteBackgroundUploadFiles({
fixedImages: whiteBackgroundImages,
otherImages,
})
} catch (error) {
if (error instanceof WhiteBackgroundUploadValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 })
}
throw error
}
const result = await uploadStandardModelWhiteBackground({
materialId,
skuId,
skuName: skuName || sku.skuName,
sourceType,
operatorUserId: userId,
fixedImages: whiteBackgroundImages,
otherImages,
})
return NextResponse.json({
success: true,
...result,
})
} catch (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) } }
: {}),
},
)
}
console.error('上传白底图失败:', error)
return NextResponse.json({ error: '上传白底图失败' }, { status: 500 })
} finally {
releaseRequestSlot?.()
}
}