feat: add product design white background import
This commit is contained in:
parent
8fd571b823
commit
01e4a08572
754
app/api/external/standard-model-white-background/by-spu/route.ts
vendored
Normal file
754
app/api/external/standard-model-white-background/by-spu/route.ts
vendored
Normal file
@ -0,0 +1,754 @@
|
||||
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'
|
||||
|
||||
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
|
||||
|
||||
try {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -1,222 +1,119 @@
|
||||
# AI 生图平台成品回传接口
|
||||
# 产品设计平台成品图回传接口
|
||||
|
||||
更新时间:2026-03-24
|
||||
更新时间:2026-06-15
|
||||
|
||||
## 目标
|
||||
## 当前口径
|
||||
|
||||
这组接口用于让 AI 生图平台把最终确认后的成品图回传到素材库。
|
||||
当前产品设计平台回传到素材库的接口,只处理「标准模型」素材类型下的「白底图」录入。
|
||||
|
||||
首版只开放「任务-详情」,但接口结构按后续多类型扩展设计。
|
||||
注意:SPU、SKU、产品条码是三个不同概念,不能混用。SPU 是产品款,SKU 是 SPU 下的具体子项;产品条码是后续素材库逐步收敛的唯一业务定位值,应唯一绑定到一个 SKU;白底图在素材库当前结构中挂在 SKU 上,所以产品平台必须同时提供 SPU、SKU 和产品条码。
|
||||
|
||||
后续如果要增加「任务-详情」「主图」「详情图」或其它素材类型,需要另开扩展接口或启用通用素材导入接口,不和本次标准模型白底图入口混在一起。
|
||||
|
||||
## 鉴权
|
||||
|
||||
- 请求头:`X-API-Key`
|
||||
- 所有接口都需要有效的 API Key
|
||||
- 产品设计平台使用独立 API Key:`产品设计平台素材回传`
|
||||
- API Key:`8b283411c67895dec884f6c18d1f8c4e`
|
||||
|
||||
## 1. 获取表单规则
|
||||
|
||||
- 方法:`GET`
|
||||
- 路径:`/api/external/material-import/schema`
|
||||
|
||||
### 查询参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `materialType` | 是 | 首版传 `任务-详情` |
|
||||
|
||||
### 返回说明
|
||||
|
||||
返回当前真实表单规则,包括:
|
||||
|
||||
- 当前素材类型是否开放
|
||||
- 要展示哪些字段
|
||||
- 每个字段是否必填
|
||||
- 哪些字段是下拉框
|
||||
- 每个下拉框当前有哪些可选项
|
||||
- 当前允许的图片分组
|
||||
|
||||
### 首版字段规则
|
||||
|
||||
当前 `任务-详情` 返回字段如下:
|
||||
|
||||
| 字段 | 含义 | 必填 |
|
||||
| --- | --- | --- |
|
||||
| `taskNo` | 任务号 | 是 |
|
||||
| `productModel` | SPU | 是 |
|
||||
| `categoryName` | 店铺 | 是 |
|
||||
| `sceneName` | 场景 | 是 |
|
||||
| `platformName` | 平台 | 否 |
|
||||
| `productCategoryName` | 品类 | 否 |
|
||||
|
||||
说明:
|
||||
|
||||
- 店铺、场景、平台、品类的可选项全部来自素材库当前数据
|
||||
- 下拉项里没有的值,首版不允许手输
|
||||
|
||||
## 2. 创建导入任务
|
||||
## 标准模型白底图回传
|
||||
|
||||
- 方法:`POST`
|
||||
- 路径:`/api/external/material-import/jobs`
|
||||
- `Content-Type`:`application/json`
|
||||
- 路径:`/api/external/standard-model-white-background/by-spu`
|
||||
- `Content-Type`:`multipart/form-data`
|
||||
|
||||
### 请求体
|
||||
### 字段
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `clientRequestId` | 是 | 请求唯一号,用于防重复提交 |
|
||||
| `materialType` | 是 | 首版固定传 `任务-详情` |
|
||||
| `requester.dingtalkUserId` | 是 | 钉钉唯一标识,用于反推素材归属用户 |
|
||||
| `formData.taskNo` | 是 | 任务号 |
|
||||
| `formData.productModel` | 是 | SPU |
|
||||
| `formData.categoryName` | 是 | 店铺 |
|
||||
| `formData.sceneName` | 是 | 场景 |
|
||||
| `formData.platformName` | 否 | 平台 |
|
||||
| `formData.productCategoryName` | 否 | 品类 |
|
||||
| `sections` | 是 | 图片分组列表 |
|
||||
| `submissionSnapshot` | 否 | 原样提交快照;不传时服务端会保存整份请求体 |
|
||||
| `clientRequestId` | 是 | 产品平台生成的请求唯一号;同一批图片重试必须保持不变 |
|
||||
| `barcode` / `productBarcode` | 是 | 产品条码,素材库后续会逐步以它作为唯一业务定位值;同一有效条码只能绑定一个 SKU |
|
||||
| `spu` / `productModel` | 是 | SPU,素材库用于定位或新建标准模型素材 |
|
||||
| `skuName` / `sku` / `skuCode` | 是 | SKU,素材库用于定位或新建该 SPU 下的 SKU;不能用 SPU 代替 |
|
||||
| `sourceType` | 是 | 图片来源,只能传 `实拍`、`渲染`、`AI` |
|
||||
| `productCategoryName` | 条件必填 | 当 SPU 在标准模型内不存在、需要新建 SPU 时必填 |
|
||||
| `materialType` | 否 | 如传,只能传 `标准模型` |
|
||||
| `platformName` | 否 | 来源平台或业务平台备注 |
|
||||
| `sourceSystem` | 否 | 来源系统标识,建议传 `product-design-platform` |
|
||||
| `productId` | 否 | 产品平台内部产品 ID,用于排查追踪 |
|
||||
| `skuBatchNo` | 否 | 产品平台批次号,用于排查追踪 |
|
||||
|
||||
### 图片分组规则
|
||||
### 图片字段
|
||||
|
||||
- 首版只允许 `main`、`sku`、`detail` 三种分组
|
||||
- 三种分组都不是必传
|
||||
- 全部图片总数至少要有 1 张
|
||||
- 每张图片必须带:
|
||||
- `url`
|
||||
- `fileName`
|
||||
- `fileSize`
|
||||
- `sortOrder`
|
||||
- 详情图顺序严格按 `sortOrder` 入库
|
||||
至少上传 1 张白底图。固定视角字段如下,按已有图片传即可,不要求一次传满 8 张。
|
||||
|
||||
### 请求示例
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
| `whiteBgLeftFront45` | 左前 45 度 |
|
||||
| `whiteBgRightFront45` | 右前 45 度 |
|
||||
| `whiteBgLeftBack45` | 左后 45 度 |
|
||||
| `whiteBgRightBack45` | 右后 45 度 |
|
||||
| `whiteBgFront` | 正面 |
|
||||
| `whiteBgBack` | 背面 |
|
||||
| `whiteBgLeft` | 左侧 |
|
||||
| `whiteBgRight` | 右侧 |
|
||||
| `whiteBgOtherImages` | 其它补充图,可重复传多个文件 |
|
||||
|
||||
```json
|
||||
{
|
||||
"clientRequestId": "req-20260324-0001",
|
||||
"materialType": "任务-详情",
|
||||
"requester": {
|
||||
"dingtalkUserId": "ding-user-001"
|
||||
},
|
||||
"formData": {
|
||||
"taskNo": "TASK202603240001",
|
||||
"productModel": "SPU-001",
|
||||
"categoryName": "旗舰店",
|
||||
"sceneName": "客厅",
|
||||
"platformName": "天猫",
|
||||
"productCategoryName": "沙发"
|
||||
},
|
||||
"sections": [
|
||||
{
|
||||
"key": "main",
|
||||
"images": [
|
||||
{
|
||||
"url": "https://img.example.com/main-1.jpg",
|
||||
"fileName": "main-1.jpg",
|
||||
"fileSize": 123456,
|
||||
"sortOrder": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "detail",
|
||||
"images": [
|
||||
{
|
||||
"url": "https://img.example.com/detail-1.jpg",
|
||||
"fileName": "detail-1.jpg",
|
||||
"fileSize": 234567,
|
||||
"sortOrder": 10
|
||||
},
|
||||
{
|
||||
"url": "https://img.example.com/detail-2.jpg",
|
||||
"fileName": "detail-2.jpg",
|
||||
"fileSize": 345678,
|
||||
"sortOrder": 20
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
## 入库规则
|
||||
|
||||
### 处理规则
|
||||
1. 素材库先在「标准模型」内按 `spu/productModel` 找 SPU。
|
||||
2. 如果 SPU 已存在,就只在这个已有 SPU 下处理 SKU。
|
||||
3. 如果该 SPU 下已有传入的 `skuName/sku/skuCode`,白底图录入这个已有 SKU。
|
||||
4. 如果该 SPU 下没有这个 SKU,则在该 SPU 下新建 SKU,再录入白底图。
|
||||
5. 如果 SPU 不存在,则要求产品平台传 `productCategoryName`;素材库会新建一条「标准模型」SPU,再在其下新建传入的 SKU,然后录入白底图。
|
||||
6. 如果标准模型内存在多个相同 SPU,接口返回冲突,不会猜测写入哪一条。
|
||||
7. 如果传入 SKU 名已经被其它标准模型 SPU 占用,接口返回冲突,不会跨 SPU 复用或覆盖。
|
||||
8. 如果传入产品条码已绑定到其它 SKU,接口返回冲突,不会把同一个条码写到多个 SKU。
|
||||
9. 如果传入产品条码还没有绑定,接口会把它绑定到本次最终确定的 SKU,方便后续逐步切到按条码唯一定位。
|
||||
10. `clientRequestId` 做幂等:同一个请求号、同一批内容重复提交会返回同一结果;同一个请求号换了内容会返回冲突。
|
||||
|
||||
服务端按以下顺序前置校验:
|
||||
|
||||
1. API Key
|
||||
2. 素材类型是否开放
|
||||
3. 钉钉身份是否能映射到真实素材库用户
|
||||
4. 表单值是否合法
|
||||
5. `taskNo` 是否已被进行中或成功任务占用
|
||||
6. 图片分组是否合法
|
||||
|
||||
只要前置校验失败,就直接返回失败,不创建导入任务。
|
||||
|
||||
### 成功返回
|
||||
## 成功返回
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "导入任务已创建",
|
||||
"data": {
|
||||
"created": true,
|
||||
"jobId": "job-uuid",
|
||||
"materialType": "任务-详情",
|
||||
"taskNo": "TASK202603240001",
|
||||
"status": "processing",
|
||||
"success": false,
|
||||
"resultMaterialId": null,
|
||||
"errorCode": null,
|
||||
"errorMessage": null,
|
||||
"completedAt": null
|
||||
"materialId": "material-uuid",
|
||||
"skuId": "sku-uuid",
|
||||
"barcode": "690000000001",
|
||||
"barcodeBindingId": "barcode-binding-uuid",
|
||||
"resolvedSpu": "SPU001",
|
||||
"resolvedSkuName": "SKU001",
|
||||
"createdMaterial": false,
|
||||
"createdSku": false,
|
||||
"createdBarcodeBinding": true,
|
||||
"uploadedAt": "2026-06-15T08:00:00.000Z",
|
||||
"traceId": "trace-uuid",
|
||||
"whiteBackgroundUploaded": true,
|
||||
"uploadedFixedCount": 1,
|
||||
"fixedCount": 1,
|
||||
"otherCount": 0,
|
||||
"whiteBackgroundCount": 1,
|
||||
"isComplete": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 幂等返回
|
||||
`createdMaterial` 表示本次是否新建了标准模型 SPU;`createdSku` 表示本次是否新建了 SKU。
|
||||
|
||||
如果同一个 `clientRequestId` 重复提交,不会重复导入,会直接返回已有任务结果。
|
||||
|
||||
## 3. 查询导入状态
|
||||
|
||||
- 方法:`GET`
|
||||
- 路径:`/api/external/material-import/jobs/{jobId}`
|
||||
|
||||
### 返回字段
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `jobId` | 导入任务 ID |
|
||||
| `materialType` | 素材类型 |
|
||||
| `taskNo` | 任务号 |
|
||||
| `status` | `processing` / `succeeded` / `failed` |
|
||||
| `success` | 是否导入成功 |
|
||||
| `resultMaterialId` | 成功后的素材库记录 ID |
|
||||
| `errorCode` | 失败错误码 |
|
||||
| `errorMessage` | 失败原因 |
|
||||
| `completedAt` | 完成时间 |
|
||||
|
||||
### 平台侧约定
|
||||
|
||||
- 只有 `success = true` 且 `status = succeeded`,才能显示「已完成」
|
||||
- `processing` 只能显示「导入中」
|
||||
- `failed` 需要提示失败原因,并允许用户修正后重新发起新请求
|
||||
|
||||
## 错误码
|
||||
## 常见错误码
|
||||
|
||||
| 错误码 | 含义 |
|
||||
| --- | --- |
|
||||
| `INVALID_API_KEY` | API Key 无效或缺失 |
|
||||
| `MATERIAL_TYPE_NOT_OPEN` | 当前素材类型未开放 |
|
||||
| `USER_NOT_FOUND` | 无法根据钉钉身份映射到素材库用户 |
|
||||
| `FORM_VALUE_INVALID` | 表单值不合法,或不在当前下拉项内 |
|
||||
| `INVALID_TASK_NO` | 任务号格式错误 |
|
||||
| `TASK_NO_OCCUPIED` | 任务号已被进行中或成功任务占用 |
|
||||
| `IMAGE_GROUP_INVALID` | 图片分组不合法或总图片数为 0 |
|
||||
| `JOB_NOT_FOUND` | 导入任务不存在 |
|
||||
| `IMPORT_FAILED` | 导入执行失败 |
|
||||
| `INVALID_CLIENT_REQUEST_ID` | `clientRequestId` 为空 |
|
||||
| `MATERIAL_TYPE_UNSUPPORTED` | 当前接口只支持标准模型白底图 |
|
||||
| `INVALID_BARCODE` | 产品条码为空 |
|
||||
| `INVALID_SPU` | SPU 为空或格式化后为空 |
|
||||
| `INVALID_SKU` | SKU 为空;白底图不能在没有 SKU 的情况下录入 |
|
||||
| `INVALID_SOURCE_TYPE` | `sourceType` 不是 `实拍`、`渲染`、`AI` |
|
||||
| `MISSING_REQUIRED_VIEWS` | 没有上传任何有效白底图 |
|
||||
| `PRODUCT_CATEGORY_REQUIRED` | 新建标准模型 SPU 时缺少 `productCategoryName` |
|
||||
| `SPU_NOT_UNIQUE` | 标准模型内存在多个相同 SPU |
|
||||
| `SKU_NAME_OCCUPIED` | 传入 SKU 已被其它标准模型 SPU 占用 |
|
||||
| `BARCODE_OCCUPIED` | 产品条码已绑定到其它 SKU |
|
||||
| `IDEMPOTENCY_CONFLICT` | 同一个 `clientRequestId` 被用于不同请求内容 |
|
||||
|
||||
## 当前实现说明
|
||||
## 给产品设计平台的对接要求
|
||||
|
||||
- 首版只支持 `任务-详情`
|
||||
- 导入成功后会新建一条新的素材记录,不覆盖原有素材
|
||||
- 同一 `taskNo` 只允许一份最终成功结果
|
||||
- 某次导入失败后,允许继续使用同一个 `taskNo` 重新提交
|
||||
产品平台请对接 `POST /api/external/standard-model-white-background/by-spu`,请求头传 `X-API-Key: 8b283411c67895dec884f6c18d1f8c4e`;回传时必须明确传 `spu/productModel`、`skuName/sku/skuCode` 和 `barcode/productBarcode`,不要把 SPU、SKU、产品条码混用;SPU 是产品款,SKU 是 SPU 下的具体子项,产品条码是后续素材库逐步收敛的唯一业务定位值,应该唯一绑定到一个 SKU;如果素材库标准模型里已有这个 SPU,就在已有 SPU 下找对应 SKU,有则录入,没有则新建 SKU 后录入,并把产品条码绑定到这个 SKU;如果素材库没有这个 SPU,就必须同时传 `productCategoryName`,素材库会先新建标准模型 SPU,再新建 SKU、绑定产品条码并录入白底图。本次接口只支持标准模型白底图,至少传 1 张白底图文件,`sourceType` 只能是 `实拍`、`渲染`、`AI`,每次提交要带稳定唯一的 `clientRequestId` 用于重试幂等。
|
||||
|
||||
@ -0,0 +1,347 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { POST } from '@/app/api/external/standard-model-white-background/by-spu/route'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { logApiCall, validateApiKey } from '@/lib/external-api'
|
||||
import {
|
||||
completeExternalApiIdempotencyRecord,
|
||||
startExternalApiIdempotencyRecord,
|
||||
} from '@/lib/external-standard-model-white-background-by-barcode'
|
||||
import { uploadStandardModelWhiteBackground } from '@/lib/standard-model-white-background-service'
|
||||
|
||||
jest.mock('@/lib/external-api', () => ({
|
||||
getApiKeyFromRequest: jest.fn((request: { headers: Headers }) => request.headers.get('X-API-Key')),
|
||||
getClientIp: jest.fn(() => '127.0.0.1'),
|
||||
logApiCall: jest.fn().mockResolvedValue(undefined),
|
||||
validateApiKey: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/external-standard-model-white-background-by-barcode', () => {
|
||||
const actual = jest.requireActual('@/lib/external-standard-model-white-background-by-barcode')
|
||||
return {
|
||||
...actual,
|
||||
completeExternalApiIdempotencyRecord: jest.fn().mockResolvedValue(undefined),
|
||||
failExternalApiIdempotencyRecord: jest.fn().mockResolvedValue(undefined),
|
||||
startExternalApiIdempotencyRecord: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
materialSku: {
|
||||
aggregate: jest.fn(),
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
materialSkuBarcode: {
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/standard-model-white-background-service', () => ({
|
||||
uploadStandardModelWhiteBackground: jest.fn(),
|
||||
}))
|
||||
|
||||
describe('external standard-model-white-background by spu route', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(validateApiKey as jest.Mock).mockResolvedValue({
|
||||
id: 'api-1',
|
||||
name: 'product-design',
|
||||
createdBy: 'user-1',
|
||||
isActive: true,
|
||||
})
|
||||
;(startExternalApiIdempotencyRecord as jest.Mock).mockResolvedValue({
|
||||
mode: 'created',
|
||||
record: { id: 'idem-1' },
|
||||
})
|
||||
;(uploadStandardModelWhiteBackground as jest.Mock).mockResolvedValue({
|
||||
whiteBackgroundUploaded: true,
|
||||
uploadedFixedCount: 1,
|
||||
fixedCount: 1,
|
||||
otherCount: 0,
|
||||
whiteBackgroundCount: 1,
|
||||
isComplete: false,
|
||||
sourceType: 'AI',
|
||||
whiteBackgroundUploadedBy: 'user-1',
|
||||
whiteBackgroundUploadedAt: '2026-06-15T08:00:00.000Z',
|
||||
downloadFileName: 'SKU001_白底图.zip',
|
||||
})
|
||||
;(prisma.materialSku.findFirst as jest.Mock).mockResolvedValue(null)
|
||||
;(prisma.materialSkuBarcode.findFirst as jest.Mock).mockResolvedValue(null)
|
||||
;(prisma.materialSkuBarcode.create as jest.Mock).mockResolvedValue({
|
||||
id: 'barcode-binding-1',
|
||||
})
|
||||
;(prisma.materialSku.aggregate as jest.Mock).mockResolvedValue({
|
||||
_max: { sortOrder: -1 },
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
|
||||
username: 'tester',
|
||||
realName: '测试用户',
|
||||
})
|
||||
})
|
||||
|
||||
function createRequest(formData: FormData) {
|
||||
return {
|
||||
headers: new Headers({
|
||||
'X-API-Key': 'test-key',
|
||||
'user-agent': 'jest',
|
||||
}),
|
||||
formData: jest.fn().mockResolvedValue(formData),
|
||||
} as any
|
||||
}
|
||||
|
||||
function createBaseForm() {
|
||||
const formData = new FormData()
|
||||
formData.append('clientRequestId', 'req-001')
|
||||
formData.append('barcode', '690000000001')
|
||||
formData.append('spu', 'spu001')
|
||||
formData.append('skuName', 'sku001')
|
||||
formData.append('sourceType', 'AI')
|
||||
formData.append(
|
||||
'whiteBgRightFront45',
|
||||
new File([Buffer.from('png-source')], 'right-front-45.png', { type: 'image/png' }),
|
||||
)
|
||||
return formData
|
||||
}
|
||||
|
||||
test('已有标准模型 SPU 和 SKU 时应直接录入该 SKU 的白底图', async () => {
|
||||
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'material-1',
|
||||
productModel: 'SPU001',
|
||||
categoryName: '通用',
|
||||
productCategoryName: '沙发',
|
||||
skus: [{ id: 'sku-1', skuName: 'SKU001' }],
|
||||
},
|
||||
])
|
||||
|
||||
const response = await POST(createRequest(createBaseForm()))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.success).toBe(true)
|
||||
expect(body.data).toMatchObject({
|
||||
materialId: 'material-1',
|
||||
skuId: 'sku-1',
|
||||
barcode: '690000000001',
|
||||
barcodeBindingId: 'barcode-binding-1',
|
||||
resolvedSpu: 'SPU001',
|
||||
resolvedSkuName: 'SKU001',
|
||||
createdMaterial: false,
|
||||
createdSku: false,
|
||||
createdBarcodeBinding: true,
|
||||
})
|
||||
expect(prisma.material.create).not.toHaveBeenCalled()
|
||||
expect(prisma.materialSku.create).not.toHaveBeenCalled()
|
||||
expect(prisma.materialSkuBarcode.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
skuId: 'sku-1',
|
||||
barcode: '690000000001',
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(uploadStandardModelWhiteBackground).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
materialId: 'material-1',
|
||||
skuId: 'sku-1',
|
||||
skuName: 'SKU001',
|
||||
sourceType: 'AI',
|
||||
operatorUserId: 'user-1',
|
||||
}),
|
||||
)
|
||||
expect(completeExternalApiIdempotencyRecord).toHaveBeenCalled()
|
||||
expect(logApiCall).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 200 }))
|
||||
})
|
||||
|
||||
test('已有标准模型 SPU 但没有该 SKU 时应在该 SPU 下新建 SKU 后录入', async () => {
|
||||
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'material-1',
|
||||
productModel: 'SPU001',
|
||||
categoryName: '通用',
|
||||
productCategoryName: '沙发',
|
||||
skus: [],
|
||||
},
|
||||
])
|
||||
;(prisma.materialSku.aggregate as jest.Mock).mockResolvedValue({
|
||||
_max: { sortOrder: 0 },
|
||||
})
|
||||
;(prisma.materialSku.create as jest.Mock).mockResolvedValue({
|
||||
id: 'sku-new',
|
||||
skuName: 'SKU001',
|
||||
})
|
||||
|
||||
const response = await POST(createRequest(createBaseForm()))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toMatchObject({
|
||||
materialId: 'material-1',
|
||||
skuId: 'sku-new',
|
||||
createdMaterial: false,
|
||||
createdSku: true,
|
||||
})
|
||||
expect(prisma.materialSku.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
materialId: 'material-1',
|
||||
skuName: 'SKU001',
|
||||
sortOrder: 1,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('标准模型内没有该 SPU 时应要求品类并新建 SPU 和 SKU 后录入', async () => {
|
||||
const formData = createBaseForm()
|
||||
formData.append('productCategoryName', '沙发')
|
||||
;(prisma.material.findMany as jest.Mock).mockResolvedValue([])
|
||||
;(prisma.material.create as jest.Mock).mockResolvedValue({
|
||||
id: 'material-new',
|
||||
productModel: 'SPU001',
|
||||
categoryName: '通用',
|
||||
productCategoryName: '沙发',
|
||||
})
|
||||
;(prisma.materialSku.create as jest.Mock).mockResolvedValue({
|
||||
id: 'sku-new',
|
||||
skuName: 'SKU001',
|
||||
})
|
||||
|
||||
const response = await POST(createRequest(formData))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(body.data).toMatchObject({
|
||||
materialId: 'material-new',
|
||||
skuId: 'sku-new',
|
||||
createdMaterial: true,
|
||||
createdSku: true,
|
||||
})
|
||||
expect(prisma.material.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
productModel: 'SPU001',
|
||||
materialType: '标准模型',
|
||||
productCategoryName: '沙发',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('缺少 SKU 时不能把 SPU 当 SKU 兜底', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('clientRequestId', 'req-002')
|
||||
formData.append('barcode', '690000000002')
|
||||
formData.append('spu', 'spu001')
|
||||
formData.append('sourceType', 'AI')
|
||||
formData.append(
|
||||
'whiteBgRightFront45',
|
||||
new File([Buffer.from('png-source')], 'right-front-45.png', { type: 'image/png' }),
|
||||
)
|
||||
|
||||
const response = await POST(createRequest(formData))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_SKU',
|
||||
message: 'skuName 不能为空,白底图必须明确挂到 SPU 下的某个 SKU',
|
||||
},
|
||||
})
|
||||
expect(uploadStandardModelWhiteBackground).not.toHaveBeenCalled()
|
||||
expect(prisma.material.findMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('缺少产品条码时应拒绝录入,避免后续唯一值链路缺失', async () => {
|
||||
const formData = new FormData()
|
||||
formData.append('clientRequestId', 'req-003')
|
||||
formData.append('spu', 'spu001')
|
||||
formData.append('skuName', 'sku001')
|
||||
formData.append('sourceType', 'AI')
|
||||
formData.append(
|
||||
'whiteBgRightFront45',
|
||||
new File([Buffer.from('png-source')], 'right-front-45.png', { type: 'image/png' }),
|
||||
)
|
||||
|
||||
const response = await POST(createRequest(formData))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_BARCODE',
|
||||
message: 'barcode/productBarcode 不能为空',
|
||||
},
|
||||
})
|
||||
expect(uploadStandardModelWhiteBackground).not.toHaveBeenCalled()
|
||||
expect(prisma.material.findMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('产品条码已绑定到其它 SKU 时应返回冲突', async () => {
|
||||
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
|
||||
{
|
||||
id: 'material-1',
|
||||
productModel: 'SPU001',
|
||||
categoryName: '通用',
|
||||
productCategoryName: '沙发',
|
||||
skus: [{ id: 'sku-1', skuName: 'SKU001' }],
|
||||
},
|
||||
])
|
||||
;(prisma.materialSkuBarcode.findFirst as jest.Mock).mockResolvedValue({
|
||||
id: 'barcode-binding-old',
|
||||
skuId: 'sku-other',
|
||||
sku: {
|
||||
skuName: 'SKU999',
|
||||
material: {
|
||||
productModel: 'SPU999',
|
||||
materialType: '标准模型',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const response = await POST(createRequest(createBaseForm()))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(body).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'BARCODE_OCCUPIED',
|
||||
message: '产品条码 690000000001 已绑定到标准模型 SPU SPU999 的 SKU SKU999',
|
||||
},
|
||||
})
|
||||
expect(uploadStandardModelWhiteBackground).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('新建标准模型 SPU 时缺少品类应返回明确错误', async () => {
|
||||
;(prisma.material.findMany as jest.Mock).mockResolvedValue([])
|
||||
|
||||
const response = await POST(createRequest(createBaseForm()))
|
||||
const body = await response.json()
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(body).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'PRODUCT_CATEGORY_REQUIRED',
|
||||
message: '该 SPU 尚未在标准模型内存在,新建标准模型 SPU 时必须传 productCategoryName',
|
||||
},
|
||||
})
|
||||
expect(prisma.material.create).not.toHaveBeenCalled()
|
||||
expect(uploadStandardModelWhiteBackground).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -5,6 +5,7 @@ import { prisma } from '@/lib/prisma'
|
||||
export const STANDARD_MODEL_WHITE_BACKGROUND_BARCODE_OPERATION = {
|
||||
UPLOAD: 'standard-model-white-background.by-barcode.upload',
|
||||
CLEANUP: 'standard-model-white-background.by-barcode.cleanup',
|
||||
UPLOAD_BY_SPU: 'standard-model-white-background.by-spu.upload',
|
||||
} as const
|
||||
|
||||
export type BarcodeOperationType =
|
||||
|
||||
@ -6,6 +6,9 @@ import {
|
||||
resolveSecureAuthCookieFromRequest,
|
||||
} from '@/lib/auth-cookie'
|
||||
|
||||
const API_CORS_ALLOWED_HEADERS =
|
||||
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, X-API-Key, Authorization, Cookie'
|
||||
|
||||
// 注意:不在middleware中启动内存监控
|
||||
// middleware使用Edge Runtime,不支持Node.js API
|
||||
// 内存监控会在lib/memory-monitor.ts模块加载时自动启动(仅生产环境)
|
||||
@ -24,7 +27,7 @@ export async function middleware(request: NextRequest) {
|
||||
'Access-Control-Allow-Credentials': 'true',
|
||||
'Access-Control-Allow-Origin': requestOrigin || origin,
|
||||
'Access-Control-Allow-Methods': 'GET,DELETE,PATCH,POST,PUT,OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization, Cookie',
|
||||
'Access-Control-Allow-Headers': API_CORS_ALLOWED_HEADERS,
|
||||
'Access-Control-Max-Age': '86400',
|
||||
},
|
||||
})
|
||||
@ -35,7 +38,7 @@ export async function middleware(request: NextRequest) {
|
||||
response.headers.set('Access-Control-Allow-Credentials', 'true')
|
||||
response.headers.set('Access-Control-Allow-Origin', requestOrigin || origin)
|
||||
response.headers.set('Access-Control-Allow-Methods', 'GET,DELETE,PATCH,POST,PUT,OPTIONS')
|
||||
response.headers.set('Access-Control-Allow-Headers', 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization, Cookie')
|
||||
response.headers.set('Access-Control-Allow-Headers', API_CORS_ALLOWED_HEADERS)
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user