From 944c15f2819458cb93c5d331090730b30a3d7194 Mon Sep 17 00:00:00 2001 From: chenpei Date: Fri, 31 Jul 2026 17:02:16 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=AF=A6=E6=83=85=20Edge=20=E5=9B=BE=E7=89=87=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../upload/product-package/complete/route.ts | 24 ++++-- components/upload/ProductPackageUploader.tsx | 81 +++++++++++-------- components/upload/drag-drop-utils.test.ts | 13 +++ components/upload/drag-drop-utils.ts | 17 +++- ...ct-package-complete-route-security.test.ts | 62 ++++++++++++++ lib/performance-survey-image-source-guard.ts | 3 + lib/performance-survey-image.worker.ts | 32 ++++++-- 7 files changed, 185 insertions(+), 47 deletions(-) diff --git a/app/api/materials/upload/product-package/complete/route.ts b/app/api/materials/upload/product-package/complete/route.ts index d53f0c9..4d4bc06 100644 --- a/app/api/materials/upload/product-package/complete/route.ts +++ b/app/api/materials/upload/product-package/complete/route.ts @@ -25,6 +25,7 @@ interface CompleteRequestBody { taskNo?: string dingtalkApprovalNo?: string noBarcodes?: boolean + allowServerPreviewFallback?: boolean assets: unknown } @@ -140,14 +141,23 @@ export async function POST(request: NextRequest) { { status: 400 }, ) } - const missingClientPreview = imageAssets.some( + const missingClientPreviewCount = imageAssets.filter( (asset) => !Array.isArray(asset.previewAssets) || asset.previewAssets.length === 0, - ) - if (missingClientPreview) { - return NextResponse.json( - { success: false, error: '客户端预览图缺失,请刷新页面后重新处理图片再提交' }, - { status: 400 }, - ) + ).length + if (missingClientPreviewCount > 0) { + if (body.allowServerPreviewFallback !== true) { + return NextResponse.json( + { success: false, error: '客户端预览图缺失,请重新处理图片后再提交' }, + { status: 400 }, + ) + } + // 浏览器预处理属于性能优化,不应成为提交门槛。Edge 的 worker、图片编解码 + // 或超大 EXIF 头部出现兼容性问题时,保留原图并交给后台唯一 worker 生成预览; + // 该 worker 仍受像素、文件大小和全局并发限制保护。 + console.warn('[Product Package Complete] 客户端预览缺失,转服务器兜底处理', { + materialId: body.materialId, + count: missingClientPreviewCount, + }) } const job = await createTaskDetailUploadJob({ diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx index a5b7452..071fbb7 100644 --- a/components/upload/ProductPackageUploader.tsx +++ b/components/upload/ProductPackageUploader.tsx @@ -38,6 +38,10 @@ import { const TASK_DETAIL_SOURCE_FILE_EXTENSIONS = ['psd', 'psb', 'tif', 'tiff', 'zip', 'rar', '7z'] as const; const TASK_DETAIL_SOURCE_FILE_ACCEPT = '.psd,.psb,.tif,.tiff,.zip,.rar,.7z'; const TASK_DETAIL_SOURCE_FILE_LABEL = '.psd、.psb、.tif、.tiff、.zip、.rar、.7z'; +// 浏览器预处理 worker 的输入格式白名单。不要使用 image/*,它还会放行 +// TIFF/HEIC/AVIF/SVG 等当前处理链不支持的类型,避免最后提交时才报错。 +const TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] as const; +const TASK_DETAIL_BROWSER_IMAGE_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'; function splitEmptyFiles(files: FileList | File[] | null): { validFiles: File[]; @@ -376,7 +380,9 @@ export default function ProductPackageUploader({ } const { validFiles, emptyFileNames } = splitEmptyFiles(files); - const { acceptedFiles: formatAcceptedFiles, errors } = validateImageFiles(validFiles); + const { acceptedFiles: formatAcceptedFiles, errors } = validateImageFiles(validFiles, { + allowedExtensions: TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS, + }); const oversizedFiles = formatAcceptedFiles.filter( (file) => file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes, ); @@ -622,6 +628,7 @@ export default function ProductPackageUploader({ const uploadedFileIds: string[] = []; let completed = false; let jobAccepted = false; + let serverPreviewFallbackUsed = false; const setProgress = (nextProgress: number) => { if (!mountedRef.current) return; @@ -712,8 +719,9 @@ export default function ProductPackageUploader({ let previewAssets: NonNullable = []; if (group !== 'psd') { + let browserPreviews: TaskDetailBrowserPreview[] = []; try { - const browserPreviews = await withBrowserPreviewSlot(() => + browserPreviews = await withBrowserPreviewSlot(() => prepareTaskDetailImagePreview( asset.file, group as TaskDetailAssetKind, @@ -722,34 +730,42 @@ export default function ProductPackageUploader({ }, ), ); - for (let previewIndex = 0; previewIndex < browserPreviews.length; previewIndex += 1) { - const preview: TaskDetailBrowserPreview = browserPreviews[previewIndex]; - totalWorkBytes += preview.file.size; - const previewKey = `${requestPrefix}-preview-${previewIndex}`; - const previewUploadId = await uploadFileWithChunks( - preview.file, - (progress) => reportFileProgress(previewKey, preview.file.size, progress), - () => cancelledRef.current, - previewKey, - ); - uploadedFileIds.push(previewUploadId); - previewAssets.push({ - uploadId: previewUploadId, - fileName: preview.file.name, - fileSize: preview.file.size, - width: preview.width, - height: preview.height, - kind: preview.kind, - sourceName: preview.sourceName, - sourceIndex: preview.sourceIndex, - sliceIndex: preview.sliceIndex, - sliceCount: preview.sliceCount, - }); - } } catch (previewError) { if (cancelledRef.current) throw previewError; - console.error(`[Upload] 浏览器预览处理失败,已停止上传:${asset.file.name}`, previewError); - throw new Error(`${asset.file.name} 的浏览器预览处理失败,请重试或更换桌面版 Chrome/Edge`); + const reason = previewError instanceof Error ? previewError.message : String(previewError); + console.warn(`[Upload] 浏览器预览处理失败,继续上传原图并交由服务器生成预览:${asset.file.name}`, reason); + serverPreviewFallbackUsed = true; + if (mountedRef.current) { + setProcessingStep(`${asset.file.name}:浏览器预览不可用,继续上传原图,服务器将自动生成预览`); + } + } + + // 预览生成失败只影响优化链路,不应阻断原图上传。完成接口会在缺少 + // 客户端预览时进入受限的服务器兜底处理;网络上传错误仍然直接抛出, + // 由外层回滚本次上传,避免把网络错误伪装成浏览器兼容问题。 + for (let previewIndex = 0; previewIndex < browserPreviews.length; previewIndex += 1) { + const preview: TaskDetailBrowserPreview = browserPreviews[previewIndex]; + totalWorkBytes += preview.file.size; + const previewKey = `${requestPrefix}-preview-${previewIndex}`; + const previewUploadId = await uploadFileWithChunks( + preview.file, + (progress) => reportFileProgress(previewKey, preview.file.size, progress), + () => cancelledRef.current, + previewKey, + ); + uploadedFileIds.push(previewUploadId); + previewAssets.push({ + uploadId: previewUploadId, + fileName: preview.file.name, + fileSize: preview.file.size, + width: preview.width, + height: preview.height, + kind: preview.kind, + sourceName: preview.sourceName, + sourceIndex: preview.sourceIndex, + sliceIndex: preview.sliceIndex, + sliceCount: preview.sliceCount, + }); } } @@ -803,6 +819,7 @@ export default function ProductPackageUploader({ taskNo, dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined, noBarcodes, + allowServerPreviewFallback: serverPreviewFallbackUsed, assets: { main: mainAssets, sku: skuAssets, @@ -1011,7 +1028,7 @@ export default function ProductPackageUploader({ id="main-images-input" ref={mainInputRef} type="file" - accept="image/*" + accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT} multiple onChange={(e) => handleImageSelect(e.target.files, setMainImages)} className="hidden" @@ -1088,7 +1105,7 @@ export default function ProductPackageUploader({ id="sku-images-input" ref={skuInputRef} type="file" - accept="image/*" + accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT} multiple onChange={(e) => handleImageSelect(e.target.files, setSkuImages)} className="hidden" @@ -1171,7 +1188,7 @@ export default function ProductPackageUploader({ id="detail-images-input" ref={detailInputRef} type="file" - accept="image/*" + accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT} multiple onChange={(e) => handleImageSelect(e.target.files, setDetailImages)} className="hidden" @@ -1187,7 +1204,7 @@ export default function ProductPackageUploader({ > {detailImages.length > 0 ? '继续添加' : '选择详情图'} - JPG / PNG / WebP,支持拖拽 + JPG / PNG / GIF / WebP,支持拖拽 {/* 详情图排序组件 */} diff --git a/components/upload/drag-drop-utils.test.ts b/components/upload/drag-drop-utils.test.ts index 0a47b38..e82114d 100644 --- a/components/upload/drag-drop-utils.test.ts +++ b/components/upload/drag-drop-utils.test.ts @@ -117,6 +117,19 @@ describe('drag-drop-utils', () => { expect(result.errors.join(';')).toContain('large.jpg') }) + test('业务链路可按浏览器处理能力限制图片格式', () => { + const jpg = new File(['image'], '1.jpg', { type: 'image/jpeg' }) + const heic = new File(['image'], 'phone.heic', { type: 'image/heic' }) + + const result = validateImageFiles([jpg, heic], { + allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'], + }) + + expect(result.acceptedFiles).toEqual([jpg]) + expect(result.errors.join(';')).toContain('phone.heic') + expect(result.errors.join(';')).toContain('JPG、JPEG、PNG、GIF、WEBP') + }) + test('图片数量限制应考虑已经选择的图片', () => { const files = [ new File(['1'], '1.png', { type: 'image/png' }), diff --git a/components/upload/drag-drop-utils.ts b/components/upload/drag-drop-utils.ts index 50d95d4..1dce4b7 100644 --- a/components/upload/drag-drop-utils.ts +++ b/components/upload/drag-drop-utils.ts @@ -14,6 +14,11 @@ export interface ImageFileValidationOptions { maxBytes?: number maxCount?: number existingCount?: number + /** + * 可选的业务格式白名单。浏览器的 image/* MIME 范围比实际处理能力更宽, + * 需要在特定上传链路中按扩展名提前拦截不兼容格式。 + */ + allowedExtensions?: readonly string[] } export interface ImageFileValidationResult { @@ -73,13 +78,16 @@ export function validateImageFiles( files: FileList | File[], options: ImageFileValidationOptions = {}, ): ImageFileValidationResult { - const { maxBytes, maxCount, existingCount = 0 } = options + const { maxBytes, maxCount, existingCount = 0, allowedExtensions } = options const unsupportedNames: string[] = [] const oversizedNames: string[] = [] const validFiles: File[] = [] for (const file of Array.from(files)) { - if (!isSupportedImageFile(file)) { + const isValidImage = allowedExtensions + ? hasAllowedFileExtension(file, allowedExtensions) + : isSupportedImageFile(file) + if (!isValidImage) { unsupportedNames.push(file.name) continue } @@ -100,7 +108,10 @@ export function validateImageFiles( const errors: string[] = [] if (unsupportedNames.length > 0) { - errors.push(`以下文件不是支持的图片格式:${unsupportedNames.join('、')}`) + const supportedLabel = allowedExtensions?.length + ? `,请上传 ${allowedExtensions.map((extension) => extension.replace(/^\./, '').toUpperCase()).join('、')} 格式` + : '' + errors.push(`以下文件不是支持的图片格式:${unsupportedNames.join('、')}${supportedLabel}`) } if (oversizedNames.length > 0 && maxBytes !== undefined) { const maxMegabytes = Math.round(maxBytes / 1024 / 1024) diff --git a/lib/__tests__/product-package-complete-route-security.test.ts b/lib/__tests__/product-package-complete-route-security.test.ts index 69d0192..49a52f6 100644 --- a/lib/__tests__/product-package-complete-route-security.test.ts +++ b/lib/__tests__/product-package-complete-route-security.test.ts @@ -168,6 +168,68 @@ describe('product-package complete public route security', () => { expect(completeProductPackageUpload).not.toHaveBeenCalled() }) + test('客户端预览失败时仍可带原图入队,由后台生成预览', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + materialType: '任务-详情', + taskDetailRequesterUserId: 'requester-1', + taskDetailSourceType: '实拍', + zipFilePath: '', + _count: { assets: 0 }, + }) + + const response = await POST({ + json: jest.fn().mockResolvedValue({ + materialId: 'material-1', + allowServerPreviewFallback: true, + assets: { + main: [{ + uploadId: 'upload-main', + fileName: 'main.jpg', + fileSize: 10, + }], + }, + }), + } as any) + const body = await response.json() + + expect(response.status).toBe(202) + expect(body.data.jobId).toBe('job-1') + expect(createTaskDetailUploadJob).toHaveBeenCalledWith(expect.objectContaining({ + materialId: 'material-1', + assets: expect.objectContaining({ + main: [expect.objectContaining({ uploadId: 'upload-main' })], + }), + })) + }) + + test('缺少客户端预览且未声明兜底时仍拒绝完成请求', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + materialType: '任务-详情', + taskDetailRequesterUserId: 'requester-1', + taskDetailSourceType: '实拍', + zipFilePath: '', + _count: { assets: 0 }, + }) + + const response = await POST({ + json: jest.fn().mockResolvedValue({ + materialId: 'material-1', + assets: { + main: [{ uploadId: 'upload-main', fileName: 'main.jpg', fileSize: 10 }], + }, + }), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toContain('客户端预览图缺失') + expect(createTaskDetailUploadJob).not.toHaveBeenCalled() + }) + test('已有部分持久化结果的素材不能重复入队', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', diff --git a/lib/performance-survey-image-source-guard.ts b/lib/performance-survey-image-source-guard.ts index ad1de21..6e21564 100644 --- a/lib/performance-survey-image-source-guard.ts +++ b/lib/performance-survey-image-source-guard.ts @@ -7,6 +7,9 @@ export const PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS = { sliceHeight: 1_500, maxSlicesPerCase: 60, headerReadBytes: 256 * 1024, + // 少数 JPEG 的 EXIF/ICC 段会超过首段读取范围;扩大到 4MB 仍远小于解码整图的成本, + // 用于在浏览器解码前继续拦截超大像素图片。 + extendedHeaderReadBytes: 4 * 1024 * 1024, } as const export type PerformanceSurveySourceImageDimensions = { diff --git a/lib/performance-survey-image.worker.ts b/lib/performance-survey-image.worker.ts index 267c6c1..e1037a7 100644 --- a/lib/performance-survey-image.worker.ts +++ b/lib/performance-survey-image.worker.ts @@ -54,15 +54,37 @@ function getCanvasContext(canvas: OffscreenCanvas) { async function decodeImage(file: File) { const header = new Uint8Array(await file.slice(0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.headerReadBytes).arrayBuffer()) - assertPerformanceSurveyBrowserSourceImageDimensions( - readPerformanceSurveySourceImageDimensions(header), - file.name, - ) + let headerDimensions = readPerformanceSurveySourceImageDimensions(header) + if (!headerDimensions && file.size > header.byteLength) { + // JPEG 的 EXIF/ICC 段可能超过 256KB。再读取有限的扩展头部,优先在解码前 + // 拦截超大像素图片;只有仍无法识别时才交给浏览器实际解码。 + const extendedHeader = new Uint8Array( + await file.slice(0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.extendedHeaderReadBytes).arrayBuffer(), + ) + headerDimensions = readPerformanceSurveySourceImageDimensions(extendedHeader) + } + // 常见图片会在文件头直接提供尺寸,先做保护性校验,避免超大图片进入浏览器解码。 + // JPEG 的 EXIF/ICC 段可能超过 256KB;头部暂时读不到 SOF 时交给浏览器实际解码, + // 再用解码后的尺寸做同一套限制,不能把合法图片误判成“不支持”。 + if (headerDimensions) { + assertPerformanceSurveyBrowserSourceImageDimensions(headerDimensions, file.name) + } + let bitmap: ImageBitmap try { - return await createImageBitmap(file) + bitmap = await createImageBitmap(file) } catch { throw new Error(`图片「${file.name}」无法解码,请确认文件没有损坏并使用 PNG、JPG 或 WebP 格式`) } + try { + assertPerformanceSurveyBrowserSourceImageDimensions( + { width: bitmap.width, height: bitmap.height }, + file.name, + ) + return bitmap + } catch (error) { + bitmap.close() + throw error + } } async function renderSlice(