import { MAX_LOGICAL_UPLOAD_BYTES } from '@/lib/material-upload-chunk-policy' const MEBIBYTE = 1024 * 1024 export const MATERIAL_UPLOAD_REQUEST_LIMITS = Object.freeze({ maxRequestBytes: 128 * MEBIBYTE, maxLogicalSourceBytes: MAX_LOGICAL_UPLOAD_BYTES, maxDirectSourceBytesExclusive: 5 * MEBIBYTE, maxFileParts: 50, maxPreviewImageBytes: 8 * MEBIBYTE, maxPreviewImages: 19, maxPreviewBytes: 32 * MEBIBYTE, maxDisplayImageBytes: 50 * MEBIBYTE, maxGalleryImageBytes: 16 * MEBIBYTE, maxGalleryImagesPerField: 49, maxGalleryBytes: 112 * MEBIBYTE, }) const ALLOWED_FILE_FIELDS = new Set([ 'zipFile', 'previewImage', 'previewImages', 'displayImage', 'modelImages', 'displayImages', ]) export class MaterialUploadRequestError extends Error { constructor( message: string, public readonly status: number, public readonly code: string, public readonly retryAfterSeconds?: number, ) { super(message) this.name = 'MaterialUploadRequestError' } } export function validateMaterialUploadRequestHeaders(headers: Headers): number { const contentType = (headers.get('content-type') || '').toLowerCase() if (!contentType.includes('multipart/form-data')) { throw new MaterialUploadRequestError( '上传请求必须使用 multipart/form-data', 415, 'MULTIPART_REQUIRED', ) } const contentEncoding = (headers.get('content-encoding') || 'identity').trim().toLowerCase() if (contentEncoding !== 'identity') { throw new MaterialUploadRequestError( '上传请求不支持压缩传输编码', 415, 'CONTENT_ENCODING_UNSUPPORTED', ) } const rawLength = headers.get('content-length') if (!rawLength || !/^\d+$/.test(rawLength.trim())) { throw new MaterialUploadRequestError( '上传请求缺少有效的 Content-Length', 411, 'CONTENT_LENGTH_REQUIRED', ) } const contentLength = Number(rawLength) if (!Number.isSafeInteger(contentLength) || contentLength <= 0) { throw new MaterialUploadRequestError( '上传请求的 Content-Length 无效', 411, 'CONTENT_LENGTH_REQUIRED', ) } if (contentLength > MATERIAL_UPLOAD_REQUEST_LIMITS.maxRequestBytes) { throw new MaterialUploadRequestError( '本次上传内容超过 128MB,请减少图片数量或使用分片上传', 413, 'UPLOAD_REQUEST_TOO_LARGE', ) } return contentLength } function isFilePart(value: FormDataEntryValue): value is File { return typeof value === 'object' && value !== null && typeof (value as File).name === 'string' && typeof (value as File).size === 'number' && typeof (value as File).arrayBuffer === 'function' } function sumFileBytes(files: File[]): number { return files.reduce((sum, file) => sum + file.size, 0) } function assertFilesWithinLimit(files: File[], maxBytes: number, label: string): void { const oversized = files.find((file) => file.size > maxBytes) if (oversized) { throw new MaterialUploadRequestError( `${label}「${oversized.name}」过大`, 413, 'UPLOAD_FILE_TOO_LARGE', ) } } export function validateMaterialUploadFormData(formData: FormData): void { const filesByField = new Map() let totalFileBytes = 0 let totalFileParts = 0 for (const [field, value] of Array.from(formData.entries())) { if (!isFilePart(value)) { continue } if (!ALLOWED_FILE_FIELDS.has(field)) { throw new MaterialUploadRequestError( `不支持的文件字段:${field}`, 400, 'UNKNOWN_FILE_FIELD', ) } if (!Number.isSafeInteger(value.size) || value.size <= 0) { throw new MaterialUploadRequestError( `文件「${value.name || field}」为空或大小无效`, 400, 'EMPTY_UPLOAD_FILE', ) } const fieldFiles = filesByField.get(field) || [] fieldFiles.push(value) filesByField.set(field, fieldFiles) totalFileParts += 1 totalFileBytes += value.size } if (totalFileParts > MATERIAL_UPLOAD_REQUEST_LIMITS.maxFileParts) { throw new MaterialUploadRequestError( `一次最多上传 ${MATERIAL_UPLOAD_REQUEST_LIMITS.maxFileParts} 个文件`, 413, 'TOO_MANY_UPLOAD_FILES', ) } if (totalFileBytes > MATERIAL_UPLOAD_REQUEST_LIMITS.maxRequestBytes) { throw new MaterialUploadRequestError( '本次上传文件总大小超过 128MB', 413, 'UPLOAD_FILES_TOO_LARGE', ) } const zipFiles = filesByField.get('zipFile') || [] const previewImage = filesByField.get('previewImage') || [] const previewImages = filesByField.get('previewImages') || [] const displayImage = filesByField.get('displayImage') || [] const modelImages = filesByField.get('modelImages') || [] const displayImages = filesByField.get('displayImages') || [] if (zipFiles.length > 1 || previewImage.length > 1 || displayImage.length > 1) { throw new MaterialUploadRequestError( 'zipFile、previewImage 和 displayImage 均只能提交一个文件', 400, 'DUPLICATE_SINGLE_FILE_FIELD', ) } if (zipFiles.some((file) => file.size >= MATERIAL_UPLOAD_REQUEST_LIMITS.maxDirectSourceBytesExclusive)) { throw new MaterialUploadRequestError( '主素材文件达到 5MB,请使用分片上传', 413, 'CHUNK_UPLOAD_REQUIRED', ) } if (previewImages.length > MATERIAL_UPLOAD_REQUEST_LIMITS.maxPreviewImages) { throw new MaterialUploadRequestError( `其他预览图最多 ${MATERIAL_UPLOAD_REQUEST_LIMITS.maxPreviewImages} 张`, 413, 'TOO_MANY_PREVIEW_IMAGES', ) } const allPreviewImages = [...previewImage, ...previewImages] assertFilesWithinLimit( allPreviewImages, MATERIAL_UPLOAD_REQUEST_LIMITS.maxPreviewImageBytes, '预览图', ) if (sumFileBytes(allPreviewImages) > MATERIAL_UPLOAD_REQUEST_LIMITS.maxPreviewBytes) { throw new MaterialUploadRequestError( '预览图总大小超过 32MB', 413, 'PREVIEW_IMAGES_TOO_LARGE', ) } assertFilesWithinLimit( displayImage, MATERIAL_UPLOAD_REQUEST_LIMITS.maxDisplayImageBytes, '展示原图', ) if ( modelImages.length > MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryImagesPerField || displayImages.length > MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryImagesPerField ) { throw new MaterialUploadRequestError( `展示图片每组最多 ${MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryImagesPerField} 张`, 413, 'TOO_MANY_GALLERY_IMAGES', ) } const galleryImages = [...modelImages, ...displayImages] assertFilesWithinLimit( galleryImages, MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryImageBytes, '展示图片', ) if (sumFileBytes(galleryImages) > MATERIAL_UPLOAD_REQUEST_LIMITS.maxGalleryBytes) { throw new MaterialUploadRequestError( '展示图片总大小超过 112MB', 413, 'GALLERY_IMAGES_TOO_LARGE', ) } if (zipFiles.length > 0 && String(formData.get('existingFilePath') || '').trim()) { throw new MaterialUploadRequestError( '不能同时提交上传文件和服务器合并文件', 400, 'SOURCE_FILE_CONFLICT', ) } } type WaitingAcquire = { resolve: (release: () => void) => void } export class MaterialUploadRequestLimiter { private active = 0 private readonly waiting: WaitingAcquire[] = [] constructor( private readonly maxActive = 1, private readonly maxWaiting = 8, ) { if (!Number.isInteger(maxActive) || maxActive <= 0) { throw new Error('maxActive 必须是正整数') } if (!Number.isInteger(maxWaiting) || maxWaiting < 0) { throw new Error('maxWaiting 必须是非负整数') } } async acquire(): Promise<() => void> { if (this.active < this.maxActive) { this.active += 1 return this.createRelease() } if (this.waiting.length >= this.maxWaiting) { throw new MaterialUploadRequestError( '当前上传处理繁忙,请稍后重试', 503, 'UPLOAD_BUSY', 3, ) } return new Promise<() => void>((resolve) => { this.waiting.push({ resolve }) }) } snapshot(): { active: number; waiting: number } { return { active: this.active, waiting: this.waiting.length } } private createRelease(): () => void { let released = false return () => { if (released) { return } released = true const next = this.waiting.shift() if (next) { next.resolve(this.createRelease()) return } this.active = Math.max(0, this.active - 1) } } } export const materialUploadRequestLimiter = new MaterialUploadRequestLimiter(1, 8) // 与浏览器允许的最高分片并发保持一致。它只限制服务器同时写盘数量, // 不把大文件网络上传退化为单并发。 export const materialUploadChunkRequestLimiter = new MaterialUploadRequestLimiter(8, 64) // 合并会额外生成一份完整源文件。全局单槽避免多个大文件同时复制, // 把瞬时磁盘占用控制在“一个合并文件 + 固定安全余量”以内。 export const materialUploadMergeRequestLimiter = new MaterialUploadRequestLimiter(1, 8)