fix: 修复任务详情 Edge 图片预览失败

This commit is contained in:
chenpei 2026-07-31 17:02:16 +08:00
parent 3731aea67c
commit 944c15f281
7 changed files with 185 additions and 47 deletions

View File

@ -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({

View File

@ -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<UploadedAsset['previewAssets']> = [];
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 ? '继续添加' : '选择详情图'}
</button>
<span className="text-xs text-gray-400">JPG / PNG / WebP,支持拖拽</span>
<span className="text-xs text-gray-400">JPG / PNG / GIF / WebP,支持拖拽</span>
</div>
{/* 详情图排序组件 */}

View File

@ -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' }),

View File

@ -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)

View File

@ -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',

View File

@ -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 = {

View File

@ -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(