diff --git a/app/api/external/standard-model-white-background/by-spu/route.ts b/app/api/external/standard-model-white-background/by-spu/route.ts new file mode 100644 index 0000000..86e4f42 --- /dev/null +++ b/app/api/external/standard-model-white-background/by-spu/route.ts @@ -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(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>) +} + +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>) { + 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> + 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 { + 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) + } +} diff --git a/docs/AI生图平台成品回传接口.md b/docs/AI生图平台成品回传接口.md index 3337a5c..4b5274f 100644 --- a/docs/AI生图平台成品回传接口.md +++ b/docs/AI生图平台成品回传接口.md @@ -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` 用于重试幂等。 diff --git a/lib/__tests__/standard-model-white-background-by-spu-route.test.ts b/lib/__tests__/standard-model-white-background-by-spu-route.test.ts new file mode 100644 index 0000000..b9e5b53 --- /dev/null +++ b/lib/__tests__/standard-model-white-background-by-spu-route.test.ts @@ -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() + }) +}) diff --git a/lib/external-standard-model-white-background-by-barcode.ts b/lib/external-standard-model-white-background-by-barcode.ts index cac32c5..ce1fffd 100644 --- a/lib/external-standard-model-white-background-by-barcode.ts +++ b/lib/external-standard-model-white-background-by-barcode.ts @@ -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 = diff --git a/middleware.ts b/middleware.ts index bd8fd09..7252f79 100644 --- a/middleware.ts +++ b/middleware.ts @@ -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 }