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