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

805 lines
23 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 { randomUUID } from 'crypto'
import { NextRequest, NextResponse } from 'next/server'
import {
getApiKeyFromRequest,
getClientIp,
logApiCall,
validateApiKey,
} from '@/lib/external-api'
import {
completeExternalApiIdempotencyRecord,
ExternalWhiteBackgroundBarcodeError,
failExternalApiIdempotencyRecord,
normalizeExternalBarcode,
stableHashPayload,
STANDARD_MODEL_WHITE_BACKGROUND_BARCODE_OPERATION,
startExternalApiIdempotencyRecord,
} from '@/lib/external-standard-model-white-background-by-barcode'
import { prisma } from '@/lib/prisma'
import {
normalizeWhiteBackgroundSkuName,
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 {
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS,
WhiteBackgroundUploadValidationError,
} from '@/lib/standard-model-white-background-upload-guard'
import {
boundedMultipartRequestLimiter,
getBoundedMultipartFailure,
} from '@/lib/multipart-route-limits'
export const runtime = 'nodejs'
const ENDPOINT = '/api/external/standard-model-white-background/by-spu'
type TraceContext = {
traceId: string
startTime: number
barcode: string
spu: string
skuName: string
ipAddress?: string
apiKeyName?: string
userAgent?: string
}
type ResolvedStandardModelTarget = {
materialId: string
skuId: string
resolvedSpu: string
resolvedSkuName: string
createdMaterial: boolean
createdSku: boolean
barcode: string
barcodeBindingId: string
createdBarcodeBinding: boolean
}
class ExternalWhiteBackgroundBySpuError extends Error {
status: number
code: string
constructor(status: number, code: string, message: string) {
super(message)
this.status = status
this.code = code
}
}
function buildErrorBody(code: string, message: string) {
return {
success: false,
error: {
code,
message,
},
}
}
function buildErrorResponse(status: number, code: string, message: string) {
return NextResponse.json(buildErrorBody(code, message), { status })
}
function buildSuccessBody<T>(data: T) {
return {
success: true,
data,
}
}
function getElapsedMs(startTime: number) {
return Math.max(0, Date.now() - startTime)
}
function finalizeResponse(response: NextResponse, trace: TraceContext) {
const duration = getElapsedMs(trace.startTime)
response.headers.set('X-Trace-Id', trace.traceId)
response.headers.set('X-Server-Duration-Ms', String(duration))
response.headers.set('Server-Timing', `app;dur=${duration}`)
if (trace.spu) {
response.headers.set('X-Request-Spu', trace.spu)
}
if (trace.barcode) {
response.headers.set('X-Request-Barcode', trace.barcode)
}
if (trace.skuName) {
response.headers.set('X-Request-Sku', trace.skuName)
}
return response
}
function isUsableFile(file: File | null | undefined): file is File {
return (
!!file &&
typeof file.name === 'string' &&
typeof file.arrayBuffer === 'function' &&
Number(file.size || 0) > 0
)
}
function getFormText(formData: FormData, fieldName: string) {
return String(formData.get(fieldName) || '').trim()
}
function getFirstFormText(formData: FormData, fieldNames: string[]) {
for (const fieldName of fieldNames) {
const value = getFormText(formData, fieldName)
if (value) {
return value
}
}
return ''
}
function collectFixedImages(formData: FormData) {
return STANDARD_MODEL_WHITE_BG_VIEWS.reduce((acc, view) => {
const file = formData.get(view.fieldName) as File | null
acc[view.key] = isUsableFile(file) ? file : null
return acc
}, {} as Partial<Record<StandardModelWhiteBgKey, File | null>>)
}
function collectOtherImages(formData: FormData) {
return formData
.getAll(STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME)
.filter((file): file is File => isUsableFile(file as File | null))
}
function countFixedImages(images: Partial<Record<StandardModelWhiteBgKey, File | null>>) {
return STANDARD_MODEL_WHITE_BG_VIEWS.filter((view) => isUsableFile(images[view.key])).length
}
function buildUploadRequestHashPayload(params: {
clientRequestId: string
barcode: string
spu: string
skuName: string
sourceType: string
productCategoryName: string
platformName: string
sourceSystem: string
productId: string
skuBatchNo: string
fixedImages: Partial<Record<StandardModelWhiteBgKey, File | null>>
otherImages: File[]
}) {
return {
clientRequestId: params.clientRequestId,
barcode: params.barcode,
spu: params.spu,
skuName: params.skuName,
sourceType: params.sourceType,
productCategoryName: params.productCategoryName,
platformName: params.platformName,
sourceSystem: params.sourceSystem,
productId: params.productId,
skuBatchNo: params.skuBatchNo,
fixed: STANDARD_MODEL_WHITE_BG_VIEWS
.map((view) => {
const file = params.fixedImages[view.key]
return file
? {
key: view.key,
name: file.name,
size: Number(file.size || 0),
type: file.type || '',
}
: null
})
.filter(Boolean),
other: params.otherImages.map((file) => ({
name: file.name,
size: Number(file.size || 0),
type: file.type || '',
})),
}
}
async function writeApiLog(params: {
apiKeyId: string
trace: TraceContext
statusCode: number
}) {
await logApiCall({
apiKeyId: params.apiKeyId,
endpoint: ENDPOINT,
method: 'POST',
queryType: params.trace.barcode ? 'barcode' : 'spu',
queryValue: params.trace.barcode || params.trace.spu || undefined,
statusCode: params.statusCode,
duration: getElapsedMs(params.trace.startTime),
ipAddress: params.trace.ipAddress,
userAgent: params.trace.userAgent,
})
}
async function findDuplicateStandardModelSkuName(skuName: string, excludeMaterialId?: string) {
return prisma.materialSku.findFirst({
where: {
deletedAt: null,
skuName: {
equals: skuName,
mode: 'insensitive',
},
...(excludeMaterialId ? { NOT: { materialId: excludeMaterialId } } : {}),
material: {
is: {
materialType: '标准模型',
deletedAt: null,
},
},
},
select: {
id: true,
materialId: true,
skuName: true,
material: {
select: {
productModel: true,
},
},
},
})
}
async function createStandardModelMaterial(input: {
spu: string
productCategoryName: string
platformName: string
operatorUserId: string
}) {
const operator = await prisma.user.findUnique({
where: { id: input.operatorUserId },
select: { username: true, realName: true },
})
if (!operator) {
throw new ExternalWhiteBackgroundBySpuError(
500,
'OPERATOR_USER_NOT_FOUND',
'API Key 绑定的素材库用户不存在,无法新建标准模型 SPU',
)
}
return prisma.material.create({
data: {
previewUrl: '',
productModel: input.spu,
categoryName: '通用',
sceneName: '通用',
platformName: input.platformName,
productCategoryName: input.productCategoryName,
materialType: '标准模型',
renderer: '',
authorName: operator.realName || operator.username,
userId: input.operatorUserId,
isAiGenerated: true,
},
select: {
id: true,
productModel: true,
categoryName: true,
productCategoryName: true,
},
})
}
async function createStandardModelSku(input: {
materialId: string
skuName: string
}) {
const maxSortOrder = await prisma.materialSku.aggregate({
where: { materialId: input.materialId },
_max: { sortOrder: true },
})
return prisma.materialSku.create({
data: {
materialId: input.materialId,
skuName: input.skuName,
previewUrl: null,
filePath: '',
fileName: null,
fileSize: BigInt(0),
sortOrder: (maxSortOrder._max.sortOrder ?? -1) + 1,
},
select: {
id: true,
skuName: true,
},
})
}
async function ensureProductBarcodeBinding(input: {
barcode: string
skuId: string
}) {
const existing = await prisma.materialSkuBarcode.findFirst({
where: {
barcode: input.barcode,
deletedAt: null,
},
select: {
id: true,
skuId: true,
sku: {
select: {
id: true,
skuName: true,
material: {
select: {
productModel: true,
materialType: true,
},
},
},
},
},
})
if (existing) {
if (existing.skuId !== input.skuId) {
throw new ExternalWhiteBackgroundBySpuError(
409,
'BARCODE_OCCUPIED',
`产品条码 ${input.barcode} 已绑定到标准模型 SPU ${existing.sku.material.productModel} 的 SKU ${existing.sku.skuName}`,
)
}
return {
barcodeBindingId: existing.id,
createdBarcodeBinding: false,
}
}
try {
const binding = await prisma.materialSkuBarcode.create({
data: {
skuId: input.skuId,
barcode: input.barcode,
},
select: {
id: true,
},
})
return {
barcodeBindingId: binding.id,
createdBarcodeBinding: true,
}
} catch (error: any) {
if (error?.code === 'P2002') {
const current = await prisma.materialSkuBarcode.findFirst({
where: {
barcode: input.barcode,
deletedAt: null,
},
select: {
id: true,
skuId: true,
sku: {
select: {
skuName: true,
material: {
select: {
productModel: true,
},
},
},
},
},
})
if (current?.skuId === input.skuId) {
return {
barcodeBindingId: current.id,
createdBarcodeBinding: false,
}
}
throw new ExternalWhiteBackgroundBySpuError(
409,
'BARCODE_OCCUPIED',
current
? `产品条码 ${input.barcode} 已绑定到标准模型 SPU ${current.sku.material.productModel} 的 SKU ${current.sku.skuName}`
: `产品条码 ${input.barcode} 已被其它 SKU 占用`,
)
}
throw error
}
}
async function resolveStandardModelTargetBySpu(input: {
barcode: string
spu: string
skuName: string
productCategoryName: string
platformName: string
operatorUserId: string
}): Promise<ResolvedStandardModelTarget> {
const matches = await prisma.material.findMany({
where: {
productModel: {
equals: input.spu,
mode: 'insensitive',
},
materialType: '标准模型',
deletedAt: null,
},
select: {
id: true,
productModel: true,
categoryName: true,
productCategoryName: true,
skus: {
where: { deletedAt: null },
select: {
id: true,
skuName: true,
},
orderBy: { sortOrder: 'asc' },
},
},
orderBy: { createdAt: 'asc' },
take: 2,
})
if (matches.length > 1) {
throw new ExternalWhiteBackgroundBySpuError(
409,
'SPU_NOT_UNIQUE',
'标准模型内存在多个相同 SPU,无法确定要写入哪一条素材',
)
}
const existingMaterial = matches[0]
if (existingMaterial) {
const existingSku = existingMaterial.skus.find(
(sku) => normalizeWhiteBackgroundSkuName(sku.skuName) === input.skuName,
)
if (existingSku) {
return {
materialId: existingMaterial.id,
skuId: existingSku.id,
resolvedSpu: existingMaterial.productModel,
resolvedSkuName: existingSku.skuName,
createdMaterial: false,
createdSku: false,
barcode: input.barcode,
...(await ensureProductBarcodeBinding({
barcode: input.barcode,
skuId: existingSku.id,
})),
}
}
const duplicateSku = await findDuplicateStandardModelSkuName(input.skuName, existingMaterial.id)
if (duplicateSku) {
throw new ExternalWhiteBackgroundBySpuError(
409,
'SKU_NAME_OCCUPIED',
`SKU 名称 ${input.skuName} 已被标准模型 SPU ${duplicateSku.material.productModel} 占用`,
)
}
const sku = await createStandardModelSku({
materialId: existingMaterial.id,
skuName: input.skuName,
})
return {
materialId: existingMaterial.id,
skuId: sku.id,
resolvedSpu: existingMaterial.productModel,
resolvedSkuName: sku.skuName,
createdMaterial: false,
createdSku: true,
barcode: input.barcode,
...(await ensureProductBarcodeBinding({
barcode: input.barcode,
skuId: sku.id,
})),
}
}
if (!input.productCategoryName) {
throw new ExternalWhiteBackgroundBySpuError(
400,
'PRODUCT_CATEGORY_REQUIRED',
'该 SPU 尚未在标准模型内存在,新建标准模型 SPU 时必须传 productCategoryName',
)
}
const duplicateSku = await findDuplicateStandardModelSkuName(input.skuName)
if (duplicateSku) {
throw new ExternalWhiteBackgroundBySpuError(
409,
'SKU_NAME_OCCUPIED',
`SKU 名称 ${input.skuName} 已被标准模型 SPU ${duplicateSku.material.productModel} 占用`,
)
}
const material = await createStandardModelMaterial({
spu: input.spu,
productCategoryName: input.productCategoryName,
platformName: input.platformName,
operatorUserId: input.operatorUserId,
})
const sku = await createStandardModelSku({
materialId: material.id,
skuName: input.skuName,
})
return {
materialId: material.id,
skuId: sku.id,
resolvedSpu: material.productModel,
resolvedSkuName: sku.skuName,
createdMaterial: true,
createdSku: true,
barcode: input.barcode,
...(await ensureProductBarcodeBinding({
barcode: input.barcode,
skuId: sku.id,
})),
}
}
export async function POST(request: NextRequest) {
const trace: TraceContext = {
traceId: randomUUID(),
startTime: Date.now(),
barcode: '',
spu: '',
skuName: '',
ipAddress: getClientIp(request),
userAgent: request.headers.get('user-agent') || undefined,
}
const apiKey = getApiKeyFromRequest(request)
const keyRecord = await validateApiKey(apiKey)
if (!keyRecord) {
return finalizeResponse(
buildErrorResponse(401, 'INVALID_API_KEY', 'API Key 无效或缺失'),
trace,
)
}
trace.apiKeyName = keyRecord.name
let idempotencyRecordId: string | null = null
let releaseRequestSlot: (() => void) | null = null
try {
const contentLength = Number(request.headers.get('content-length'))
if (!Number.isFinite(contentLength) || contentLength <= 0) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 411 })
return finalizeResponse(
buildErrorResponse(411, 'CONTENT_LENGTH_REQUIRED', '必须提供有效的请求大小'),
trace,
)
}
if (contentLength > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxRequestBytes) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 413 })
return finalizeResponse(
buildErrorResponse(413, 'REQUEST_TOO_LARGE', '白底图上传请求过大'),
trace,
)
}
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire()
const formData = await request.formData()
const clientRequestId = getFormText(formData, 'clientRequestId')
const materialType = getFormText(formData, 'materialType')
const barcode = normalizeExternalBarcode(
getFirstFormText(formData, ['barcode', 'productBarcode', 'product_barcode']),
)
const rawSpu = getFirstFormText(formData, ['spu', 'productModel'])
const normalizedSpu = normalizeWhiteBackgroundSkuName(rawSpu)
const rawSkuName = getFirstFormText(formData, ['skuName', 'sku', 'skuCode'])
const normalizedSkuName = normalizeWhiteBackgroundSkuName(rawSkuName)
const sourceTypeRaw = getFormText(formData, 'sourceType')
const productCategoryName = getFormText(formData, 'productCategoryName')
const platformName = getFormText(formData, 'platformName')
const sourceSystem = getFormText(formData, 'sourceSystem')
const productId = getFormText(formData, 'productId')
const skuBatchNo = getFormText(formData, 'skuBatchNo')
trace.barcode = barcode
trace.spu = normalizedSpu
trace.skuName = normalizedSkuName
if (!clientRequestId) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
return finalizeResponse(
buildErrorResponse(400, 'INVALID_CLIENT_REQUEST_ID', 'clientRequestId 不能为空'),
trace,
)
}
if (materialType && materialType !== '标准模型') {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
return finalizeResponse(
buildErrorResponse(400, 'MATERIAL_TYPE_UNSUPPORTED', '当前接口只支持标准模型白底图录入'),
trace,
)
}
if (!normalizedSpu) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
return finalizeResponse(buildErrorResponse(400, 'INVALID_SPU', 'spu 不能为空'), trace)
}
if (!barcode) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
return finalizeResponse(
buildErrorResponse(400, 'INVALID_BARCODE', 'barcode/productBarcode 不能为空'),
trace,
)
}
if (!normalizedSkuName) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
return finalizeResponse(
buildErrorResponse(400, 'INVALID_SKU', 'skuName 不能为空,白底图必须明确挂到 SPU 下的某个 SKU'),
trace,
)
}
const sourceType = normalizeStandardModelWhiteBackgroundSourceType(sourceTypeRaw)
if (!sourceType) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
return finalizeResponse(
buildErrorResponse(400, 'INVALID_SOURCE_TYPE', 'sourceType 只能是 实拍、渲染、AI'),
trace,
)
}
const fixedImages = collectFixedImages(formData)
const otherImages = collectOtherImages(formData)
if (countFixedImages(fixedImages) === 0 && otherImages.length === 0) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
return finalizeResponse(
buildErrorResponse(400, 'MISSING_REQUIRED_VIEWS', '请至少上传 1 张白底图'),
trace,
)
}
const requestHash = stableHashPayload(
buildUploadRequestHashPayload({
clientRequestId,
barcode,
spu: normalizedSpu,
skuName: normalizedSkuName,
sourceType,
productCategoryName,
platformName,
sourceSystem,
productId,
skuBatchNo,
fixedImages,
otherImages,
}),
)
const idempotency = await startExternalApiIdempotencyRecord({
apiKeyId: keyRecord.id,
operationType: STANDARD_MODEL_WHITE_BACKGROUND_BARCODE_OPERATION.UPLOAD_BY_SPU,
clientRequestId,
requestHash,
})
if (idempotency.mode === 'replay') {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: idempotency.record.responseStatus || 200 })
return finalizeResponse(
NextResponse.json(idempotency.record.responseBody, {
status: idempotency.record.responseStatus || 200,
}),
trace,
)
}
const activeIdempotencyRecordId = String(idempotency.record.id)
idempotencyRecordId = activeIdempotencyRecordId
const resolved = await resolveStandardModelTargetBySpu({
barcode,
spu: normalizedSpu,
skuName: normalizedSkuName,
productCategoryName,
platformName,
operatorUserId: keyRecord.createdBy,
})
const uploadResult = await uploadStandardModelWhiteBackground({
materialId: resolved.materialId,
skuId: resolved.skuId,
skuName: resolved.resolvedSkuName,
sourceType,
operatorUserId: keyRecord.createdBy,
fixedImages,
otherImages,
})
const responseBody = buildSuccessBody({
materialId: resolved.materialId,
skuId: resolved.skuId,
barcode: resolved.barcode,
barcodeBindingId: resolved.barcodeBindingId,
resolvedSpu: resolved.resolvedSpu,
resolvedSkuName: resolved.resolvedSkuName,
createdMaterial: resolved.createdMaterial,
createdSku: resolved.createdSku,
createdBarcodeBinding: resolved.createdBarcodeBinding,
uploadedAt: uploadResult.whiteBackgroundUploadedAt,
traceId: trace.traceId,
whiteBackgroundUploaded: uploadResult.whiteBackgroundUploaded,
uploadedFixedCount: uploadResult.uploadedFixedCount,
fixedCount: uploadResult.fixedCount,
otherCount: uploadResult.otherCount,
whiteBackgroundCount: uploadResult.whiteBackgroundCount,
isComplete: uploadResult.isComplete,
})
await completeExternalApiIdempotencyRecord({
recordId: activeIdempotencyRecordId,
responseStatus: 200,
responseBody,
})
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 200 })
return finalizeResponse(NextResponse.json(responseBody), trace)
} catch (error) {
if (idempotencyRecordId) {
await failExternalApiIdempotencyRecord(idempotencyRecordId)
}
const boundedFailure = getBoundedMultipartFailure(error)
if (boundedFailure) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: boundedFailure.status })
const response = buildErrorResponse(
boundedFailure.status,
boundedFailure.code,
boundedFailure.message,
)
if (boundedFailure.retryAfterSeconds) {
response.headers.set('Retry-After', String(boundedFailure.retryAfterSeconds))
}
return finalizeResponse(response, trace)
}
if (
error instanceof ExternalWhiteBackgroundBySpuError ||
error instanceof ExternalWhiteBackgroundBarcodeError
) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: error.status })
return finalizeResponse(buildErrorResponse(error.status, error.code, error.message), trace)
}
if (error instanceof WhiteBackgroundUploadValidationError) {
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
return finalizeResponse(
buildErrorResponse(400, 'INVALID_IMAGE_UPLOAD', error.message),
trace,
)
}
const message = error instanceof Error ? error.message : '上传白底图失败'
console.error('[external/standard-model-white-background/by-spu] POST error:', error)
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 500 })
return finalizeResponse(buildErrorResponse(500, 'UPLOAD_FAILED', message || '上传白底图失败'), trace)
} finally {
releaseRequestSlot?.()
}
}