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`
|
- 请求头:`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`
|
- 方法:`POST`
|
||||||
- 路径:`/api/external/material-import/jobs`
|
- 路径:`/api/external/standard-model-white-background/by-spu`
|
||||||
- `Content-Type`:`application/json`
|
- `Content-Type`:`multipart/form-data`
|
||||||
|
|
||||||
### 请求体
|
### 字段
|
||||||
|
|
||||||
| 字段 | 必填 | 说明 |
|
| 字段 | 必填 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `clientRequestId` | 是 | 请求唯一号,用于防重复提交 |
|
| `clientRequestId` | 是 | 产品平台生成的请求唯一号;同一批图片重试必须保持不变 |
|
||||||
| `materialType` | 是 | 首版固定传 `任务-详情` |
|
| `barcode` / `productBarcode` | 是 | 产品条码,素材库后续会逐步以它作为唯一业务定位值;同一有效条码只能绑定一个 SKU |
|
||||||
| `requester.dingtalkUserId` | 是 | 钉钉唯一标识,用于反推素材归属用户 |
|
| `spu` / `productModel` | 是 | SPU,素材库用于定位或新建标准模型素材 |
|
||||||
| `formData.taskNo` | 是 | 任务号 |
|
| `skuName` / `sku` / `skuCode` | 是 | SKU,素材库用于定位或新建该 SPU 下的 SKU;不能用 SPU 代替 |
|
||||||
| `formData.productModel` | 是 | SPU |
|
| `sourceType` | 是 | 图片来源,只能传 `实拍`、`渲染`、`AI` |
|
||||||
| `formData.categoryName` | 是 | 店铺 |
|
| `productCategoryName` | 条件必填 | 当 SPU 在标准模型内不存在、需要新建 SPU 时必填 |
|
||||||
| `formData.sceneName` | 是 | 场景 |
|
| `materialType` | 否 | 如传,只能传 `标准模型` |
|
||||||
| `formData.platformName` | 否 | 平台 |
|
| `platformName` | 否 | 来源平台或业务平台备注 |
|
||||||
| `formData.productCategoryName` | 否 | 品类 |
|
| `sourceSystem` | 否 | 来源系统标识,建议传 `product-design-platform` |
|
||||||
| `sections` | 是 | 图片分组列表 |
|
| `productId` | 否 | 产品平台内部产品 ID,用于排查追踪 |
|
||||||
| `submissionSnapshot` | 否 | 原样提交快照;不传时服务端会保存整份请求体 |
|
| `skuBatchNo` | 否 | 产品平台批次号,用于排查追踪 |
|
||||||
|
|
||||||
### 图片分组规则
|
### 图片字段
|
||||||
|
|
||||||
- 首版只允许 `main`、`sku`、`detail` 三种分组
|
至少上传 1 张白底图。固定视角字段如下,按已有图片传即可,不要求一次传满 8 张。
|
||||||
- 三种分组都不是必传
|
|
||||||
- 全部图片总数至少要有 1 张
|
|
||||||
- 每张图片必须带:
|
|
||||||
- `url`
|
|
||||||
- `fileName`
|
|
||||||
- `fileSize`
|
|
||||||
- `sortOrder`
|
|
||||||
- 详情图顺序严格按 `sortOrder` 入库
|
|
||||||
|
|
||||||
### 请求示例
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `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
|
```json
|
||||||
{
|
{
|
||||||
"success": true,
|
"success": true,
|
||||||
"message": "导入任务已创建",
|
|
||||||
"data": {
|
"data": {
|
||||||
"created": true,
|
"materialId": "material-uuid",
|
||||||
"jobId": "job-uuid",
|
"skuId": "sku-uuid",
|
||||||
"materialType": "任务-详情",
|
"barcode": "690000000001",
|
||||||
"taskNo": "TASK202603240001",
|
"barcodeBindingId": "barcode-binding-uuid",
|
||||||
"status": "processing",
|
"resolvedSpu": "SPU001",
|
||||||
"success": false,
|
"resolvedSkuName": "SKU001",
|
||||||
"resultMaterialId": null,
|
"createdMaterial": false,
|
||||||
"errorCode": null,
|
"createdSku": false,
|
||||||
"errorMessage": null,
|
"createdBarcodeBinding": true,
|
||||||
"completedAt": null
|
"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 无效或缺失 |
|
| `INVALID_API_KEY` | API Key 无效或缺失 |
|
||||||
| `MATERIAL_TYPE_NOT_OPEN` | 当前素材类型未开放 |
|
| `INVALID_CLIENT_REQUEST_ID` | `clientRequestId` 为空 |
|
||||||
| `USER_NOT_FOUND` | 无法根据钉钉身份映射到素材库用户 |
|
| `MATERIAL_TYPE_UNSUPPORTED` | 当前接口只支持标准模型白底图 |
|
||||||
| `FORM_VALUE_INVALID` | 表单值不合法,或不在当前下拉项内 |
|
| `INVALID_BARCODE` | 产品条码为空 |
|
||||||
| `INVALID_TASK_NO` | 任务号格式错误 |
|
| `INVALID_SPU` | SPU 为空或格式化后为空 |
|
||||||
| `TASK_NO_OCCUPIED` | 任务号已被进行中或成功任务占用 |
|
| `INVALID_SKU` | SKU 为空;白底图不能在没有 SKU 的情况下录入 |
|
||||||
| `IMAGE_GROUP_INVALID` | 图片分组不合法或总图片数为 0 |
|
| `INVALID_SOURCE_TYPE` | `sourceType` 不是 `实拍`、`渲染`、`AI` |
|
||||||
| `JOB_NOT_FOUND` | 导入任务不存在 |
|
| `MISSING_REQUIRED_VIEWS` | 没有上传任何有效白底图 |
|
||||||
| `IMPORT_FAILED` | 导入执行失败 |
|
| `PRODUCT_CATEGORY_REQUIRED` | 新建标准模型 SPU 时缺少 `productCategoryName` |
|
||||||
|
| `SPU_NOT_UNIQUE` | 标准模型内存在多个相同 SPU |
|
||||||
|
| `SKU_NAME_OCCUPIED` | 传入 SKU 已被其它标准模型 SPU 占用 |
|
||||||
|
| `BARCODE_OCCUPIED` | 产品条码已绑定到其它 SKU |
|
||||||
|
| `IDEMPOTENCY_CONFLICT` | 同一个 `clientRequestId` 被用于不同请求内容 |
|
||||||
|
|
||||||
## 当前实现说明
|
## 给产品设计平台的对接要求
|
||||||
|
|
||||||
- 首版只支持 `任务-详情`
|
产品平台请对接 `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` 用于重试幂等。
|
||||||
- 导入成功后会新建一条新的素材记录,不覆盖原有素材
|
|
||||||
- 同一 `taskNo` 只允许一份最终成功结果
|
|
||||||
- 某次导入失败后,允许继续使用同一个 `taskNo` 重新提交
|
|
||||||
|
|||||||
@ -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 = {
|
export const STANDARD_MODEL_WHITE_BACKGROUND_BARCODE_OPERATION = {
|
||||||
UPLOAD: 'standard-model-white-background.by-barcode.upload',
|
UPLOAD: 'standard-model-white-background.by-barcode.upload',
|
||||||
CLEANUP: 'standard-model-white-background.by-barcode.cleanup',
|
CLEANUP: 'standard-model-white-background.by-barcode.cleanup',
|
||||||
|
UPLOAD_BY_SPU: 'standard-model-white-background.by-spu.upload',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type BarcodeOperationType =
|
export type BarcodeOperationType =
|
||||||
|
|||||||
@ -6,6 +6,9 @@ import {
|
|||||||
resolveSecureAuthCookieFromRequest,
|
resolveSecureAuthCookieFromRequest,
|
||||||
} from '@/lib/auth-cookie'
|
} 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中启动内存监控
|
||||||
// middleware使用Edge Runtime,不支持Node.js API
|
// middleware使用Edge Runtime,不支持Node.js API
|
||||||
// 内存监控会在lib/memory-monitor.ts模块加载时自动启动(仅生产环境)
|
// 内存监控会在lib/memory-monitor.ts模块加载时自动启动(仅生产环境)
|
||||||
@ -24,7 +27,7 @@ export async function middleware(request: NextRequest) {
|
|||||||
'Access-Control-Allow-Credentials': 'true',
|
'Access-Control-Allow-Credentials': 'true',
|
||||||
'Access-Control-Allow-Origin': requestOrigin || origin,
|
'Access-Control-Allow-Origin': requestOrigin || origin,
|
||||||
'Access-Control-Allow-Methods': 'GET,DELETE,PATCH,POST,PUT,OPTIONS',
|
'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',
|
'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-Credentials', 'true')
|
||||||
response.headers.set('Access-Control-Allow-Origin', requestOrigin || origin)
|
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-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
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user