chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

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?.()
}
}