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