Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
import { randomUUID } from 'crypto'
|
|
import { NextRequest, NextResponse } from 'next/server'
|
|
import {
|
|
getApiKeyFromRequest,
|
|
getClientIp,
|
|
logApiCall,
|
|
validateApiKey,
|
|
} from '@/lib/external-api'
|
|
import {
|
|
completeExternalApiIdempotencyRecord,
|
|
ExternalWhiteBackgroundBarcodeError,
|
|
failExternalApiIdempotencyRecord,
|
|
resolveStandardModelSkuByBarcode,
|
|
stableHashPayload,
|
|
STANDARD_MODEL_WHITE_BACKGROUND_BARCODE_OPERATION,
|
|
startExternalApiIdempotencyRecord,
|
|
} from '@/lib/external-standard-model-white-background-by-barcode'
|
|
import {
|
|
STANDARD_MODEL_WHITE_BG_OTHER_FIELD_NAME,
|
|
STANDARD_MODEL_WHITE_BG_VIEWS,
|
|
type StandardModelWhiteBgKey,
|
|
} from '@/lib/standard-model-white-background'
|
|
import { normalizeStandardModelWhiteBackgroundSourceType } from '@/lib/standard-model-white-background-metadata'
|
|
import { uploadStandardModelWhiteBackground } from '@/lib/standard-model-white-background-service'
|
|
import {
|
|
STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS,
|
|
WhiteBackgroundUploadValidationError,
|
|
} from '@/lib/standard-model-white-background-upload-guard'
|
|
import {
|
|
boundedMultipartRequestLimiter,
|
|
getBoundedMultipartFailure,
|
|
} from '@/lib/multipart-route-limits'
|
|
|
|
export const runtime = 'nodejs'
|
|
|
|
const ENDPOINT = '/api/external/standard-model-white-background/by-barcode'
|
|
|
|
type TraceContext = {
|
|
traceId: string
|
|
startTime: number
|
|
barcode: string
|
|
ipAddress?: string
|
|
apiKeyName?: string
|
|
userAgent?: string
|
|
}
|
|
|
|
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.barcode) {
|
|
response.headers.set('X-Request-Barcode', trace.barcode)
|
|
}
|
|
|
|
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 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
|
|
sourceType: string
|
|
sourceSystem: string
|
|
productId: string
|
|
skuBatchNo: string
|
|
fixedImages: Partial<Record<StandardModelWhiteBgKey, File | null>>
|
|
otherImages: File[]
|
|
}) {
|
|
return {
|
|
clientRequestId: params.clientRequestId,
|
|
barcode: params.barcode,
|
|
sourceType: params.sourceType,
|
|
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: 'barcode',
|
|
queryValue: params.trace.barcode || undefined,
|
|
statusCode: params.statusCode,
|
|
duration: getElapsedMs(params.trace.startTime),
|
|
ipAddress: params.trace.ipAddress,
|
|
userAgent: params.trace.userAgent,
|
|
})
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
const trace: TraceContext = {
|
|
traceId: randomUUID(),
|
|
startTime: Date.now(),
|
|
barcode: '',
|
|
ipAddress: getClientIp(request),
|
|
userAgent: request.headers.get('user-agent') || undefined,
|
|
}
|
|
|
|
const apiKey = getApiKeyFromRequest(request)
|
|
const keyRecord = await validateApiKey(apiKey)
|
|
if (!keyRecord) {
|
|
return finalizeResponse(
|
|
buildErrorResponse(401, 'INVALID_API_KEY', 'API Key 无效或缺失'),
|
|
trace,
|
|
)
|
|
}
|
|
|
|
trace.apiKeyName = keyRecord.name
|
|
|
|
let idempotencyRecordId: string | null = null
|
|
let releaseRequestSlot: (() => void) | null = null
|
|
|
|
try {
|
|
const contentLength = Number(request.headers.get('content-length'))
|
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
|
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 411 })
|
|
return finalizeResponse(
|
|
buildErrorResponse(411, 'CONTENT_LENGTH_REQUIRED', '必须提供有效的请求大小'),
|
|
trace,
|
|
)
|
|
}
|
|
if (contentLength > STANDARD_MODEL_WHITE_BG_UPLOAD_LIMITS.maxRequestBytes) {
|
|
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 413 })
|
|
return finalizeResponse(
|
|
buildErrorResponse(413, 'REQUEST_TOO_LARGE', '白底图上传请求过大'),
|
|
trace,
|
|
)
|
|
}
|
|
|
|
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire()
|
|
const formData = await request.formData()
|
|
const clientRequestId = String(formData.get('clientRequestId') || '').trim()
|
|
const barcode = String(formData.get('barcode') || '').trim()
|
|
const sourceTypeRaw = String(formData.get('sourceType') || '').trim()
|
|
const sourceSystem = String(formData.get('sourceSystem') || '').trim()
|
|
const productId = String(formData.get('productId') || '').trim()
|
|
const skuBatchNo = String(formData.get('skuBatchNo') || '').trim()
|
|
trace.barcode = barcode
|
|
|
|
if (!clientRequestId) {
|
|
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
|
|
return finalizeResponse(
|
|
buildErrorResponse(400, 'INVALID_CLIENT_REQUEST_ID', 'clientRequestId 不能为空'),
|
|
trace,
|
|
)
|
|
}
|
|
|
|
if (!barcode) {
|
|
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
|
|
return finalizeResponse(buildErrorResponse(400, 'INVALID_BARCODE', 'barcode 不能为空'), 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,
|
|
sourceType: sourceTypeRaw,
|
|
sourceSystem,
|
|
productId,
|
|
skuBatchNo,
|
|
fixedImages,
|
|
otherImages,
|
|
}),
|
|
)
|
|
|
|
const idempotency = await startExternalApiIdempotencyRecord({
|
|
apiKeyId: keyRecord.id,
|
|
operationType: STANDARD_MODEL_WHITE_BACKGROUND_BARCODE_OPERATION.UPLOAD,
|
|
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 resolveStandardModelSkuByBarcode(barcode)
|
|
const uploadResult = await uploadStandardModelWhiteBackground({
|
|
materialId: resolved.materialId,
|
|
skuId: resolved.skuId,
|
|
skuName: resolved.skuName,
|
|
sourceType,
|
|
operatorUserId: keyRecord.createdBy,
|
|
fixedImages,
|
|
otherImages,
|
|
})
|
|
|
|
const responseBody = buildSuccessBody({
|
|
materialId: resolved.materialId,
|
|
skuId: resolved.skuId,
|
|
resolvedSpu: resolved.resolvedSpu,
|
|
resolvedSkuName: resolved.resolvedSkuName,
|
|
uploadedAt: uploadResult.whiteBackgroundUploadedAt,
|
|
traceId: trace.traceId,
|
|
whiteBackgroundUploaded: uploadResult.whiteBackgroundUploaded,
|
|
uploadedFixedCount: uploadResult.uploadedFixedCount,
|
|
fixedCount: uploadResult.fixedCount,
|
|
otherCount: uploadResult.otherCount,
|
|
whiteBackgroundCount: uploadResult.whiteBackgroundCount,
|
|
isComplete: uploadResult.isComplete,
|
|
})
|
|
|
|
await completeExternalApiIdempotencyRecord({
|
|
recordId: activeIdempotencyRecordId,
|
|
responseStatus: 200,
|
|
responseBody,
|
|
})
|
|
|
|
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 200 })
|
|
return finalizeResponse(NextResponse.json(responseBody), trace)
|
|
} catch (error) {
|
|
if (idempotencyRecordId) {
|
|
await failExternalApiIdempotencyRecord(idempotencyRecordId)
|
|
}
|
|
|
|
const boundedFailure = getBoundedMultipartFailure(error)
|
|
if (boundedFailure) {
|
|
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: boundedFailure.status })
|
|
const response = buildErrorResponse(
|
|
boundedFailure.status,
|
|
boundedFailure.code,
|
|
boundedFailure.message,
|
|
)
|
|
if (boundedFailure.retryAfterSeconds) {
|
|
response.headers.set('Retry-After', String(boundedFailure.retryAfterSeconds))
|
|
}
|
|
return finalizeResponse(response, trace)
|
|
}
|
|
|
|
if (error instanceof ExternalWhiteBackgroundBarcodeError) {
|
|
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: error.status })
|
|
return finalizeResponse(buildErrorResponse(error.status, error.code, error.message), trace)
|
|
}
|
|
|
|
if (error instanceof WhiteBackgroundUploadValidationError) {
|
|
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 400 })
|
|
return finalizeResponse(
|
|
buildErrorResponse(400, 'INVALID_IMAGE_UPLOAD', error.message),
|
|
trace,
|
|
)
|
|
}
|
|
|
|
const message = error instanceof Error ? error.message : '上传白底图失败'
|
|
console.error('[external/standard-model-white-background/by-barcode] POST error:', error)
|
|
await writeApiLog({ apiKeyId: keyRecord.id, trace, statusCode: 500 })
|
|
return finalizeResponse(buildErrorResponse(500, 'UPLOAD_FAILED', message || '上传白底图失败'), trace)
|
|
} finally {
|
|
releaseRequestSlot?.()
|
|
}
|
|
}
|