fix: 加固任务详情上传稳定性

This commit is contained in:
chenpei 2026-08-02 01:04:11 +08:00
parent 873156ed0f
commit 3495530f3d
20 changed files with 1084 additions and 148 deletions

View File

@ -15,6 +15,7 @@ import { validateAndTriggerDownload } from '@/lib/client-download'
import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler'
import { normalizeVideoMaterialType, isVideoMaterialType as isVideoMaterialTypeValue } from '@/lib/video-material-types'
import { buildPublicMaterialShareUrl } from '@/lib/public-app-url'
import { normalizeMaterialPreviewUrl, pickFirstMaterialPreviewUrl } from '@/lib/material-preview-url'
// 首页拆分组件
import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home'
@ -590,19 +591,36 @@ export default function Home() {
const data = await response.json()
// 清理素材 ID,并为预览图补齐统一 fallback
const materialsList = ((data.materials || data) as Material[])
.map((e) => {
if (!e.id || typeof e.id !== 'string') return null
.flatMap((e): Material[] => {
if (!e.id || typeof e.id !== 'string') return []
const trimmedId = e.id.trim()
if (!trimmedId) return null
if (!trimmedId) return []
// previewUrl fallback逻辑
const previewUrl = e.previewUrl ||
e.videoCoverUrl ||
(e.displayImages && e.displayImages.length > 0 ? e.displayImages[0] : '')
const displayImages = (Array.isArray(e.displayImages) ? e.displayImages : [])
.map(normalizeMaterialPreviewUrl)
.filter((url): url is string => Boolean(url))
const videoSourceUrl = normalizeMaterialPreviewUrl(e.videoSourceUrl)
const videoPreview540Url = normalizeMaterialPreviewUrl(e.videoPreview540Url)
const videoPreviewClipUrl = normalizeMaterialPreviewUrl(e.videoPreviewClipUrl)
const videoCoverUrl = normalizeMaterialPreviewUrl(e.videoCoverUrl)
// 只允许非空字符串进入预览 URL,避免历史异常布尔值挡住后续合法地址。
const previewUrl = pickFirstMaterialPreviewUrl(
e.previewUrl,
videoCoverUrl,
...displayImages,
) || ''
return { ...e, id: trimmedId, previewUrl }
return [{
...e,
id: trimmedId,
previewUrl,
displayImages,
videoSourceUrl,
videoPreview540Url,
videoPreviewClipUrl,
videoCoverUrl,
}]
})
.filter((e): e is Material => e !== null)
let appendedItems: Material[] = materialsList
if (append) {
@ -729,9 +747,10 @@ export default function Home() {
? {
...m,
videoProcessingStatus: data.status,
videoPreview540Url: m.videoPreview540Url || data.hasPreview540 ? data.hasPreview540 : null,
videoPreviewClipUrl: m.videoPreviewClipUrl || data.hasClip ? data.hasClip : null,
videoCoverUrl: m.videoCoverUrl || data.hasCover ? data.hasCover : null,
// 状态接口只有 has* 布尔标志,真实 URL 由完成后的列表刷新取得。
videoPreview540Url: normalizeMaterialPreviewUrl(m.videoPreview540Url),
videoPreviewClipUrl: normalizeMaterialPreviewUrl(m.videoPreviewClipUrl),
videoCoverUrl: normalizeMaterialPreviewUrl(m.videoCoverUrl),
videoDuration: data.duration ?? m.videoDuration,
transcodeAttempts: data.attempts ?? m.transcodeAttempts,
transcodeProgress: data.progress ?? m.transcodeProgress,

View File

@ -1,7 +1,9 @@
import { act, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ProductPackageUploader from '@/components/upload/ProductPackageUploader'
import ProductPackageUploader, {
selectTaskDetailFilesWithinLimits,
} from '@/components/upload/ProductPackageUploader'
import { PerformanceSurveyBrowserImageError } from '@/lib/performance-survey-image-error'
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
@ -296,6 +298,135 @@ describe('ProductPackageUploader', () => {
.toBe('image/jpeg,image/png,image/gif,image/webp')
})
test('选图门禁逐个保留合格文件,并覆盖单组、总图片、总条目和总大小限制', () => {
const file = (name: string, size: number) => {
const value = new File(['x'], name, { type: 'image/jpeg' })
Object.defineProperty(value, 'size', { configurable: true, value: size })
return value
}
const emptySnapshot = () => ({ main: [], sku: [], detail: [], psd: [] })
const baseLimits = {
maxImagesPerGroup: 10,
maxTotalImageCount: 10,
maxPsdFileCount: 10,
maxTotalSourceFileCount: 10,
maxTotalSourceBytes: 100,
}
const groupResult = selectTaskDetailFilesWithinLimits(
'main',
[file('a.jpg', 1), file('b.jpg', 1)],
emptySnapshot(),
{ ...baseLimits, maxImagesPerGroup: 1 },
)
expect(groupResult.acceptedFiles.map((item) => item.name)).toEqual(['a.jpg'])
expect(groupResult.errors.join('')).toContain('主图最多选择 1 张')
const psdGroupResult = selectTaskDetailFilesWithinLimits(
'psd',
[file('source-a.psd', 1), file('source-b.psd', 1)],
emptySnapshot(),
{ ...baseLimits, maxPsdFileCount: 1 },
)
expect(psdGroupResult.acceptedFiles.map((item) => item.name)).toEqual(['source-a.psd'])
expect(psdGroupResult.errors.join('')).toContain('PSD 源文件最多选择 1 个')
const totalImageResult = selectTaskDetailFilesWithinLimits(
'detail',
[file('detail.jpg', 1)],
{ ...emptySnapshot(), main: [file('existing.jpg', 1)] },
{ ...baseLimits, maxTotalImageCount: 1 },
)
expect(totalImageResult.acceptedFiles).toHaveLength(0)
expect(totalImageResult.errors.join('')).toContain('本次图片合计最多 1 张')
const totalSourceResult = selectTaskDetailFilesWithinLimits(
'main',
[file('new.jpg', 1)],
{ ...emptySnapshot(), psd: [file('existing.psd', 1)] },
{ ...baseLimits, maxTotalSourceFileCount: 1 },
)
expect(totalSourceResult.acceptedFiles).toHaveLength(0)
expect(totalSourceResult.errors.join('')).toContain('原图和源文件合计最多 1 个')
const totalBytesResult = selectTaskDetailFilesWithinLimits(
'sku',
[file('fits.jpg', 1), file('too-much.jpg', 2)],
{ ...emptySnapshot(), main: [file('existing.jpg', 2)] },
{ ...baseLimits, maxTotalSourceBytes: 4 },
)
expect(totalBytesResult.acceptedFiles.map((item) => item.name)).toEqual(['fits.jpg'])
expect(totalBytesResult.errors.join('')).toContain('原图和源文件总大小最多 4B')
})
test('快速连续选图使用同步快照计数,超限文件不创建 Blob URL', async () => {
const mutableLimits = TASK_DETAIL_UPLOAD_LIMITS as unknown as { maxImagesPerGroup: number }
const originalLimit = mutableLimits.maxImagesPerGroup
mutableLimits.maxImagesPerGroup = 2
try {
render(<ProductPackageUploader {...defaultProps} />)
await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员')
const input = screen.getByLabelText('主图') as HTMLInputElement
const first = new File(['first'], 'first.jpg', { type: 'image/jpeg' })
const second = new File(['second'], 'second.jpg', { type: 'image/jpeg' })
const third = new File(['third'], 'third.jpg', { type: 'image/jpeg' })
act(() => {
fireEvent.change(input, { target: { files: [first] } })
fireEvent.change(input, { target: { files: [second, third] } })
})
expect(URL.createObjectURL).toHaveBeenCalledTimes(2)
expect(screen.getByRole('button', { name: '删除主图 first.jpg' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '删除主图 second.jpg' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '删除主图 third.jpg' })).not.toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('主图最多选择 2 张')
expect(screen.getByRole('alert')).toHaveTextContent('请删除不需要的文件,或分批提交')
expect(screen.getByRole('alert')).toHaveTextContent('其余 1 张符合限制的文件已保留')
} finally {
mutableLimits.maxImagesPerGroup = originalLimit
}
})
test('取消上传会 abort 正在运行的浏览器图片预处理', async () => {
const user = userEvent.setup()
jest.spyOn(console, 'error').mockImplementation(() => undefined)
const previewDeferred = createDeferred<never>()
let previewSignal: AbortSignal | undefined
mockPrepareTaskDetailImagePreview.mockImplementationOnce((
_file: File,
_group: string,
_onProgress: unknown,
options?: { signal?: AbortSignal },
) => {
previewSignal = options?.signal
previewSignal?.addEventListener('abort', () => {
previewDeferred.reject(new DOMException('图片处理已取消', 'AbortError'))
}, { once: true })
return previewDeferred.promise
})
const fetchMock = installUploadFlowFetch()
render(<ProductPackageUploader {...defaultProps} />)
await fillRequiredTaskFields(
user,
new File(['image'], 'cancel-processing.jpg', { type: 'image/jpeg' }),
)
await user.click(screen.getByRole('button', { name: '提交' }))
await waitFor(() => expect(previewSignal).toBeDefined())
await user.click(screen.getByRole('button', { name: '取消上传' }))
expect(previewSignal?.aborted).toBe(true)
expect(await screen.findByText('已取消上传')).toBeInTheDocument()
expect(mockChunkUpload).not.toHaveBeenCalled()
expect(fetchMock).toHaveBeenCalledWith(
'/api/materials/upload/product-package/abort',
expect.objectContaining({ method: 'POST' }),
)
})
test('Edge 浏览器预览处理失败时继续上传原图并显式请求服务器兜底', async () => {
const user = userEvent.setup()
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)

View File

@ -4,7 +4,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import DetailImageSorter from './DetailImageSorter';
import UploadForm from './UploadForm';
import { addCategory, addScene } from './category-handlers';
@ -102,6 +102,113 @@ interface AssetFile {
}
type UploadGroup = 'main' | 'sku' | 'detail' | 'psd';
type ImageUploadGroup = Exclude<UploadGroup, 'psd'>;
type TaskDetailFileSelectionSnapshot = Record<UploadGroup, File[]>;
type TaskDetailFileSelectionLimitKey =
| 'maxImagesPerGroup'
| 'maxTotalImageCount'
| 'maxPsdFileCount'
| 'maxTotalSourceFileCount'
| 'maxTotalSourceBytes';
type TaskDetailFileSelectionLimits = Readonly<Record<TaskDetailFileSelectionLimitKey, number>>;
type SelectionLimitReason = 'group' | 'totalImages' | 'totalSources' | 'totalBytes';
const UPLOAD_GROUP_LABELS: Record<UploadGroup, string> = {
main: '主图',
sku: 'SKU 图',
detail: '详情图',
psd: 'PSD 源文件',
};
function formatSelectionLimitBytes(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) return `${Math.round(bytes / 1024 / 1024 / 1024)}GB`;
if (bytes >= 1024 * 1024) return `${Math.round(bytes / 1024 / 1024)}MB`;
if (bytes >= 1024) return `${Math.round(bytes / 1024)}KB`;
return `${bytes}B`;
}
function summarizeRejectedFiles(files: File[]): string {
const names = files.slice(0, 3).map((file) => file.name || '未命名文件');
return files.length > names.length
? `${names.join('、')}等 ${files.length} 个文件`
: names.join('、');
}
export function selectTaskDetailFilesWithinLimits(
group: UploadGroup,
candidates: File[],
existing: TaskDetailFileSelectionSnapshot,
limits: TaskDetailFileSelectionLimits = TASK_DETAIL_UPLOAD_LIMITS,
): { acceptedFiles: File[]; errors: string[] } {
const rejected: Record<SelectionLimitReason, File[]> = {
group: [],
totalImages: [],
totalSources: [],
totalBytes: [],
};
const acceptedFiles: File[] = [];
const isImageGroup = group !== 'psd';
let groupCount = existing[group].length;
let totalImageCount = existing.main.length + existing.sku.length + existing.detail.length;
let totalSourceCount = totalImageCount + existing.psd.length;
let totalSourceBytes = Object.values(existing)
.flat()
.reduce((sum, file) => sum + Math.max(0, Number(file.size) || 0), 0);
candidates.forEach((file) => {
const groupLimit = isImageGroup ? limits.maxImagesPerGroup : limits.maxPsdFileCount;
let reason: SelectionLimitReason | null = null;
if (groupCount >= groupLimit) {
reason = 'group';
} else if (isImageGroup && totalImageCount >= limits.maxTotalImageCount) {
reason = 'totalImages';
} else if (totalSourceCount >= limits.maxTotalSourceFileCount) {
reason = 'totalSources';
} else if (totalSourceBytes + file.size > limits.maxTotalSourceBytes) {
reason = 'totalBytes';
}
if (reason) {
rejected[reason].push(file);
return;
}
acceptedFiles.push(file);
groupCount += 1;
if (isImageGroup) totalImageCount += 1;
totalSourceCount += 1;
totalSourceBytes += file.size;
});
const errors: string[] = [];
const groupLimit = isImageGroup ? limits.maxImagesPerGroup : limits.maxPsdFileCount;
if (rejected.group.length > 0) {
errors.push(
`${UPLOAD_GROUP_LABELS[group]}最多选择 ${groupLimit} ${isImageGroup ? '张' : '个'},已忽略 ${summarizeRejectedFiles(rejected.group)};请删除不需要的文件,或分批提交`,
);
}
if (rejected.totalImages.length > 0) {
errors.push(
`本次图片合计最多 ${limits.maxTotalImageCount} 张,已忽略 ${summarizeRejectedFiles(rejected.totalImages)};请减少其他分组的图片,或分批提交`,
);
}
if (rejected.totalSources.length > 0) {
errors.push(
`本次原图和源文件合计最多 ${limits.maxTotalSourceFileCount} 个,已忽略 ${summarizeRejectedFiles(rejected.totalSources)};请删除部分已选文件,或分批提交`,
);
}
if (rejected.totalBytes.length > 0) {
errors.push(
`本次原图和源文件总大小最多 ${formatSelectionLimitBytes(limits.maxTotalSourceBytes)},已忽略 ${summarizeRejectedFiles(rejected.totalBytes)};请压缩、删除部分文件,或分批提交`,
);
}
return { acceptedFiles, errors };
}
type UploadedAsset = {
fileName: string;
@ -204,11 +311,24 @@ export default function ProductPackageUploader({
const uploadStopRef = useRef(false);
const activeXhrsRef = useRef<Set<XMLHttpRequest>>(new Set());
const activeXhrSetsRef = useRef<Map<string, Set<XMLHttpRequest>>>(new Map());
const browserPreviewAbortRef = useRef<AbortController | null>(null);
const jobPollAbortRef = useRef<AbortController | null>(null);
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const progressRef = useRef(0);
const jobAcceptedRef = useRef(false);
const assetGroupsRef = useRef<AssetFile[][]>([]);
const selectionSnapshotRef = useRef<TaskDetailFileSelectionSnapshot>({
main: [],
sku: [],
detail: [],
psd: [],
});
const abortActiveBrowserPreview = useCallback(() => {
const controller = browserPreviewAbortRef.current;
if (controller) controller.abort();
browserPreviewAbortRef.current = null;
}, []);
useEffect(() => {
assetGroupsRef.current = [mainImages, skuImages, detailImages, psdFiles];
@ -227,11 +347,12 @@ export default function ProductPackageUploader({
activeXhrsRef.current.forEach((xhr) => xhr.abort());
activeXhrsRef.current.clear();
activeXhrSets.clear();
abortActiveBrowserPreview();
jobPollAbortRef.current?.abort();
if (completionTimerRef.current) clearTimeout(completionTimerRef.current);
revokeAssetPreviews(assetGroupsRef.current);
};
}, []);
}, [abortActiveBrowserPreview]);
useEffect(() => {
let active = true;
@ -322,11 +443,48 @@ export default function ProductPackageUploader({
prepareFileDrag(e, uploading);
};
const getSelectionSnapshot = (): TaskDetailFileSelectionSnapshot => selectionSnapshotRef.current;
const appendSelectedFiles = (group: UploadGroup, files: File[]) => {
if (files.length === 0) return;
const preparedFiles: AssetFile[] = files.map((file) => ({
file,
preview: group === 'psd' ? '' : URL.createObjectURL(file),
id: Math.random().toString(36).substring(2, 11),
sortOrder: 0,
}));
selectionSnapshotRef.current = {
...selectionSnapshotRef.current,
[group]: [...selectionSnapshotRef.current[group], ...files],
};
const append = (previous: AssetFile[]) => [
...previous,
...preparedFiles.map((item, index) => ({ ...item, sortOrder: previous.length + index })),
];
if (group === 'main') setMainImages(append);
else if (group === 'sku') setSkuImages(append);
else if (group === 'detail') setDetailImages(append);
else setPsdFiles(append);
};
const setSelectionFeedback = (
validationErrors: string[],
acceptedCount: number,
unit: '张' | '个',
) => {
const messages = [...validationErrors];
if (messages.length > 0 && acceptedCount > 0) {
messages.push(`其余 ${acceptedCount} ${unit}符合限制的文件已保留,可继续补充或直接提交`);
}
setError(messages.join(';'));
};
const handleDrop = (
e: React.DragEvent,
setter: React.Dispatch<React.SetStateAction<AssetFile[]>>,
group: UploadGroup,
dragSetter: (value: boolean) => void,
accept: 'image' | 'file' | 'psd'
accept: 'image' | 'psd'
) => {
prepareFileDrag(e, uploading);
dragSetter(false);
@ -337,51 +495,16 @@ export default function ProductPackageUploader({
const files = Array.from(e.dataTransfer.files);
if (accept === 'image') {
handleImageSelect(files, setter);
handleImageSelect(files, group as ImageUploadGroup);
} else if (accept === 'psd') {
// PSD文件拖拽(支持多个文件)
const psdFiles: File[] = [];
const invalidFiles: string[] = [];
const { validFiles, emptyFileNames } = splitEmptyFiles(files);
validFiles.forEach((file) => {
const ext = file.name.split('.').pop()?.toLowerCase();
if (TASK_DETAIL_SOURCE_FILE_EXTENSIONS.includes((ext || '') as typeof TASK_DETAIL_SOURCE_FILE_EXTENSIONS[number])) {
psdFiles.push(file);
} else {
invalidFiles.push(file.name);
}
});
const errors: string[] = [];
if (emptyFileNames.length > 0) {
errors.push(`以下文件为空,无法上传:${emptyFileNames.join(', ')}`);
}
if (invalidFiles.length > 0) {
errors.push(`以下文件格式不支持:${invalidFiles.join(', ')},请上传 ${TASK_DETAIL_SOURCE_FILE_LABEL} 文件`);
}
if (psdFiles.length > 0) {
setPsdFiles((prev) => {
const newFiles: AssetFile[] = psdFiles.map((file, index) => ({
file,
preview: '', // PSD文件不需要预览
id: Math.random().toString(36).substring(2, 11),
sortOrder: prev.length + index,
}));
return [...prev, ...newFiles];
});
setError(errors.join(';'));
} else if (errors.length > 0) {
setError(errors.join(';'));
}
handlePsdSelect(files);
}
};
// 处理图片选择
const handleImageSelect = (
files: FileList | File[] | null,
setter: React.Dispatch<React.SetStateAction<AssetFile[]>>,
group: ImageUploadGroup,
) => {
if (!files) return;
if (uploading) {
@ -396,7 +519,7 @@ export default function ProductPackageUploader({
const oversizedFiles = formatAcceptedFiles.filter(
(file) => file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes,
);
const acceptedFiles = formatAcceptedFiles.filter(
const singleSizeAcceptedFiles = formatAcceptedFiles.filter(
(file) => file.size <= TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes,
);
const validationErrors = [...errors];
@ -405,36 +528,44 @@ export default function ProductPackageUploader({
}
if (oversizedFiles.length > 0) {
validationErrors.push(
`以下图片超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制:${oversizedFiles.map((file) => file.name).join('、')}`,
`以下图片超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制:${oversizedFiles.map((file) => file.name).join('、')};请先压缩或拆分后重新选择`,
);
}
setError(validationErrors.join(';'));
setter((prev) => {
const startOrder = prev.length;
const newFiles: AssetFile[] = acceptedFiles.map((file, index) => ({
file,
preview: URL.createObjectURL(file),
id: Math.random().toString(36).substring(2, 11),
sortOrder: startOrder + index,
}));
return [...prev, ...newFiles];
});
const selection = selectTaskDetailFilesWithinLimits(
group,
singleSizeAcceptedFiles,
getSelectionSnapshot(),
);
appendSelectedFiles(group, selection.acceptedFiles);
setSelectionFeedback(
[...validationErrors, ...selection.errors],
selection.acceptedFiles.length,
'张',
);
};
// 删除图片
const handleRemoveImage = (
id: string,
setter: React.Dispatch<React.SetStateAction<AssetFile[]>>
) => {
setter((prev) => {
const removed = prev.find((item) => item.id === id);
if (removed) {
URL.revokeObjectURL(removed.preview);
}
return prev.filter((item) => item.id !== id);
});
const handleRemoveFile = (id: string, group: UploadGroup) => {
const assets = group === 'main'
? mainImages
: group === 'sku'
? skuImages
: group === 'detail'
? detailImages
: psdFiles;
const removed = assets.find((item) => item.id === id);
if (!removed) return;
if (removed.preview) URL.revokeObjectURL(removed.preview);
selectionSnapshotRef.current = {
...selectionSnapshotRef.current,
[group]: selectionSnapshotRef.current[group].filter((file) => file !== removed.file),
};
if (group === 'main') setMainImages((previous) => previous.filter((item) => item.id !== id));
else if (group === 'sku') setSkuImages((previous) => previous.filter((item) => item.id !== id));
else if (group === 'detail') setDetailImages((previous) => previous.filter((item) => item.id !== id));
else setPsdFiles((previous) => previous.filter((item) => item.id !== id));
};
const updateSkuImageMeta = (id: string, updates: Partial<AssetFile>) => {
@ -444,7 +575,7 @@ export default function ProductPackageUploader({
};
// 处理 PSD 文件选择
const handlePsdSelect = (files: FileList | null) => {
const handlePsdSelect = (files: FileList | File[] | null) => {
if (!files || files.length === 0) return;
if (uploading) {
setError('文件正在上传,请等待完成后再添加');
@ -472,20 +603,17 @@ export default function ProductPackageUploader({
errors.push(`以下文件格式不支持:${invalidFiles.join(', ')},请上传 ${TASK_DETAIL_SOURCE_FILE_LABEL} 文件`);
}
if (validFiles.length > 0) {
setPsdFiles((prev) => {
const newFiles: AssetFile[] = validFiles.map((file, index) => ({
file,
preview: '', // PSD文件不需要预览
id: Math.random().toString(36).substring(2, 11),
sortOrder: prev.length + index,
}));
return [...prev, ...newFiles];
});
setError(errors.join(';'));
} else if (errors.length > 0) {
setError(errors.join(';'));
}
const selection = selectTaskDetailFilesWithinLimits(
'psd',
validFiles,
getSelectionSnapshot(),
);
appendSelectedFiles('psd', selection.acceptedFiles);
setSelectionFeedback(
[...errors, ...selection.errors],
selection.acceptedFiles.length,
'个',
);
};
// 使用分片上传到本地服务器(和任务-3D原图一样)。
@ -553,6 +681,7 @@ export default function ProductPackageUploader({
uploadStopRef.current = true;
// 第一处失败出现后立即停止同批网络传输;consumer 仍需全部 settle,
// 这样稍后完成的上传 ID 能进入最终一次素材回滚请求。
abortActiveBrowserPreview();
activeXhrsRef.current.forEach((xhr) => xhr.abort());
};
@ -653,6 +782,7 @@ export default function ProductPackageUploader({
cancelledRef.current = false;
uploadStopRef.current = false;
jobAcceptedRef.current = false;
abortActiveBrowserPreview();
jobPollAbortRef.current?.abort();
let createdMaterialId: string | null = null;
@ -732,15 +862,20 @@ export default function ProductPackageUploader({
let browserPreviewBusy = false;
const browserPreviewWaiters: Array<() => void> = [];
const withBrowserPreviewSlot = async <T,>(worker: () => Promise<T>): Promise<T> => {
const withBrowserPreviewSlot = async <T,>(worker: (signal: AbortSignal) => Promise<T>): Promise<T> => {
if (browserPreviewBusy) {
await new Promise<void>((resolve) => browserPreviewWaiters.push(resolve));
}
browserPreviewBusy = true;
const previewController = new AbortController();
browserPreviewAbortRef.current = previewController;
try {
if (cancelledRef.current || uploadStopRef.current) throw new Error('上传已取消');
return await worker();
return await worker(previewController.signal);
} finally {
if (browserPreviewAbortRef.current === previewController) {
browserPreviewAbortRef.current = null;
}
browserPreviewBusy = false;
browserPreviewWaiters.shift()?.();
}
@ -756,13 +891,14 @@ export default function ProductPackageUploader({
let browserPreviews: TaskDetailBrowserPreview[] = [];
let browserPreviewFailed = false;
try {
browserPreviews = await withBrowserPreviewSlot(() =>
browserPreviews = await withBrowserPreviewSlot((signal) =>
prepareTaskDetailImagePreview(
asset.file,
group as TaskDetailAssetKind,
(label) => {
if (mountedRef.current) setProcessingStep(`${asset.file.name}:${label}`);
},
{ signal },
),
);
} catch (previewError) {
@ -961,6 +1097,7 @@ export default function ProductPackageUploader({
activeXhrsRef.current.forEach((xhr) => xhr.abort());
activeXhrsRef.current.clear();
activeXhrSetsRef.current.clear();
abortActiveBrowserPreview();
jobPollAbortRef.current = null;
uploadStopRef.current = true;
if (mountedRef.current) setUploading(false);
@ -977,6 +1114,7 @@ export default function ProductPackageUploader({
activeXhrsRef.current.forEach((xhr) => xhr.abort());
activeXhrsRef.current.clear();
activeXhrSetsRef.current.clear();
abortActiveBrowserPreview();
jobPollAbortRef.current?.abort();
setProcessingStep(jobAcceptedRef.current ? '已停止等待,后台任务继续处理...' : '正在取消上传...');
};
@ -1084,7 +1222,7 @@ export default function ProductPackageUploader({
onDragEnter={(e) => handleDragEnter(e, setIsMainDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsMainDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, setMainImages, setIsMainDragging, 'image')}
onDrop={(e) => handleDrop(e, 'main', setIsMainDragging, 'image')}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => mainInputRef.current?.click())}
>
<input
@ -1093,7 +1231,10 @@ export default function ProductPackageUploader({
type="file"
accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT}
multiple
onChange={(e) => handleImageSelect(e.target.files, setMainImages)}
onChange={(e) => {
handleImageSelect(e.target.files, 'main');
e.currentTarget.value = '';
}}
className="hidden"
disabled={uploading}
/>
@ -1104,7 +1245,7 @@ export default function ProductPackageUploader({
<img src={img.preview} alt="" className="h-full w-full bg-gray-50 object-contain dark:bg-gray-800" />
</div>
<button
onClick={() => handleRemoveImage(img.id, setMainImages)}
onClick={() => handleRemoveFile(img.id, 'main')}
aria-label={`删除主图 ${img.file.name}`}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
disabled={uploading}
@ -1162,7 +1303,7 @@ export default function ProductPackageUploader({
onDragEnter={(e) => handleDragEnter(e, setIsSkuDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsSkuDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, setSkuImages, setIsSkuDragging, 'image')}
onDrop={(e) => handleDrop(e, 'sku', setIsSkuDragging, 'image')}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => skuInputRef.current?.click())}
>
<input
@ -1171,7 +1312,10 @@ export default function ProductPackageUploader({
type="file"
accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT}
multiple
onChange={(e) => handleImageSelect(e.target.files, setSkuImages)}
onChange={(e) => {
handleImageSelect(e.target.files, 'sku');
e.currentTarget.value = '';
}}
className="hidden"
disabled={uploading}
/>
@ -1182,7 +1326,7 @@ export default function ProductPackageUploader({
<img src={img.preview} alt="" className="h-full w-full bg-gray-50 object-contain dark:bg-gray-800" />
</div>
<button
onClick={() => handleRemoveImage(img.id, setSkuImages)}
onClick={() => handleRemoveFile(img.id, 'sku')}
aria-label={`删除 SKU 图 ${img.file.name}`}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
disabled={uploading}
@ -1246,7 +1390,7 @@ export default function ProductPackageUploader({
onDragEnter={(e) => handleDragEnter(e, setIsDetailDragging)}
onDragLeave={(e) => handleDragLeave(e, setIsDetailDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, setDetailImages, setIsDetailDragging, 'image')}
onDrop={(e) => handleDrop(e, 'detail', setIsDetailDragging, 'image')}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => detailInputRef.current?.click())}
>
<input
@ -1255,7 +1399,10 @@ export default function ProductPackageUploader({
type="file"
accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT}
multiple
onChange={(e) => handleImageSelect(e.target.files, setDetailImages)}
onChange={(e) => {
handleImageSelect(e.target.files, 'detail');
e.currentTarget.value = '';
}}
className="hidden"
disabled={uploading}
/>
@ -1277,7 +1424,7 @@ export default function ProductPackageUploader({
<DetailImageSorter
images={detailImages}
onChange={setDetailImages}
onRemove={(id) => handleRemoveImage(id, setDetailImages)}
onRemove={(id) => handleRemoveFile(id, 'detail')}
disabled={uploading}
/>
)}
@ -1306,7 +1453,7 @@ export default function ProductPackageUploader({
onDragLeave={(e) => handleDragLeave(e, setIsPsdDragging)}
onDragOver={handleDragOver}
onDrop={(e) => {
handleDrop(e, setDetailImages, setIsPsdDragging, 'psd');
handleDrop(e, 'psd', setIsPsdDragging, 'psd');
}}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => psdInputRef.current?.click())}
>
@ -1316,7 +1463,10 @@ export default function ProductPackageUploader({
type="file"
accept={TASK_DETAIL_SOURCE_FILE_ACCEPT}
multiple
onChange={(e) => handlePsdSelect(e.target.files)}
onChange={(e) => {
handlePsdSelect(e.target.files);
e.currentTarget.value = '';
}}
className="hidden"
disabled={uploading}
/>
@ -1337,7 +1487,7 @@ export default function ProductPackageUploader({
<span key={psdFile.id} className="inline-flex items-center gap-1 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs text-gray-700 dark:text-gray-300">
{psdFile.file.name}
<button
onClick={() => setPsdFiles((prev) => prev.filter((f) => f.id !== psdFile.id))}
onClick={() => handleRemoveFile(psdFile.id, 'psd')}
aria-label={`删除源文件 ${psdFile.file.name}`}
className="text-red-400 hover:text-red-600"
disabled={uploading}

View File

@ -106,13 +106,27 @@
## 四、其余未完成问题
### 下一批(直接影响上传稳定性和“未知预览”)
### 代码批次 2(2026-08-02 已完成本地修复,待提交和部署)
1. 首页视频预览 URL 的三元表达式优先级错误,可能把 URL 写成布尔值或空值。
2. 浏览器图片 Worker 没有取消和超时;异常时可能一直占用用户浏览器资源。
3. 选择图片时没有立即执行数量和总量门禁,先创建大量预览,到提交时才报错。
4. 上传状态轮询的 Abort listener 正常结束后未移除,长任务会逐步累积监听器。
5. 浏览器图片处理失败会重新把原图交给服务器处理,需要确认服务器兜底仍受像素、体积、并发和队列门禁保护。
本批原来的 5 项已完成代码修复:
1. 首页不再把转码状态接口的 `true/false` 写进视频 URL;首页收到的预览、封面、视频源和展示图也只保留非空字符串,异常布尔值、数字或对象不会挡住后续合法预览地址。
2. 浏览器图片 Worker 已支持用户取消和 10 分钟防卡死超时。成功、Worker 异常、消息异常、超时和取消都会清理定时器、监听器并终止 Worker;用户取消不会误走服务器兜底,损坏文件和像素/宽度/切片超限仍直接报错。
3. 图片和 PSD 在选择或拖拽时立即执行单组数量、图片总数、源文件总数和 1GB 总量门禁;超限文件不会创建 Blob 预览,合规文件会保留并给出明确处理提示。同步快照同时防止快速连续选图绕过限制。
4. 上传状态轮询每次正常等待结束后都会移除 Abort listener,长任务不再逐轮累积监听器。
5. 服务器兜底链路确认仍受单图 100MB、总量 1GB、60MP、切片数量、单用户/全站队列和全局图片并发门禁保护;同时把客户端预览与服务器源图的 Sharp 元数据读取纳入统一门禁。分片组装改为先核对物理总字节,再用固定 1MiB 缓冲区顺序复制,不再把整个分片一次性读入内存。
本批没有把正常文件上传并发降为 1:网络文件并发仍为 2,每个文件的分片并发策略保持不变;单槽位只用于浏览器图片解码和服务器图片计算。
已完成验证:
- 直接相关 10 个测试套件、88 项测试全部通过。
- 全量 Jest 回归共 146 个测试套件、933 项测试全部通过。
- TypeScript 类型检查通过;本批文件 ESLint 为 0 error,只有原文件已有的 Hook 与 `<img>` 提示。
- Next.js 生产编译成功;Impeccable 前端检测没有发现新增问题。
- 本机隔离工作树通过 junction 复用原项目 `node_modules`,standalone 文件追踪阶段记录了 Windows `EPERM` 符号链接提示,因此不能把本机构建当成最终容器打包验收。
尚未验证:真实 Chrome/Edge 大图、断网/取消、线上 S3 上传,以及部署后的生产容器内存峰值和真实页面验收。这些必须在部署窗口完成后才能把本批标记为“生产已关闭”。
### 后续 P1
@ -136,7 +150,7 @@
1. **已完成运行态收口**:旧影子服务和 FRP 映射已停止;继续核对旧凭据是否失效及相关访问日志。
2. **已暂缓**:uploads 数据集根目录权限保持现状,等待用户后续重新授权。
3. **代码批次 1**:P1-1/P1-3 已提交并推送,待合并和部署;上线后验收网络状态 GET、POST 405、历史图片/视频预览和 Range 播放。
4. **代码批次 2**:修复“未知预览”与浏览器图片 Worker 取消/超时、选图即时门禁、轮询监听器清理。
4. **代码批次 2**:已完成本地修复和全量回归,待提交、部署及真实 Chrome/Edge 与生产内存验收。
5. **代码批次 3**:继续处理上传/ZIP 内存、长期下载 Token、权限和数据库一致性。
当前可以确认“旧影子漏洞入口已不可达”,但在旧凭据失效状态和访问日志核对完成前,不能把历史凭据事件标记为完全结案;`uploads` 的 `0777` 权限问题按用户决定暂缓,仍属于已知未关闭项。

View File

@ -0,0 +1,63 @@
/** @jest-environment node */
describe('image-processing-limiter', () => {
afterEach(() => {
delete process.env.IMAGE_PROCESSING_MAX_CONCURRENT
})
test('并发为 2 时同时释放两个任务,名额仍先直接转交给既有等待者', async () => {
jest.resetModules()
process.env.IMAGE_PROCESSING_MAX_CONCURRENT = '2'
const { withImageProcessingSlot } = await import('@/lib/image-processing-limiter')
const releases: Array<() => void> = []
const started: number[] = []
let active = 0
let peak = 0
const runTask = (index: number) => withImageProcessingSlot(() => {
active += 1
peak = Math.max(peak, active)
started.push(index)
return new Promise<void>((resolve) => {
releases[index] = () => {
active -= 1
resolve()
}
})
})
const firstTask = runTask(0)
const secondTask = runTask(1)
const firstQueuedTask = runTask(2)
const secondQueuedTask = runTask(3)
await new Promise<void>((resolve) => setImmediate(resolve))
expect(started).toEqual([0, 1])
releases[0]()
releases[1]()
// 两个 release continuation 已排入微任务后再发起新请求。旧实现会先把
// active 降到 0,新请求 4 因而抢入,随后等待者 2、3 继续自增到 3。
const lateTask = Promise.resolve().then(() => runTask(4))
await new Promise<void>((resolve) => setImmediate(resolve))
expect(started).toEqual([0, 1, 2, 3])
expect(peak).toBe(2)
releases[2]()
releases[3]()
await new Promise<void>((resolve) => setImmediate(resolve))
expect(started).toEqual([0, 1, 2, 3, 4])
releases[4]()
await Promise.all([
firstTask,
secondTask,
firstQueuedTask,
secondQueuedTask,
lateTask,
])
expect(peak).toBe(2)
})
})

View File

@ -0,0 +1,33 @@
import {
normalizeMaterialPreviewUrl,
pickFirstMaterialPreviewUrl,
} from '@/lib/material-preview-url'
describe('material-preview-url', () => {
test('URL 字段只保留非空字符串', () => {
expect(normalizeMaterialPreviewUrl('/uploads/preview.webp')).toBe('/uploads/preview.webp')
expect(normalizeMaterialPreviewUrl(' https://cdn.example.com/cover.jpg '))
.toBe('https://cdn.example.com/cover.jpg')
expect(normalizeMaterialPreviewUrl(true)).toBeNull()
expect(normalizeMaterialPreviewUrl(false)).toBeNull()
expect(normalizeMaterialPreviewUrl(null)).toBeNull()
expect(normalizeMaterialPreviewUrl(1)).toBeNull()
expect(normalizeMaterialPreviewUrl({ url: '/not-a-string.jpg' })).toBeNull()
expect(normalizeMaterialPreviewUrl(' ')).toBeNull()
})
test('合法相对地址和带签名的绝对地址保持兼容', () => {
const signedUrl = 'https://cdn.example.com/preview.webp?token=a%2Bb&expires=999'
expect(normalizeMaterialPreviewUrl('/uploads/preview.webp')).toBe('/uploads/preview.webp')
expect(normalizeMaterialPreviewUrl(signedUrl)).toBe(signedUrl)
})
test('异常布尔值或空值不会挡住后续合法预览图', () => {
expect(pickFirstMaterialPreviewUrl(
true,
null,
'',
' /uploads/from-display-images.webp ',
)).toBe('/uploads/from-display-images.webp')
})
})

View File

@ -0,0 +1,177 @@
import {
PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS,
preparePerformanceSurveyImagesInBrowser,
} from '@/lib/performance-survey-image-browser'
class FakeWorker {
static instances: FakeWorker[] = []
static postMessageError: Error | null = null
onmessage: ((event: MessageEvent) => void) | null = null
onerror: ((event: ErrorEvent) => void) | null = null
onmessageerror: ((event: MessageEvent) => void) | null = null
postMessage = jest.fn((_message: unknown) => {
if (FakeWorker.postMessageError) throw FakeWorker.postMessageError
})
terminate = jest.fn()
constructor() {
FakeWorker.instances.push(this)
}
}
describe('performance-survey-image-browser', () => {
const originalWorker = Object.getOwnPropertyDescriptor(globalThis, 'Worker')
const originalOffscreenCanvas = Object.getOwnPropertyDescriptor(globalThis, 'OffscreenCanvas')
const originalCreateImageBitmap = Object.getOwnPropertyDescriptor(globalThis, 'createImageBitmap')
beforeEach(() => {
jest.useFakeTimers()
FakeWorker.instances = []
FakeWorker.postMessageError = null
Object.defineProperty(globalThis, 'Worker', {
configurable: true,
writable: true,
value: FakeWorker,
})
Object.defineProperty(globalThis, 'OffscreenCanvas', {
configurable: true,
writable: true,
value: class FakeOffscreenCanvas {},
})
Object.defineProperty(globalThis, 'createImageBitmap', {
configurable: true,
writable: true,
value: jest.fn(),
})
})
afterEach(() => {
jest.useRealTimers()
if (originalWorker) Object.defineProperty(globalThis, 'Worker', originalWorker)
else delete (globalThis as { Worker?: unknown }).Worker
if (originalOffscreenCanvas) Object.defineProperty(globalThis, 'OffscreenCanvas', originalOffscreenCanvas)
else delete (globalThis as { OffscreenCanvas?: unknown }).OffscreenCanvas
if (originalCreateImageBitmap) Object.defineProperty(globalThis, 'createImageBitmap', originalCreateImageBitmap)
else delete (globalThis as { createImageBitmap?: unknown }).createImageBitmap
})
test('用户取消会立即终止 Worker,且不会被识别为服务器兜底错误', async () => {
const controller = new AbortController()
const removeListenerSpy = jest.spyOn(controller.signal, 'removeEventListener')
const promise = preparePerformanceSurveyImagesInBrowser(
[new File(['image'], 'photo.jpg', { type: 'image/jpeg' })],
undefined,
{ profile: 'task_detail_preview', signal: controller.signal, timeoutMs: 60_000 },
)
const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' })
const worker = FakeWorker.instances[0]
expect(worker).toBeDefined()
controller.abort()
await rejection
expect(worker.terminate).toHaveBeenCalledTimes(1)
expect(removeListenerSpy).toHaveBeenCalled()
expect(jest.getTimerCount()).toBe(0)
})
test('watchdog 超时会终止 Worker 并返回独立 WORKER_TIMEOUT', async () => {
expect(PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS).toBe(10 * 60 * 1000)
const promise = preparePerformanceSurveyImagesInBrowser(
[new File(['image'], 'slow.jpg', { type: 'image/jpeg' })],
undefined,
{ profile: 'task_detail_preview', timeoutMs: 1_000 },
)
const rejection = expect(promise).rejects.toMatchObject({
code: 'WORKER_TIMEOUT',
message: expect.stringContaining('将改由服务器安全处理'),
})
await jest.advanceTimersByTimeAsync(1_000)
await rejection
const worker = FakeWorker.instances[0]
expect(worker.terminate).toHaveBeenCalledTimes(1)
expect(worker.onmessage).toBeNull()
expect(worker.onerror).toBeNull()
expect(worker.onmessageerror).toBeNull()
expect(jest.getTimerCount()).toBe(0)
})
test('成功返回时也清理 watchdog、Abort listener 和 Worker', async () => {
const controller = new AbortController()
const removeListenerSpy = jest.spyOn(controller.signal, 'removeEventListener')
const promise = preparePerformanceSurveyImagesInBrowser(
[new File(['image'], 'success.jpg', { type: 'image/jpeg' })],
undefined,
{ profile: 'task_detail_preview', signal: controller.signal, timeoutMs: 60_000 },
)
const worker = FakeWorker.instances[0]
const request = worker.postMessage.mock.calls[0][0] as { id: string }
worker.onmessage?.({
data: {
id: request.id,
ok: true,
images: [{
blob: new Blob(['preview'], { type: 'image/webp' }),
fileName: 'success-preview.webp',
mimeType: 'image/webp',
width: 800,
height: 600,
kind: 'preview',
sourceName: 'success.jpg',
sourceIndex: 0,
sliceIndex: 0,
sliceCount: 1,
}],
},
} as MessageEvent)
await expect(promise).resolves.toEqual([
expect.objectContaining({
file: expect.any(File),
sourceName: 'success.jpg',
}),
])
expect(worker.terminate).toHaveBeenCalledTimes(1)
expect(removeListenerSpy).toHaveBeenCalled()
expect(jest.getTimerCount()).toBe(0)
})
test('postMessage 失败时清理 watchdog、Abort listener 和 Worker', async () => {
FakeWorker.postMessageError = new Error('structured clone failed')
const controller = new AbortController()
const removeListenerSpy = jest.spyOn(controller.signal, 'removeEventListener')
const promise = preparePerformanceSurveyImagesInBrowser(
[new File(['image'], 'clone-error.jpg', { type: 'image/jpeg' })],
undefined,
{ profile: 'task_detail_preview', signal: controller.signal, timeoutMs: 60_000 },
)
await expect(promise).rejects.toMatchObject({ code: 'WORKER_UNAVAILABLE' })
const worker = FakeWorker.instances[0]
expect(worker.terminate).toHaveBeenCalledTimes(1)
expect(removeListenerSpy).toHaveBeenCalled()
expect(jest.getTimerCount()).toBe(0)
})
test('Worker 运行错误时只结算一次并清理资源', async () => {
const promise = preparePerformanceSurveyImagesInBrowser(
[new File(['image'], 'worker-error.jpg', { type: 'image/jpeg' })],
undefined,
{ profile: 'task_detail_preview', timeoutMs: 60_000 },
)
const worker = FakeWorker.instances[0]
const handleError = worker.onerror
handleError?.({} as ErrorEvent)
handleError?.({} as ErrorEvent)
await expect(promise).rejects.toMatchObject({ code: 'WORKER_UNAVAILABLE' })
expect(worker.terminate).toHaveBeenCalledTimes(1)
expect(jest.getTimerCount()).toBe(0)
})
})

View File

@ -9,6 +9,7 @@ describe('performance-survey-image-error', () => {
'BROWSER_CAPABILITY_UNAVAILABLE',
'BROWSER_DECODE_UNAVAILABLE',
'WORKER_UNAVAILABLE',
'WORKER_TIMEOUT',
'CANVAS_UNAVAILABLE',
'CANVAS_EXPORT_UNAVAILABLE',
])('%s 可进入浏览器能力不足的服务器兜底', (code) => {

View File

@ -21,6 +21,7 @@ import {
} from '@/lib/task-material-processor'
import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions'
import { assignTaskNoOnComplete } from '@/lib/product-package'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import path from 'path'
@ -96,6 +97,10 @@ jest.mock('@/lib/image-processor', () => ({
sliceImage: jest.fn(async (buffer: Buffer) => [buffer]),
}))
jest.mock('@/lib/image-processing-limiter', () => ({
withImageProcessingSlot: jest.fn(async (task: () => Promise<unknown>) => task()),
}))
jest.mock('@/lib/remote-upload-sync', () => ({
isRemoteUploadSyncEnabled: jest.fn(() => false),
syncStoredUploadFileToRemote: jest.fn().mockResolvedValue(null),
@ -242,6 +247,59 @@ describe('product-package-completion', () => {
failOn: 'error',
}),
)
expect(withImageProcessingSlot).toHaveBeenCalledTimes(1)
})
test('服务器兜底生成预览时元数据读取也经过全局图片处理门禁', async () => {
const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-server-preview-fallback-'))
try {
const sourcePath = path.join(uploadRoot, 'source.jpg')
const sourceBuffer = Buffer.from('server-preview-source')
await writeFile(sourcePath, sourceBuffer)
;(getOwnedUploadSession as jest.Mock).mockResolvedValue({
userId: 'user-1',
fileSize: sourceBuffer.length,
})
;(getUploadedFile as jest.Mock).mockResolvedValue(sourcePath)
;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
id: 'material-1',
materialType: '任务-详情',
taskDetailSourceType: '实拍',
taskStatus: 'uploading',
previewUrl: '',
zipFilePath: '',
zipFileName: '',
zipFileSize: BigInt(0),
taskNo: null,
dingtalkApprovalNo: null,
isFullSet: false,
productModel: 'SPU-001',
categoryName: '旗舰店',
user: { realName: '张三', username: 'zhangsan' },
})
;(createZipFromFiles as jest.Mock).mockResolvedValue({
filePath: 'zip-main',
fileName: 'main.zip',
fileSize: sourceBuffer.length,
})
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [{
uploadId: 'upload-main',
fileName: 'source.jpg',
fileSize: sourceBuffer.length,
}],
},
})).resolves.toEqual(expect.objectContaining({ materialId: 'material-1' }))
expect(withImageProcessingSlot).toHaveBeenCalledTimes(1)
} finally {
await rm(uploadRoot, { recursive: true, force: true })
}
})
test.each([

View File

@ -0,0 +1,46 @@
import { prepareTaskDetailImagePreview } from '@/lib/task-detail-image-browser'
const mockPreparePerformanceSurveyImagesInBrowser = jest.fn()
jest.mock('@/lib/performance-survey-image-browser', () => ({
preparePerformanceSurveyImagesInBrowser: (...args: unknown[]) => (
mockPreparePerformanceSurveyImagesInBrowser(...args)
),
}))
describe('task-detail-image-browser', () => {
beforeEach(() => {
jest.clearAllMocks()
mockPreparePerformanceSurveyImagesInBrowser.mockResolvedValue([{
file: new File(['preview'], 'photo-preview.webp', { type: 'image/webp' }),
mimeType: 'image/webp',
width: 800,
height: 600,
kind: 'preview',
sourceName: 'photo.jpg',
sourceIndex: 0,
sliceIndex: 0,
sliceCount: 1,
}])
})
test('将 AbortSignal 和 watchdog 超时透传到底层 Worker', async () => {
const controller = new AbortController()
const file = new File(['image'], 'photo.jpg', { type: 'image/jpeg' })
await prepareTaskDetailImagePreview(file, 'main', undefined, {
signal: controller.signal,
timeoutMs: 12_345,
})
expect(mockPreparePerformanceSurveyImagesInBrowser).toHaveBeenCalledWith(
[file],
undefined,
{
profile: 'task_detail_preview',
signal: controller.signal,
timeoutMs: 12_345,
},
)
})
})

View File

@ -0,0 +1,49 @@
import { pollTaskDetailUploadJob } from '@/lib/task-detail-upload-status'
describe('task-detail-upload-status', () => {
const originalFetch = global.fetch
beforeEach(() => {
jest.useFakeTimers()
})
afterEach(() => {
jest.useRealTimers()
global.fetch = originalFetch
jest.restoreAllMocks()
})
test('正常等待结束时移除 abort listener,不在长轮询中累积', async () => {
const controller = new AbortController()
const addListenerSpy = jest.spyOn(controller.signal, 'addEventListener')
const removeListenerSpy = jest.spyOn(controller.signal, 'removeEventListener')
global.fetch = jest.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ jobId: 'job-1', status: 'processing' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ jobId: 'job-1', status: 'processing' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ jobId: 'job-1', status: 'processing' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ jobId: 'job-1', status: 'completed' }),
}) as unknown as typeof fetch
const resultPromise = pollTaskDetailUploadJob('job-1', { signal: controller.signal })
await jest.advanceTimersByTimeAsync(800 + 1_000 + 1_250)
await expect(resultPromise).resolves.toMatchObject({ status: 'completed' })
expect(addListenerSpy).toHaveBeenCalledTimes(3)
expect(removeListenerSpy).toHaveBeenCalledTimes(3)
addListenerSpy.mock.calls.forEach((call, index) => {
expect(removeListenerSpy.mock.calls[index][1]).toBe(call[1])
})
expect(jest.getTimerCount()).toBe(0)
})
})

View File

@ -70,6 +70,19 @@ describe('upload-sessions 最终落盘安全', () => {
await expect(readFile(outputPath!)).resolves.toEqual(content)
})
test('物理分片超过会话声明大小时在读入内存前拒绝组装', async () => {
const uploadId = 'upload-oversized-physical-chunk'
const declaredContent = Buffer.from('safe')
const oversizedChunk = Buffer.concat([declaredContent, Buffer.alloc(1024 * 1024, 1)])
await writeSingleChunk(uploadId, oversizedChunk)
await saveUploadSession(uploadId, createSession('source.jpg', declaredContent.length))
await expect(getUploadedFile(uploadId)).resolves.toBeNull()
await expect(
readFile(path.join(uploadRoot, 'assembled', `${uploadId}-source.jpg`)),
).rejects.toMatchObject({ code: 'ENOENT' })
})
test.each([
'../../../sentinel.txt',
'..\\..\\sentinel.txt',

View File

@ -21,15 +21,18 @@ async function acquireImageProcessingSlot(): Promise<void> {
}
await new Promise<void>((resolve) => waitingImageJobs.push(resolve))
activeImageJobs += 1
}
function releaseImageProcessingSlot(): void {
activeImageJobs = Math.max(0, activeImageJobs - 1)
const next = waitingImageJobs.shift()
if (next) {
// 将当前占用的名额直接交给队首等待者。这里不能先减 active 再让等待者加回,
// 否则两者之间的新请求可能抢到这个瞬时空位,突破配置的并发上限。
next()
return
}
activeImageJobs = Math.max(0, activeImageJobs - 1)
}
/**

View File

@ -0,0 +1,13 @@
export function normalizeMaterialPreviewUrl(value: unknown): string | null {
if (typeof value !== 'string') return null
const normalized = value.trim()
return normalized || null
}
export function pickFirstMaterialPreviewUrl(...values: unknown[]): string | null {
for (const value of values) {
const normalized = normalizeMaterialPreviewUrl(value)
if (normalized) return normalized
}
return null
}

View File

@ -38,6 +38,20 @@ type WorkerErrorMessage = {
const DESKTOP_BROWSER_REQUIRED_MESSAGE = '图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传'
// 任务详情单图允许到 100MB / 60MP,低性能办公电脑处理可能需要数分钟。
// 10 分钟与项目现有大文件处理上限一致,既避免误杀慢设备,又能终止永不回包的 Worker。
export const PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS = 10 * 60 * 1000
export type PerformanceSurveyBrowserImageOptions = {
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail'
signal?: AbortSignal
timeoutMs?: number
}
function createBrowserImageAbortError() {
return new DOMException('图片处理已取消', 'AbortError')
}
function assertPerformanceSurveyBrowserImageSupport() {
if (
typeof window === 'undefined'
@ -55,7 +69,11 @@ function assertPerformanceSurveyBrowserImageSupport() {
function runImageWorker(
files: File[],
profile: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail',
options: Pick<PerformanceSurveyBrowserImageOptions, 'signal' | 'timeoutMs'> = {},
): Promise<WorkerProcessedImage[]> {
if (options.signal?.aborted) {
return Promise.reject(createBrowserImageAbortError())
}
assertPerformanceSurveyBrowserImageSupport()
return new Promise((resolve, reject) => {
@ -71,10 +89,23 @@ function runImageWorker(
}
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`
let settled = false
let watchdogTimer: ReturnType<typeof setTimeout> | null = null
const finish = (callback: () => void) => {
function handleAbort() {
finish(() => reject(createBrowserImageAbortError()))
}
function finish(callback: () => void) {
if (settled) return
settled = true
if (watchdogTimer !== null) {
clearTimeout(watchdogTimer)
watchdogTimer = null
}
options.signal?.removeEventListener('abort', handleAbort)
worker.onmessage = null
worker.onerror = null
worker.onmessageerror = null
worker.terminate()
callback()
}
@ -97,6 +128,25 @@ function runImageWorker(
'浏览器图片处理线程启动失败,请使用桌面版 Chrome 或 Edge 重试',
)))
}
worker.onmessageerror = () => {
finish(() => reject(new PerformanceSurveyBrowserImageError(
'WORKER_UNAVAILABLE',
'浏览器图片处理线程无法返回结果,请重试',
)))
}
options.signal?.addEventListener('abort', handleAbort, { once: true })
const configuredTimeout = Number(options.timeoutMs)
const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0
? configuredTimeout
: PERFORMANCE_SURVEY_IMAGE_WORKER_TIMEOUT_MS
watchdogTimer = setTimeout(() => {
const message = profile.startsWith('task_detail')
? '浏览器图片处理超时,已停止本地处理,将改由服务器安全处理'
: '浏览器图片处理超时,请减少单次图片数量后重试'
finish(() => reject(new PerformanceSurveyBrowserImageError('WORKER_TIMEOUT', message)))
}, timeoutMs)
try {
worker.postMessage({ id: requestId, files, profile })
} catch {
@ -111,9 +161,7 @@ function runImageWorker(
export async function preparePerformanceSurveyImagesInBrowser(
files: File[],
onProgress?: (label: string) => void,
options?: {
profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail'
},
options: PerformanceSurveyBrowserImageOptions = {},
): Promise<PerformanceSurveyBrowserProcessedImage[]> {
if (files.length === 0) throw new Error('请至少选择 1 张图片')
@ -123,7 +171,11 @@ export async function preparePerformanceSurveyImagesInBrowser(
? '正在浏览器后台生成预览和 WebP 切片'
: `正在浏览器后台转换 ${orderedFiles.length} 张有序切片为 WebP`,
)
const processed = await runImageWorker(orderedFiles, options?.profile || 'performance_survey')
const processed = await runImageWorker(
orderedFiles,
options.profile || 'performance_survey',
{ signal: options.signal, timeoutMs: options.timeoutMs },
)
return processed.map((item) => ({
file: new File([item.blob], item.fileName, {

View File

@ -2,6 +2,7 @@ export type PerformanceSurveyBrowserImageErrorCode =
| 'BROWSER_CAPABILITY_UNAVAILABLE'
| 'BROWSER_DECODE_UNAVAILABLE'
| 'WORKER_UNAVAILABLE'
| 'WORKER_TIMEOUT'
| 'CANVAS_UNAVAILABLE'
| 'CANVAS_EXPORT_UNAVAILABLE'
| 'SOURCE_FORMAT_UNSUPPORTED'
@ -18,6 +19,7 @@ const BROWSER_CAPABILITY_ERROR_CODES = new Set<PerformanceSurveyBrowserImageErro
'BROWSER_CAPABILITY_UNAVAILABLE',
'BROWSER_DECODE_UNAVAILABLE',
'WORKER_UNAVAILABLE',
'WORKER_TIMEOUT',
'CANVAS_UNAVAILABLE',
'CANVAS_EXPORT_UNAVAILABLE',
])

View File

@ -25,6 +25,7 @@ import { mkdir, readFile, writeFile } from 'fs/promises'
import { randomBytes } from 'crypto'
import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type'
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import {
getDingtalkApprovalNoError,
normalizeDingtalkApprovalNo,
@ -226,11 +227,13 @@ export async function assertClientPreviewWebpMetadata(
|| await import('sharp') as unknown as SharpModule
let metadata: import('sharp').Metadata
try {
metadata = await sharpModule.default(filePath, {
limitInputPixels: TASK_DETAIL_UPLOAD_LIMITS.maxSlicePixels,
sequentialRead: true,
failOn: 'error',
}).metadata()
metadata = await withImageProcessingSlot(() =>
sharpModule.default(filePath, {
limitInputPixels: TASK_DETAIL_UPLOAD_LIMITS.maxSlicePixels,
sequentialRead: true,
failOn: 'error',
}).metadata(),
)
} catch {
throw new Error(`客户端预览图 ${previewAsset.fileName} 不是可安全读取的 WebP`)
}
@ -791,11 +794,13 @@ async function generatePreviewsFromAssets(
if (buffer.length > 200 * 1024 * 1024) {
throw new Error(`预览源图片 ${asset.fileName} 不能超过 200MB`)
}
const metadata = await previewTools.sharp!.default(buffer, {
limitInputPixels: 60_000_000,
sequentialRead: true,
failOn: 'error',
}).metadata()
const metadata = await withImageProcessingSlot(() =>
previewTools.sharp!.default(buffer, {
limitInputPixels: TASK_DETAIL_SERVER_SOURCE_MAX_PIXELS,
sequentialRead: true,
failOn: 'error',
}).metadata(),
)
const shouldSlice = type === 'detail' && metadata.height && metadata.height > 1500
const buffers = shouldSlice
? await previewTools.sliceImage!(

View File

@ -19,11 +19,19 @@ export async function prepareTaskDetailImagePreview(
file: File,
assetKind: TaskDetailAssetKind,
onProgress?: (label: string) => void,
options: {
signal?: AbortSignal
timeoutMs?: number
} = {},
): Promise<TaskDetailBrowserPreview[]> {
const processed = await preparePerformanceSurveyImagesInBrowser(
[file],
onProgress,
{ profile: assetKind === 'detail' ? 'task_detail_detail' : 'task_detail_preview' },
{
profile: assetKind === 'detail' ? 'task_detail_detail' : 'task_detail_preview',
signal: options.signal,
timeoutMs: options.timeoutMs,
},
)
if (assetKind === 'detail' && processed.some((item) => item.sliceCount > 1)) {

View File

@ -30,11 +30,22 @@ function wait(delayMs: number, signal?: AbortSignal) {
reject(new DOMException('上传已取消', 'AbortError'))
return
}
const timer = setTimeout(resolve, delayMs)
signal?.addEventListener('abort', () => {
let timer: ReturnType<typeof setTimeout>
function cleanup() {
signal?.removeEventListener('abort', handleAbort)
}
function handleResolve() {
cleanup()
resolve()
}
function handleAbort() {
clearTimeout(timer)
cleanup()
reject(new DOMException('上传已取消', 'AbortError'))
}, { once: true })
}
timer = setTimeout(handleResolve, delayMs)
signal?.addEventListener('abort', handleAbort, { once: true })
})
}

View File

@ -433,7 +433,7 @@ export async function getUploadedFile(uploadId: string): Promise<string | null>
return null
}
const { readdir, readFile, open, lstat, unlink } = await import('fs/promises');
const { readdir, open, lstat, unlink } = await import('fs/promises');
const uploadRoot = getUploadRoot()
const tempDir = resolveStrictChildPath(uploadRoot, 'temp', normalizedUploadId)
const outputDir = await ensureSafeDirectoryWithinRoot(uploadRoot, 'assembled')
@ -496,18 +496,106 @@ export async function getUploadedFile(uploadId: string): Promise<string | null>
return null;
}
// 分片逐块写入,避免大文件合并时把所有分片同时堆进内存。
if (
!Number.isSafeInteger(session.fileSize) ||
session.fileSize <= 0 ||
chunkFiles.some((chunkFile, index) => chunkFile !== `chunk-${index}`)
) {
console.warn(`[Upload Session] Invalid chunk layout: ${normalizedUploadId}`)
return null
}
const validatedChunks: Array<{ path: string; size: number }> = []
let validatedTotalBytes = 0
for (const chunkFile of chunkFiles) {
const chunkPath = path.join(tempDir, chunkFile)
const chunkStats = await lstat(chunkPath)
if (
chunkStats.isSymbolicLink() ||
!chunkStats.isFile() ||
!Number.isSafeInteger(chunkStats.size) ||
chunkStats.size <= 0
) {
console.warn(`[Upload Session] Invalid chunk file: ${normalizedUploadId}/${chunkFile}`)
return null
}
validatedTotalBytes += chunkStats.size
if (!Number.isSafeInteger(validatedTotalBytes) || validatedTotalBytes > session.fileSize) {
console.warn(
`[Upload Session] Chunk bytes exceed declared size: ${normalizedUploadId}, expected=${session.fileSize}, actual>${session.fileSize}`,
)
return null
}
validatedChunks.push({ path: chunkPath, size: chunkStats.size })
}
if (validatedTotalBytes !== session.fileSize) {
console.warn(
`[Upload Session] Chunk bytes mismatch: ${normalizedUploadId}, expected=${session.fileSize}, actual=${validatedTotalBytes}`,
)
return null
}
// 使用固定大小缓冲区顺序复制。不能对每个分片直接 readFile:若物理分片
// 与会话声明大小不一致,攻击者可以让组装阶段在最终大小校验前吞入超大 Buffer。
const outputHandle = await open(outputPath, 'wx');
outputCreated = true
try {
for (const chunkFile of chunkFiles) {
const chunkPath = path.join(tempDir, chunkFile);
const chunkStats = await lstat(chunkPath)
if (chunkStats.isSymbolicLink() || !chunkStats.isFile()) {
throw new Error(`上传分片不是普通文件: ${chunkFile}`)
const copyBuffer = Buffer.allocUnsafe(1024 * 1024)
let copiedTotalBytes = 0
for (const chunk of validatedChunks) {
const chunkHandle = await open(chunk.path, 'r')
let copiedChunkBytes = 0
try {
const openedChunkStats = await chunkHandle.stat()
if (!openedChunkStats.isFile() || openedChunkStats.size !== chunk.size) {
throw new Error(`上传分片在组装期间发生变化: ${path.basename(chunk.path)}`)
}
while (true) {
const { bytesRead } = await chunkHandle.read(
copyBuffer,
0,
copyBuffer.length,
null,
)
if (bytesRead === 0) {
break
}
copiedChunkBytes += bytesRead
copiedTotalBytes += bytesRead
if (copiedChunkBytes > chunk.size || copiedTotalBytes > session.fileSize) {
throw new Error(`上传分片大小超过会话声明: ${path.basename(chunk.path)}`)
}
let writtenOffset = 0
while (writtenOffset < bytesRead) {
const { bytesWritten } = await outputHandle.write(
copyBuffer,
writtenOffset,
bytesRead - writtenOffset,
null,
)
if (bytesWritten <= 0) {
throw new Error('上传文件组装写入失败')
}
writtenOffset += bytesWritten
}
}
} finally {
await chunkHandle.close()
}
const chunkBuffer = await readFile(chunkPath);
await outputHandle.write(chunkBuffer);
if (copiedChunkBytes !== chunk.size) {
throw new Error(`上传分片大小在组装期间发生变化: ${path.basename(chunk.path)}`)
}
}
if (copiedTotalBytes !== session.fileSize) {
throw new Error('上传文件组装大小与会话声明不一致')
}
} finally {
await outputHandle.close();