fix: 加固任务详情上传稳定性
This commit is contained in:
parent
873156ed0f
commit
3495530f3d
43
app/page.tsx
43
app/page.tsx
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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` 权限问题按用户决定暂缓,仍属于已知未关闭项。
|
||||
|
||||
63
lib/__tests__/image-processing-limiter.test.ts
Normal file
63
lib/__tests__/image-processing-limiter.test.ts
Normal 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)
|
||||
|
||||
})
|
||||
})
|
||||
33
lib/__tests__/material-preview-url.test.ts
Normal file
33
lib/__tests__/material-preview-url.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
177
lib/__tests__/performance-survey-image-browser.test.ts
Normal file
177
lib/__tests__/performance-survey-image-browser.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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) => {
|
||||
|
||||
@ -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([
|
||||
|
||||
46
lib/__tests__/task-detail-image-browser.test.ts
Normal file
46
lib/__tests__/task-detail-image-browser.test.ts
Normal 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,
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
49
lib/__tests__/task-detail-upload-status.test.ts
Normal file
49
lib/__tests__/task-detail-upload-status.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
13
lib/material-preview-url.ts
Normal file
13
lib/material-preview-url.ts
Normal 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
|
||||
}
|
||||
@ -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, {
|
||||
|
||||
@ -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',
|
||||
])
|
||||
|
||||
@ -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!(
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user