fix: 修复任务详情 Edge 图片预览失败
This commit is contained in:
parent
3731aea67c
commit
944c15f281
@ -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({
|
||||
|
||||
@ -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>
|
||||
|
||||
{/* 详情图排序组件 */}
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user