From 0897c81471b4a32d05e278a44a67c490a235d9be Mon Sep 17 00:00:00 2001 From: chenpei Date: Sat, 1 Aug 2026 15:57:02 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A8=B3=E5=AE=9A=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E4=B8=8A=E4=BC=A0=E4=B8=8E=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/materials/facets/route.ts | 84 +- .../upload/product-package/abort/route.ts | 114 ++- .../upload/product-package/complete/route.ts | 102 ++- .../upload/product-package/init/route.ts | 7 + .../upload/product-package/status/route.ts | 43 + .../PerformanceSurveyAdminConfig.test.tsx | 10 +- .../upload/ProductPackageUploader.test.tsx | 417 +++++++++- components/upload/ProductPackageUploader.tsx | 638 +++++++++++---- components/upload/drag-drop-utils.test.ts | 43 + components/upload/drag-drop-utils.ts | 30 +- ...Ÿ产图片视频内存事故与处理记录-2026-07-31.md | 43 + instrumentation.ts | 25 + lib/__tests__/chunk-upload.test.ts | 26 + .../department-permission-batch.test.ts | 168 ++++ lib/__tests__/image-processor-slice.test.ts | 122 +++ lib/__tests__/material-query.test.ts | 76 +- lib/__tests__/materials-facets-route.test.ts | 128 +++ .../performance-survey-image-error.test.ts | 35 + ...formance-survey-image-source-guard.test.ts | 17 +- .../performance-survey-service-policy.test.ts | 234 +++++- ...oduct-package-abort-route-security.test.ts | 64 +- ...ct-package-complete-route-security.test.ts | 321 ++++++++ .../product-package-completion.test.ts | 313 +++++++- .../product-package-init-route.test.ts | 14 +- .../product-package-status-route.test.ts | 57 ++ .../product-package-task-no-lock.test.ts | 82 ++ lib/__tests__/task-detail-upload-jobs.test.ts | 307 ++++++++ lib/chunk-upload.ts | 15 +- lib/department-permission.ts | 227 +++++- lib/image-processor.ts | 80 +- lib/material-query.ts | 76 +- lib/performance-survey-image-browser.ts | 38 +- lib/performance-survey-image-error.ts | 41 + lib/performance-survey-image-source-guard.ts | 30 +- lib/performance-survey-image.worker.ts | 179 ++++- lib/performance-survey-service.ts | 130 ++- lib/product-package-completion.ts | 742 ++++++++++++++++-- lib/product-package.ts | 58 +- lib/task-detail-image-browser.ts | 38 + lib/task-detail-upload-jobs.ts | 552 +++++++++++++ lib/task-detail-upload-policy.ts | 77 ++ lib/task-detail-upload-status.ts | 70 ++ lib/task-material-processor.ts | 17 +- .../migration.sql | 36 + prisma/schema.prisma | 28 + 45 files changed, 5477 insertions(+), 477 deletions(-) create mode 100644 app/api/materials/upload/product-package/status/route.ts create mode 100644 lib/__tests__/department-permission-batch.test.ts create mode 100644 lib/__tests__/image-processor-slice.test.ts create mode 100644 lib/__tests__/materials-facets-route.test.ts create mode 100644 lib/__tests__/performance-survey-image-error.test.ts create mode 100644 lib/__tests__/product-package-status-route.test.ts create mode 100644 lib/__tests__/product-package-task-no-lock.test.ts create mode 100644 lib/__tests__/task-detail-upload-jobs.test.ts create mode 100644 lib/performance-survey-image-error.ts create mode 100644 lib/task-detail-image-browser.ts create mode 100644 lib/task-detail-upload-jobs.ts create mode 100644 lib/task-detail-upload-policy.ts create mode 100644 lib/task-detail-upload-status.ts create mode 100644 prisma/migrations/20260731_add_product_package_upload_jobs/migration.sql diff --git a/app/api/materials/facets/route.ts b/app/api/materials/facets/route.ts index 113ab3c..a7acd8a 100644 --- a/app/api/materials/facets/route.ts +++ b/app/api/materials/facets/route.ts @@ -4,6 +4,7 @@ import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { buildMaterialWhereFromSearchParams, + createMaterialQueryContext, MaterialFilterDimension, MaterialQueryError, normalizeMaterialTypeForUi, @@ -33,6 +34,10 @@ function toFacetItems(rows: any[], field: FacetField) { .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')) } +function countGroupedRows(rows: any[]) { + return rows.reduce((total, row) => total + Number(row._count?._all || 0), 0) +} + function toMonthItems(rows: Array<{ createdAt: Date }>) { const monthMap = new Map() @@ -85,9 +90,13 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams const user = session.user as any + const context = await createMaterialQueryContext(searchParams, user) const buildWhere = async (exclude?: MaterialFilterDimension) => - buildMaterialWhereFromSearchParams(searchParams, user, exclude ? { exclude } : {}) + buildMaterialWhereFromSearchParams(searchParams, user, { + context, + ...(exclude ? { exclude } : {}), + }) const countWithFilters = async (exclude?: MaterialFilterDimension) => { const result = await buildWhere(exclude) @@ -100,7 +109,7 @@ export async function GET(request: NextRequest) { const groupByField = async (field: FacetField) => { const result = await buildWhere(FIELD_TO_EXCLUDE[field]) if (result.empty) { - return [] + return { items: [], total: 0 } } const rows = await (prisma.material.groupBy as any)({ @@ -109,13 +118,16 @@ export async function GET(request: NextRequest) { _count: { _all: true }, }) - return toFacetItems(rows, field) + return { + items: toFacetItems(rows, field), + total: countGroupedRows(rows), + } } const loadMaterialTypes = async () => { const result = await buildWhere('materialType') if (result.empty) { - return [] + return { items: [], total: 0 } } const rows = await (prisma.material.groupBy as any)({ @@ -124,13 +136,16 @@ export async function GET(request: NextRequest) { _count: { _all: true }, }) - return toMaterialTypeItems(rows) + return { + items: toMaterialTypeItems(rows), + total: countGroupedRows(rows), + } } const loadMonths = async () => { const result = await buildWhere('month') if (result.empty) { - return [] + return { items: [], total: 0 } } const rows = await prisma.material.findMany({ @@ -139,54 +154,41 @@ export async function GET(request: NextRequest) { orderBy: { createdAt: 'desc' }, }) - return toMonthItems(rows) + return { + items: toMonthItems(rows), + total: rows.length, + } } - const [ - total, - categories, - scenes, - platforms, - productCategories, - months, - materialTypes, - materialTypeTotal, - categoryTotal, - sceneTotal, - platformTotal, - productCategoryTotal, - monthTotal, - ] = await Promise.all([ + // 复用 groupBy 的计数汇总,避免为每个筛选维度再执行一次 count: + // 整个 facets 请求由 13 条 SQL 降为 7 条,同时每批最多并发 3 条。 + const [total, categoryResult, sceneResult] = await Promise.all([ countWithFilters(), groupByField('categoryName'), groupByField('sceneName'), + ]) + const [platformResult, productCategoryResult, monthResult] = await Promise.all([ groupByField('platformName'), groupByField('productCategoryName'), loadMonths(), - loadMaterialTypes(), - countWithFilters('materialType'), - countWithFilters('category'), - countWithFilters('scene'), - countWithFilters('platform'), - countWithFilters('productCategory'), - countWithFilters('month'), ]) + const materialTypeResult = await loadMaterialTypes() return NextResponse.json({ total, - categories, - scenes, - platforms, - productCategories, - months, - materialTypes, + categories: categoryResult.items, + scenes: sceneResult.items, + platforms: platformResult.items, + productCategories: productCategoryResult.items, + months: monthResult.items, + materialTypes: materialTypeResult.items, totals: { - materialType: materialTypeTotal, - category: categoryTotal, - scene: sceneTotal, - platform: platformTotal, - productCategory: productCategoryTotal, - month: monthTotal, + materialType: materialTypeResult.total, + category: categoryResult.total, + scene: sceneResult.total, + platform: platformResult.total, + productCategory: productCategoryResult.total, + month: monthResult.total, }, }) } catch (error) { diff --git a/app/api/materials/upload/product-package/abort/route.ts b/app/api/materials/upload/product-package/abort/route.ts index a8d22ad..1c269ea 100644 --- a/app/api/materials/upload/product-package/abort/route.ts +++ b/app/api/materials/upload/product-package/abort/route.ts @@ -3,7 +3,7 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { - deleteUploadSession, + deleteOwnedUploadSession, getOwnedUploadSession, isSafeUploadId, } from '@/lib/upload-sessions' @@ -12,6 +12,8 @@ import { isPathFreeOfSymbolicLinks, resolveStrictChildPath, } from '@/lib/path-security' +import { acquireTaskDetailUploadLock } from '@/lib/task-detail-upload-jobs' +import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' import { rm, unlink } from 'fs/promises' interface AbortRequestBody { @@ -25,6 +27,29 @@ interface ValidatedAbortUpload { tempDir: string } +const ABORT_VALIDATION_CONCURRENCY = 12 + +async function mapWithConcurrency( + values: T[], + concurrency: number, + mapper: (value: T, index: number) => Promise, +): Promise { + const results = new Array(values.length) + let nextIndex = 0 + + async function worker() { + while (nextIndex < values.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await mapper(values[index], index) + } + } + + const workerCount = Math.min(Math.max(1, concurrency), values.length) + await Promise.all(Array.from({ length: workerCount }, () => worker())) + return results +} + async function validateAbortUploads( uploadIds: string[], userId: string, @@ -35,9 +60,7 @@ async function validateAbortUploads( if (!assembledRoot || !tempRoot) { throw new Error('上传目录配置不安全') } - const validated: ValidatedAbortUpload[] = [] - - for (const uploadId of uploadIds) { + return mapWithConcurrency(uploadIds, ABORT_VALIDATION_CONCURRENCY, async (uploadId) => { const uploadSession = await getOwnedUploadSession(uploadId, userId) if (!uploadSession) { throw new Error(`上传会话不存在或不属于当前用户:${uploadId}`) @@ -48,27 +71,31 @@ async function validateAbortUploads( `${uploadId}-${uploadSession.fileName}`, ) const tempDir = resolveStrictChildPath(tempRoot, uploadId) + const [assembledPathIsSafe, tempDirIsSafe] = assembledPath && tempDir + ? await Promise.all([ + isPathFreeOfSymbolicLinks(uploadRoot, assembledPath, { + allowMissingDescendants: true, + }), + isPathFreeOfSymbolicLinks(uploadRoot, tempDir, { + allowMissingDescendants: true, + }), + ]) + : [false, false] if ( !assembledPath || !tempDir || - !(await isPathFreeOfSymbolicLinks(uploadRoot, assembledPath, { - allowMissingDescendants: true, - })) || - !(await isPathFreeOfSymbolicLinks(uploadRoot, tempDir, { - allowMissingDescendants: true, - })) + !assembledPathIsSafe || + !tempDirIsSafe ) { throw new Error(`上传会话路径不安全:${uploadId}`) } - validated.push({ + return { uploadId, assembledPath, tempDir, - }) - } - - return validated + } + }) } export async function POST(req: NextRequest) { @@ -89,9 +116,12 @@ export async function POST(req: NextRequest) { ) } const uploadIds = Array.from(new Set(rawUploadIds.map((id) => id.trim()))) - if (uploadIds.length > 200) { + if (uploadIds.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalUploadSessionCount) { return NextResponse.json( - { success: false, error: '单次最多清理 200 个上传会话' }, + { + success: false, + error: `单次最多清理 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalUploadSessionCount} 个上传会话`, + }, { status: 400 }, ) } @@ -106,8 +136,6 @@ export async function POST(req: NextRequest) { id: true, userId: true, materialType: true, - previewUrl: true, - zipFilePath: true, }, }) @@ -123,11 +151,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: true, deleted: false, reason: 'not_task_detail' }) } - const assetsCount = await prisma.materialAsset.count({ where: { materialId } }) - if (assetsCount > 0 || material.previewUrl || material.zipFilePath) { - return NextResponse.json({ success: true, deleted: false, reason: 'already_processed' }) - } - // 在执行任何文件、会话或素材删除之前,先一次性校验全部 uploadId、 // 会话存在性、所有权和物理路径边界,避免部分清理后才发现越权输入。 let validatedUploads: ValidatedAbortUpload[] @@ -138,6 +161,45 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: false, error: message }, { status: 400 }) } + const deletion = await prisma.$transaction(async (tx) => { + // 与 createTaskDetailUploadJob 使用同一把事务锁,防止“检查无任务”后并发入队。 + await acquireTaskDetailUploadLock(tx, 'product-package-upload-create') + const activeJob = await tx.productPackageUploadJob.findFirst({ + where: { materialId, status: { in: ['queued', 'processing'] } }, + select: { id: true, status: true }, + }) + if (activeJob) return { deleted: false as const, reason: 'job_processing' as const, activeJob } + + const currentMaterial = await tx.material.findUnique({ + where: { id: materialId }, + select: { previewUrl: true, zipFilePath: true }, + }) + if (!currentMaterial) { + return { deleted: false as const, reason: 'material_not_found' as const } + } + + const assetsCount = await tx.materialAsset.count({ where: { materialId } }) + if (assetsCount > 0 || currentMaterial.previewUrl || currentMaterial.zipFilePath) { + return { deleted: false as const, reason: 'already_processed' as const } + } + + await tx.material.delete({ where: { id: materialId } }) + return { deleted: true as const, reason: 'deleted' as const } + }) + + if (!deletion.deleted) { + if (deletion.reason === 'job_processing' && deletion.activeJob) { + return NextResponse.json({ + success: true, + deleted: false, + reason: deletion.reason, + jobId: deletion.activeJob.id, + status: deletion.activeJob.status, + }) + } + return NextResponse.json({ success: true, deleted: false, reason: deletion.reason }) + } + for (const upload of validatedUploads) { await unlink(upload.assembledPath).catch((error: any) => { if (error?.code !== 'ENOENT') { @@ -147,11 +209,9 @@ export async function POST(req: NextRequest) { await rm(upload.tempDir, { recursive: true, force: true }) } - await prisma.material.delete({ where: { id: materialId } }) for (const upload of validatedUploads) { - await deleteUploadSession(upload.uploadId) + await deleteOwnedUploadSession(upload.uploadId, userId) } - return NextResponse.json({ success: true, deleted: true, diff --git a/app/api/materials/upload/product-package/complete/route.ts b/app/api/materials/upload/product-package/complete/route.ts index 0c60e10..aa677ce 100644 --- a/app/api/materials/upload/product-package/complete/route.ts +++ b/app/api/materials/upload/product-package/complete/route.ts @@ -8,19 +8,25 @@ import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { - completeProductPackageUpload, parsePublicProductPackageAssets, } from '@/lib/product-package-completion' +import { + createTaskDetailUploadJob, + getLatestTaskDetailUploadJobStatus, + TaskDetailUploadQueueCapacityError, +} from '@/lib/task-detail-upload-jobs' +import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export const maxDuration = 10800 +export const maxDuration = 60 interface CompleteRequestBody { materialId: string taskNo?: string dingtalkApprovalNo?: string noBarcodes?: boolean + allowServerPreviewFallback?: boolean assets: unknown } @@ -55,6 +61,9 @@ export async function POST(request: NextRequest) { userId: true, materialType: true, taskDetailRequesterUserId: true, + taskDetailSourceType: true, + zipFilePath: true, + _count: { select: { assets: true } }, }, }) @@ -86,7 +95,75 @@ export async function POST(request: NextRequest) { ) } - const result = await completeProductPackageUpload({ + if (!normalizeTaskDetailSourceType(material.taskDetailSourceType)) { + return NextResponse.json( + { success: false, error: '任务-详情必须选择出图方式(实拍、渲染或 AI)' }, + { status: 400 }, + ) + } + + // 先恢复已有任务的状态,再检查素材是否已有持久化结果。首次 202 响应若在 + // 网络中丢失,后台可能已经完成;此时重试必须返回原 job,而不是误报 409。 + const existingJob = await getLatestTaskDetailUploadJobStatus( + body.materialId, + (session.user as any).id, + ) + if (existingJob && ['queued', 'processing', 'completed'].includes(existingJob.status)) { + return NextResponse.json({ + success: true, + data: { + jobId: existingJob.jobId, + materialId: existingJob.materialId, + status: existingJob.status, + stage: existingJob.stage, + progress: existingJob.progress, + message: existingJob.status === 'completed' + ? '后台处理已完成' + : '文件已接收,正在后台处理', + }, + }, { status: 202 }) + } + + // 没有可复用任务时,初始化素材的 zip 路径和资产必须为空,防止再次入队 + // 覆盖既有文件或产生重复资产。 + if (material.zipFilePath || material._count?.assets > 0) { + return NextResponse.json( + { success: false, error: '该素材已有后台处理结果,请刷新素材状态后再操作' }, + { status: 409 }, + ) + } + + const imageAssets = [ + ...(assets.main || []), + ...(assets.sku || []), + ...(assets.detail || []), + ] + if (imageAssets.length === 0) { + return NextResponse.json( + { success: false, error: '请至少上传一张图片' }, + { status: 400 }, + ) + } + const missingClientPreviewCount = imageAssets.filter( + (asset) => !Array.isArray(asset.previewAssets) || asset.previewAssets.length === 0, + ).length + if (missingClientPreviewCount > 0) { + if (body.allowServerPreviewFallback !== true) { + return NextResponse.json( + { success: false, error: '客户端预览图缺失,请重新处理图片后再提交' }, + { status: 400 }, + ) + } + // 浏览器预处理属于性能优化,不应成为提交门槛。Edge 的 worker、图片编解码 + // 或超大 EXIF 头部出现兼容性问题时,保留原图并交给后台唯一 worker 生成预览; + // 该 worker 仍受像素、文件大小和全局并发限制保护。 + console.warn('[Product Package Complete] 客户端预览缺失,转服务器兜底处理', { + materialId: body.materialId, + count: missingClientPreviewCount, + }) + } + + const job = await createTaskDetailUploadJob({ materialId: body.materialId, taskNo: body.taskNo, dingtalkApprovalNo: body.dingtalkApprovalNo, @@ -100,15 +177,22 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, data: { - materialId: result.materialId, - taskNo: result.taskNo, - dingtalkApprovalNo: result.dingtalkApprovalNo, - isFullSet: result.isFullSet, - message: '上传完成', + jobId: job.jobId, + materialId: job.materialId, + status: job.status, + stage: job.stage, + progress: job.progress, + message: '文件已接收,正在后台处理', }, - }) + }, { status: 202 }) } catch (error) { console.error('[Product Package Complete] Error:', error) + if (error instanceof TaskDetailUploadQueueCapacityError) { + return NextResponse.json( + { success: false, error: error.message, code: 'UPLOAD_QUEUE_FULL' }, + { status: error.statusCode, headers: { 'Retry-After': '60' } }, + ) + } return NextResponse.json( { success: false, diff --git a/app/api/materials/upload/product-package/init/route.ts b/app/api/materials/upload/product-package/init/route.ts index 09dbe8d..04605c7 100644 --- a/app/api/materials/upload/product-package/init/route.ts +++ b/app/api/materials/upload/product-package/init/route.ts @@ -86,6 +86,13 @@ export async function POST(request: NextRequest) { ); } + if (!taskDetailSourceType) { + return NextResponse.json( + { success: false, error: '任务-详情必须选择出图方式(实拍、渲染或 AI)' }, + { status: 400 } + ); + } + // 如果提供了任务号,验证格式和唯一性 if (taskNo && taskNo.trim()) { // 验证任务号格式 diff --git a/app/api/materials/upload/product-package/status/route.ts b/app/api/materials/upload/product-package/status/route.ts new file mode 100644 index 0000000..e67d0fa --- /dev/null +++ b/app/api/materials/upload/product-package/status/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from 'next/server' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' +import { + getLatestTaskDetailUploadJobStatus, + getTaskDetailUploadJobStatus, +} from '@/lib/task-detail-upload-jobs' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' +export const maxDuration = 30 + +export async function GET(request: NextRequest) { + try { + const session = await getServerSession(authOptions) + const operatorUserId = (session?.user as { id?: string } | undefined)?.id + if (!operatorUserId) { + return NextResponse.json({ success: false, error: '未登录' }, { status: 401 }) + } + + const jobId = request.nextUrl.searchParams.get('jobId')?.trim() || '' + const materialId = request.nextUrl.searchParams.get('materialId')?.trim() || '' + if ((!jobId && !materialId) || jobId.length > 100 || materialId.length > 100) { + return NextResponse.json({ success: false, error: '缺少有效的 jobId 或 materialId' }, { status: 400 }) + } + + const job = jobId + ? await getTaskDetailUploadJobStatus(jobId, operatorUserId) + : await getLatestTaskDetailUploadJobStatus(materialId, operatorUserId) + if (!job) { + // 不区分“任务不存在”和“任务属于其他用户”,避免泄露任务状态。 + return NextResponse.json({ success: false, error: '任务不存在或无权限查看' }, { status: 404 }) + } + + return NextResponse.json({ success: true, data: job }) + } catch (error) { + console.error('[Product Package Status] Error:', error) + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : '服务器错误' }, + { status: 500 }, + ) + } +} diff --git a/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx b/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx index 64b2d33..fd33b2d 100644 --- a/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx +++ b/app/performance-survey/PerformanceSurveyAdminConfig.test.tsx @@ -179,7 +179,7 @@ describe('PerformanceSurveyAdminConfig', () => { test('概览将岗位设置与发布状态合并展示', async () => { const request = createRequest() const onNavigate = jest.fn() - render() + render() expect(screen.getByText('配置加载中...')).toBeInTheDocument() await waitFor(() => expect(screen.getByRole('heading', { name: '2026-07 配置概览' })).toBeInTheDocument()) @@ -193,11 +193,11 @@ describe('PerformanceSurveyAdminConfig', () => { test('不同管理页面只请求当前页面需要的配置范围', async () => { const request = createRequest() - const view = render() + const view = render() await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/config?month=2026-07&scope=templates')) - view.rerender() + view.rerender() await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/config?month=2026-07&scope=people')) }) @@ -430,7 +430,7 @@ describe('PerformanceSurveyAdminConfig', () => { test('人员批量分配提交最终 REST 请求体', async () => { const request = createRequest() - render() + render() await waitFor(() => expect(screen.getAllByText('张三').length).toBeGreaterThan(0)) fireEvent.click(screen.getByLabelText('选择 张三')) @@ -892,7 +892,7 @@ describe('PerformanceSurveyAdminConfig', () => { : group), } const request = createRequest(configuration) - render() + render() await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核项' })).toBeInTheDocument()) const publishButton = screen.getByRole('button', { name: '发布岗位设置' }) expect(publishButton).toBeEnabled() diff --git a/components/upload/ProductPackageUploader.test.tsx b/components/upload/ProductPackageUploader.test.tsx index 560abdb..d223d1b 100644 --- a/components/upload/ProductPackageUploader.test.tsx +++ b/components/upload/ProductPackageUploader.test.tsx @@ -1,7 +1,28 @@ -import { createEvent, fireEvent, render, screen } from '@testing-library/react' +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 { PerformanceSurveyBrowserImageError } from '@/lib/performance-survey-image-error' +import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' + +const mockChunkUpload = jest.fn() +const mockPrepareTaskDetailImagePreview = jest.fn() +const mockPollTaskDetailUploadJob = jest.fn() + +jest.mock('@/lib/chunk-upload', () => ({ + chunkUpload: (...args: unknown[]) => mockChunkUpload(...args), +})) + +jest.mock('@/lib/task-detail-image-browser', () => ({ + prepareTaskDetailImagePreview: (...args: unknown[]) => mockPrepareTaskDetailImagePreview(...args), +})) + +jest.mock('@/lib/task-detail-upload-status', () => ({ + pollTaskDetailUploadJob: (...args: unknown[]) => mockPollTaskDetailUploadJob(...args), + TaskDetailUploadJobError: class TaskDetailUploadJobError extends Error { + jobStatus = 'failed' + }, +})) jest.mock('@/components/upload/DetailImageSorter', () => ({ __esModule: true, @@ -10,7 +31,18 @@ jest.mock('@/components/upload/DetailImageSorter', () => ({ jest.mock('@/components/upload/UploadForm', () => ({ __esModule: true, - default: () =>
, + default: ({ + onProductModelChange, + onCategoryChange, + }: { + onProductModelChange: (value: string) => void + onCategoryChange: (value: string) => void + }) => ( +
+ + +
+ ), })) jest.mock('@/components/upload/category-handlers', () => ({ @@ -29,11 +61,72 @@ const defaultProps = { onRefreshScenes: jest.fn().mockResolvedValue(undefined), } +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + +function usersResponse() { + return { + ok: true, + json: async () => [{ id: 'requester-1', username: 'operator-a', displayName: '运营甲' }], + } +} + +async function fillRequiredTaskFields( + user: ReturnType, + files: File | File[], +) { + const requesterInput = await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员') + await user.click(requesterInput) + await user.type(requesterInput, '运营甲') + await user.click(screen.getByRole('option', { name: /运营甲/ })) + await user.click(screen.getByRole('button', { name: '设置测试 SPU' })) + await user.click(screen.getByRole('button', { name: '设置测试店铺' })) + await user.selectOptions(screen.getByLabelText(/出图方式/), '实拍') + await user.upload(screen.getByLabelText('主图'), files) +} + +const capabilityError = () => new PerformanceSurveyBrowserImageError( + 'BROWSER_DECODE_UNAVAILABLE', + '当前 Edge 环境无法解码这张合法图片', +) + +function installUploadFlowFetch(completeResponse?: Promise) { + const fetchMock = jest.fn((input: RequestInfo | URL) => { + const url = String(input) + if (url === '/api/users/list') return Promise.resolve(usersResponse()) + if (url === '/api/materials/upload/product-package/init') { + return Promise.resolve({ ok: true, json: async () => ({ data: { materialId: 'material-1' } }) }) + } + if (url === '/api/materials/upload/product-package/complete' && completeResponse) { + return completeResponse + } + if (url === '/api/materials/upload/product-package/abort') { + return Promise.resolve({ ok: true, json: async () => ({ success: true }) }) + } + return Promise.reject(new Error(`测试未处理请求:${url}`)) + }) + global.fetch = fetchMock as unknown as typeof fetch + return fetchMock +} + describe('ProductPackageUploader', () => { const originalFetch = global.fetch beforeEach(() => { jest.clearAllMocks() + mockChunkUpload.mockResolvedValue({ uploadId: 'upload-original-1' }) + mockPollTaskDetailUploadJob.mockResolvedValue({ + status: 'completed', + progress: 100, + stage: '处理完成', + }) global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => [ @@ -58,6 +151,10 @@ describe('ProductPackageUploader', () => { }) }) + afterEach(() => { + jest.restoreAllMocks() + }) + afterAll(() => { global.fetch = originalFetch }) @@ -97,13 +194,14 @@ describe('ProductPackageUploader', () => { expect(screen.queryByText('empty.psd')).not.toBeInTheDocument() }) - test('应提供任务详情出图方式选择项,且默认未设置', async () => { + test('应提供任务详情出图方式选择项,且默认为必填', async () => { render() await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员') const select = screen.getByLabelText(/出图方式/) as HTMLSelectElement expect(select.value).toBe('') - expect(screen.getByRole('option', { name: '未设置' })).toBeInTheDocument() + expect(select).toBeRequired() + expect(screen.getByRole('option', { name: '请选择出图方式' })).toBeInTheDocument() expect(screen.getByRole('option', { name: '实拍' })).toBeInTheDocument() expect(screen.getByRole('option', { name: '渲染' })).toBeInTheDocument() expect(screen.getByRole('option', { name: 'AI' })).toBeInTheDocument() @@ -185,4 +283,315 @@ describe('ProductPackageUploader', () => { fireEvent(dropZone, outerDragLeave) expect(dropZone).not.toHaveClass('border-blue-500') }) + + test('任务图片选择框只允许浏览器处理链明确支持的格式', async () => { + render() + await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员') + + expect((screen.getByLabelText('主图') as HTMLInputElement).accept) + .toBe('image/jpeg,image/png,image/gif,image/webp') + expect((screen.getByLabelText('SKU图') as HTMLInputElement).accept) + .toBe('image/jpeg,image/png,image/gif,image/webp') + expect((screen.getByLabelText('详情图') as HTMLInputElement).accept) + .toBe('image/jpeg,image/png,image/gif,image/webp') + }) + + test('Edge 浏览器预览处理失败时继续上传原图并显式请求服务器兜底', async () => { + const user = userEvent.setup() + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined) + mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError()) + ;(global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: 'requester-1', username: 'operator-a', displayName: '运营甲' }], + }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ data: { materialId: 'material-1' } }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ data: { jobId: 'job-1' } }) }) + + render() + await fillRequiredTaskFields( + user, + new File(['image'], 'edge-photo.jpg', { type: 'image/jpeg' }), + ) + await user.click(screen.getByRole('button', { name: '提交' })) + + await waitFor(() => expect(mockPollTaskDetailUploadJob).toHaveBeenCalledWith( + 'job-1', + expect.objectContaining({ signal: expect.any(AbortSignal) }), + )) + + expect(mockChunkUpload).toHaveBeenCalledTimes(1) + expect(mockChunkUpload.mock.calls[0][0]).toMatchObject({ name: 'edge-photo.jpg' }) + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('edge-photo.jpg'), + '当前 Edge 环境无法解码这张合法图片', + ) + + const completeRequest = (global.fetch as jest.Mock).mock.calls.find( + ([url]) => url === '/api/materials/upload/product-package/complete', + ) + expect(completeRequest).toBeDefined() + expect(JSON.parse(completeRequest[1].body)).toMatchObject({ + materialId: 'material-1', + allowServerPreviewFallback: true, + assets: { + main: [expect.objectContaining({ + fileName: 'edge-photo.jpg', + uploadId: 'upload-original-1', + })], + }, + }) + }) + + test('任务图片超过 60M 像素时立即阻断,不上传原图也不创建后台任务', async () => { + const user = userEvent.setup() + jest.spyOn(console, 'error').mockImplementation(() => undefined) + mockPrepareTaskDetailImagePreview.mockRejectedValueOnce( + new PerformanceSurveyBrowserImageError( + 'SOURCE_PIXEL_LIMIT', + '图片「70m.jpg」像素总量超过 60,000,000,请先压缩或切图', + ), + ) + const fetchMock = installUploadFlowFetch() + + render() + await fillRequiredTaskFields( + user, + new File(['image'], '70m.jpg', { type: 'image/jpeg' }), + ) + await user.click(screen.getByRole('button', { name: '提交' })) + + expect(await screen.findByText('图片「70m.jpg」像素总量超过 60,000,000,请先压缩或切图')).toBeInTheDocument() + expect(mockChunkUpload).not.toHaveBeenCalled() + expect(mockPollTaskDetailUploadJob).not.toHaveBeenCalled() + expect(fetchMock.mock.calls.some(([url]) => url === '/api/materials/upload/product-package/complete')).toBe(false) + expect(fetchMock).toHaveBeenCalledWith( + '/api/materials/upload/product-package/abort', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ materialId: 'material-1', uploadIds: [] }), + }), + ) + }) + + test('并发首错后等待另一上传成功,再用完整 uploadId 列表统一回滚', async () => { + const user = userEvent.setup() + jest.spyOn(console, 'error').mockImplementation(() => undefined) + jest.spyOn(console, 'warn').mockImplementation(() => undefined) + mockPrepareTaskDetailImagePreview.mockRejectedValue(capabilityError()) + const uploadA = createDeferred<{ uploadId: string }>() + const uploadB = createDeferred<{ uploadId: string }>() + const xhrA = { abort: jest.fn() } as unknown as XMLHttpRequest + const xhrB = { abort: jest.fn() } as unknown as XMLHttpRequest + mockChunkUpload.mockImplementation((file: File, _metadata: unknown, options: { + onActiveXhrChange?: (xhrs: Set) => void + }) => { + const isA = file.name === 'a.jpg' + options.onActiveXhrChange?.(new Set([isA ? xhrA : xhrB])) + return isA ? uploadA.promise : uploadB.promise + }) + const fetchMock = installUploadFlowFetch() + + render() + await fillRequiredTaskFields(user, [ + new File(['a'], 'a.jpg', { type: 'image/jpeg' }), + new File(['b'], 'b.jpg', { type: 'image/jpeg' }), + ]) + await user.click(screen.getByRole('button', { name: '提交' })) + await waitFor(() => expect(mockChunkUpload).toHaveBeenCalledTimes(2)) + + await act(async () => { + uploadA.reject(new Error('A 上传失败')) + await Promise.resolve() + }) + await waitFor(() => { + expect(xhrA.abort).toHaveBeenCalled() + expect(xhrB.abort).toHaveBeenCalled() + }) + + await act(async () => { + uploadB.resolve({ uploadId: 'upload-b' }) + await Promise.resolve() + }) + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( + '/api/materials/upload/product-package/abort', + expect.objectContaining({ + body: JSON.stringify({ materialId: 'material-1', uploadIds: ['upload-b'] }), + }), + )) + expect(fetchMock.mock.calls.filter(([url]) => url === '/api/materials/upload/product-package/abort')).toHaveLength(1) + }) + + test('并发首错后另一上传也失败时停止重试并只执行一次素材回滚', async () => { + const user = userEvent.setup() + jest.spyOn(console, 'error').mockImplementation(() => undefined) + jest.spyOn(console, 'warn').mockImplementation(() => undefined) + mockPrepareTaskDetailImagePreview.mockRejectedValue(capabilityError()) + const uploadA = createDeferred<{ uploadId: string }>() + const uploadB = createDeferred<{ uploadId: string }>() + const xhrA = { abort: jest.fn() } as unknown as XMLHttpRequest + const xhrB = { abort: jest.fn() } as unknown as XMLHttpRequest + let bOptions: { isCancelled?: () => boolean } | undefined + mockChunkUpload.mockImplementation((file: File, _metadata: unknown, options: { + isCancelled?: () => boolean + onActiveXhrChange?: (xhrs: Set) => void + }) => { + const isA = file.name === 'a.jpg' + if (!isA) bOptions = options + options.onActiveXhrChange?.(new Set([isA ? xhrA : xhrB])) + return isA ? uploadA.promise : uploadB.promise + }) + const fetchMock = installUploadFlowFetch() + + render() + await fillRequiredTaskFields(user, [ + new File(['a'], 'a.jpg', { type: 'image/jpeg' }), + new File(['b'], 'b.jpg', { type: 'image/jpeg' }), + ]) + await user.click(screen.getByRole('button', { name: '提交' })) + await waitFor(() => expect(mockChunkUpload).toHaveBeenCalledTimes(2)) + + await act(async () => { + uploadA.reject(new Error('A 上传失败')) + await Promise.resolve() + }) + await waitFor(() => expect(bOptions?.isCancelled?.()).toBe(true)) + expect(xhrA.abort).toHaveBeenCalled() + expect(xhrB.abort).toHaveBeenCalled() + + await act(async () => { + uploadB.reject(new Error('B 随后失败')) + await Promise.resolve() + }) + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( + '/api/materials/upload/product-package/abort', + expect.objectContaining({ + body: JSON.stringify({ materialId: 'material-1', uploadIds: [] }), + }), + )) + expect(fetchMock.mock.calls.filter(([url]) => url === '/api/materials/upload/product-package/abort')).toHaveLength(1) + }) + + test('complete 返回 202 前取消时标记后台已接单但不再启动轮询', async () => { + const user = userEvent.setup() + jest.spyOn(console, 'error').mockImplementation(() => undefined) + jest.spyOn(console, 'warn').mockImplementation(() => undefined) + mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError()) + const completeResponse = createDeferred() + const fetchMock = installUploadFlowFetch(completeResponse.promise) + + render() + await fillRequiredTaskFields( + user, + new File(['image'], 'cancel-before-202.jpg', { type: 'image/jpeg' }), + ) + await user.click(screen.getByRole('button', { name: '提交' })) + await waitFor(() => expect(fetchMock.mock.calls.some( + ([url]) => url === '/api/materials/upload/product-package/complete', + )).toBe(true)) + + await user.click(screen.getByRole('button', { name: '取消上传' })) + await act(async () => { + completeResponse.resolve({ ok: true, json: async () => ({ data: { jobId: 'job-after-cancel' } }) }) + await Promise.resolve() + }) + + expect(await screen.findByText('已停止等待,后台任务仍在服务器处理中,可稍后查看素材状态')).toBeInTheDocument() + expect(mockPollTaskDetailUploadJob).not.toHaveBeenCalled() + expect(fetchMock.mock.calls.some(([url]) => url === '/api/materials/upload/product-package/abort')).toBe(false) + }) + + test('complete 返回 202 前卸载组件时不创建后台轮询', async () => { + const user = userEvent.setup() + jest.spyOn(console, 'error').mockImplementation(() => undefined) + jest.spyOn(console, 'warn').mockImplementation(() => undefined) + mockPrepareTaskDetailImagePreview.mockRejectedValueOnce(capabilityError()) + const completeResponse = createDeferred() + const fetchMock = installUploadFlowFetch(completeResponse.promise) + + const { unmount } = render() + await fillRequiredTaskFields( + user, + new File(['image'], 'unmount-before-202.jpg', { type: 'image/jpeg' }), + ) + await user.click(screen.getByRole('button', { name: '提交' })) + await waitFor(() => expect(fetchMock.mock.calls.some( + ([url]) => url === '/api/materials/upload/product-package/complete', + )).toBe(true)) + + unmount() + await act(async () => { + completeResponse.resolve({ ok: true, json: async () => ({ data: { jobId: 'job-after-unmount' } }) }) + await Promise.resolve() + await Promise.resolve() + }) + + expect(mockPollTaskDetailUploadJob).not.toHaveBeenCalled() + expect(fetchMock.mock.calls.some(([url]) => url === '/api/materials/upload/product-package/abort')).toBe(false) + }) + + test('浏览器生成的预览超过服务端总量前停止上传并回滚空素材', async () => { + const user = userEvent.setup() + jest.spyOn(console, 'error').mockImplementation(() => undefined) + const mutableLimits = TASK_DETAIL_UPLOAD_LIMITS as unknown as { maxTotalPreviewCount: number } + const originalLimit = mutableLimits.maxTotalPreviewCount + mutableLimits.maxTotalPreviewCount = 1 + mockPrepareTaskDetailImagePreview.mockResolvedValueOnce([ + { + file: new File(['preview-1'], 'preview-1.webp', { type: 'image/webp' }), + width: 800, + height: 600, + kind: 'preview', + sourceName: 'photo.jpg', + sourceIndex: 0, + sliceIndex: 0, + sliceCount: 1, + }, + { + file: new File(['preview-2'], 'preview-2.webp', { type: 'image/webp' }), + width: 800, + height: 600, + kind: 'preview', + sourceName: 'photo.jpg', + sourceIndex: 0, + sliceIndex: 0, + sliceCount: 1, + }, + ]) + ;(global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => [{ id: 'requester-1', username: 'operator-a', displayName: '运营甲' }], + }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ data: { materialId: 'material-1' } }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ success: true }) }) + + try { + render() + const requesterInput = await screen.findByPlaceholderText('搜索发起本次出图需求的运营人员') + await user.click(requesterInput) + await user.type(requesterInput, '运营甲') + await user.click(screen.getByRole('option', { name: /运营甲/ })) + await user.click(screen.getByRole('button', { name: '设置测试 SPU' })) + await user.click(screen.getByRole('button', { name: '设置测试店铺' })) + await user.selectOptions(screen.getByLabelText(/出图方式/), '实拍') + await user.upload( + screen.getByLabelText('主图'), + new File(['image'], 'photo.jpg', { type: 'image/jpeg' }), + ) + await user.click(screen.getByRole('button', { name: '提交' })) + + expect(await screen.findByText('本次图片生成的预览切片过多或过大,请减少详情长图数量后分批提交')).toBeInTheDocument() + expect(mockChunkUpload).not.toHaveBeenCalled() + expect(global.fetch).toHaveBeenCalledWith( + '/api/materials/upload/product-package/abort', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ materialId: 'material-1', uploadIds: [] }), + }), + ) + } finally { + mutableLimits.maxTotalPreviewCount = originalLimit + } + }) }) diff --git a/components/upload/ProductPackageUploader.tsx b/components/upload/ProductPackageUploader.tsx index 5c8d296..ff6a088 100644 --- a/components/upload/ProductPackageUploader.tsx +++ b/components/upload/ProductPackageUploader.tsx @@ -15,15 +15,37 @@ import { } from '@/lib/task-detail-source-type'; import { hasDragLeftDropZone, + handleDropZoneKeyDown, prepareFileDrag, validateImageFiles, } from './drag-drop-utils'; import TaskReferenceFields from './TaskReferenceFields'; import { getDingtalkApprovalNoError } from '@/lib/dingtalk-approval-no'; +import { + TASK_DETAIL_UPLOAD_LIMITS, + validateTaskDetailUploadFiles, +} from '@/lib/task-detail-upload-policy'; +import { + prepareTaskDetailImagePreview, + type TaskDetailAssetKind, + type TaskDetailBrowserPreview, +} from '@/lib/task-detail-image-browser'; +import { + isPerformanceSurveyBrowserCapabilityError, + PerformanceSurveyBrowserImageError, +} from '@/lib/performance-survey-image-error'; +import { + pollTaskDetailUploadJob, + TaskDetailUploadJobError, +} from '@/lib/task-detail-upload-status'; const TASK_DETAIL_SOURCE_FILE_EXTENSIONS = ['psd', 'psb', 'tif', 'tiff', 'zip', 'rar', '7z'] as const; const TASK_DETAIL_SOURCE_FILE_ACCEPT = '.psd,.psb,.tif,.tiff,.zip,.rar,.7z'; const TASK_DETAIL_SOURCE_FILE_LABEL = '.psd、.psb、.tif、.tiff、.zip、.rar、.7z'; +// 浏览器预处理 worker 的输入格式白名单。不要使用 image/*,它还会放行 +// TIFF/HEIC/AVIF/SVG 等当前处理链不支持的类型,避免最后提交时才报错。 +const TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'] as const; +const TASK_DETAIL_BROWSER_IMAGE_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp'; function splitEmptyFiles(files: FileList | File[] | null): { validFiles: File[]; @@ -79,6 +101,35 @@ interface AssetFile { barcodes?: string[]; } +type UploadGroup = 'main' | 'sku' | 'detail' | 'psd'; + +type UploadedAsset = { + fileName: string; + fileSize: number; + uploadId: string; + sortOrder: number; + previewAssets?: Array<{ + uploadId: string; + fileName: string; + fileSize: number; + width: number; + height: number; + kind: 'preview' | 'slice'; + sourceName: string; + sourceIndex: number; + sliceIndex: number; + sliceCount: number; + }>; + previewUploadId?: string; + previewFileName?: string; + previewFileSize?: number; +}; + +type UploadedTaskResult = { + group: UploadGroup; + asset: UploadedAsset; +}; + function parseBarcodeInput(input: string): string[] { return Array.from( new Set( @@ -90,6 +141,16 @@ function parseBarcodeInput(input: string): string[] { ); } +function revokeAssetPreviews(groups: Array) { + const urls = new Set( + groups + .flat() + .map((item) => item.preview) + .filter((preview): preview is string => Boolean(preview) && preview.startsWith('blob:')), + ); + urls.forEach((preview) => URL.revokeObjectURL(preview)); +} + export default function ProductPackageUploader({ onSuccess, onCancel, @@ -138,6 +199,39 @@ export default function ProductPackageUploader({ const skuInputRef = useRef(null); const detailInputRef = useRef(null); const psdInputRef = useRef(null); + const mountedRef = useRef(true); + const cancelledRef = useRef(false); + const uploadStopRef = useRef(false); + const activeXhrsRef = useRef>(new Set()); + const activeXhrSetsRef = useRef>>(new Map()); + const jobPollAbortRef = useRef(null); + const completionTimerRef = useRef | null>(null); + const progressRef = useRef(0); + const jobAcceptedRef = useRef(false); + const assetGroupsRef = useRef([]); + + useEffect(() => { + assetGroupsRef.current = [mainImages, skuImages, detailImages, psdFiles]; + }, [mainImages, skuImages, detailImages, psdFiles]); + + useEffect(() => { + // React Strict Mode 在开发环境会执行一次 setup → cleanup → setup。 + // 每次 setup 都要恢复挂载标记,避免后续进度和错误状态被误判为已卸载。 + mountedRef.current = true; + cancelledRef.current = false; + const activeXhrSets = activeXhrSetsRef.current; + return () => { + mountedRef.current = false; + cancelledRef.current = true; + uploadStopRef.current = true; + activeXhrsRef.current.forEach((xhr) => xhr.abort()); + activeXhrsRef.current.clear(); + activeXhrSets.clear(); + jobPollAbortRef.current?.abort(); + if (completionTimerRef.current) clearTimeout(completionTimerRef.current); + revokeAssetPreviews(assetGroupsRef.current); + }; + }, []); useEffect(() => { let active = true; @@ -296,11 +390,24 @@ export default function ProductPackageUploader({ } const { validFiles, emptyFileNames } = splitEmptyFiles(files); - const { acceptedFiles, errors } = validateImageFiles(validFiles); + const { acceptedFiles: formatAcceptedFiles, errors } = validateImageFiles(validFiles, { + allowedExtensions: TASK_DETAIL_BROWSER_IMAGE_EXTENSIONS, + }); + const oversizedFiles = formatAcceptedFiles.filter( + (file) => file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes, + ); + const acceptedFiles = formatAcceptedFiles.filter( + (file) => file.size <= TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes, + ); const validationErrors = [...errors]; if (emptyFileNames.length > 0) { validationErrors.unshift(`以下文件为空,无法上传:${emptyFileNames.join('、')}`); } + if (oversizedFiles.length > 0) { + validationErrors.push( + `以下图片超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制:${oversizedFiles.map((file) => file.name).join('、')}`, + ); + } setError(validationErrors.join(';')); setter((prev) => { @@ -381,27 +488,47 @@ export default function ProductPackageUploader({ } }; - // 使用分片上传到本地服务器(和任务-3D原图一样) + // 使用分片上传到本地服务器(和任务-3D原图一样)。 + // 图片预处理和网络上传是两条不同的资源链路:预处理保持单张,网络分片 + // 允许两个并发,避免 300M 公网链路被单个请求的往返等待限制吞吐。 const uploadFileWithChunks = async ( file: File, - onProgress?: (progress: number) => void + onProgress?: (progress: number) => void, + isCancelled?: () => boolean, + requestKey?: string, ): Promise => { const { chunkUpload } = await import('@/lib/chunk-upload'); - + const result = await chunkUpload(file, { materialType: '任务-详情', fileName: file.name, }, { // chunkSize使用默认配置:内网20MB,公网10MB - skipComplete: true, // 跳过complete,由后续统一处理 - onProgress: (progress) => { - onProgress?.(progress); + skipComplete: true, + chunkConcurrency: 2, + isCancelled, + onProgress: (progress) => onProgress?.(progress), + onActiveXhrChange: (xhrs) => { + if (requestKey) { + activeXhrSetsRef.current.set(requestKey, xhrs); + const merged = new Set(); + activeXhrSetsRef.current.forEach((set) => set.forEach((xhr) => merged.add(xhr))); + activeXhrsRef.current = merged; + } }, }); + if (requestKey) { + activeXhrSetsRef.current.delete(requestKey); + const merged = new Set(); + activeXhrSetsRef.current.forEach((set) => set.forEach((xhr) => merged.add(xhr))); + activeXhrsRef.current = merged; + } return result.uploadId; }; + // 网络传输允许两个文件同时进行;浏览器预处理在提交函数内另有单槽位, + // 不会同时解码两张大图。服务器端真正的 Sharp/ZIP 处理仍由后台 worker 全局串行。 const MAX_FILE_CONCURRENCY = 2; const runWithConcurrency = async ( @@ -414,16 +541,36 @@ export default function ProductPackageUploader({ const safeLimit = Math.max(1, Math.min(limit, items.length)); const results: TOutput[] = new Array(items.length); let nextIndex = 0; + let stopped = false; + let hasError = false; + let firstError: unknown; + + const stopConsumers = (error: unknown) => { + if (hasError) return; + hasError = true; + firstError = error; + stopped = true; + uploadStopRef.current = true; + // 第一处失败出现后立即停止同批网络传输;consumer 仍需全部 settle, + // 这样稍后完成的上传 ID 能进入最终一次素材回滚请求。 + activeXhrsRef.current.forEach((xhr) => xhr.abort()); + }; const consume = async () => { - while (nextIndex < items.length) { + while (!stopped && nextIndex < items.length) { const current = nextIndex; nextIndex += 1; - results[current] = await worker(items[current], current); + try { + results[current] = await worker(items[current], current); + } catch (error) { + stopConsumers(error); + return; + } } }; - await Promise.all(Array.from({ length: safeLimit }, () => consume())); + await Promise.allSettled(Array.from({ length: safeLimit }, () => consume())); + if (hasError) throw firstError; return results; }; @@ -434,13 +581,16 @@ export default function ProductPackageUploader({ return; } - // 验证必填字段(任务号改为可选,不提示) + if (!taskDetailSourceType) { + setError('请选择出图方式'); + return; + } + if (!productModel || !categoryName) { setError('请填写SPU型号和店铺'); return; } - // 如果提供了任务号,验证格式 if (taskNo && taskNo.trim() && !validateTaskNo(taskNo)) { setError('任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)'); return; @@ -451,31 +601,32 @@ export default function ProductPackageUploader({ return; } - // 主图改为非必选,只要至少有一张图片即可 if (mainImages.length === 0 && skuImages.length === 0 && detailImages.length === 0) { setError('请至少上传一张图片(主图、SKU图或详情图)'); return; } + const uploadPolicyError = validateTaskDetailUploadFiles({ + main: mainImages.map((item) => item.file), + sku: skuImages.map((item) => item.file), + detail: detailImages.map((item) => item.file), + psd: psdFiles.map((item) => item.file), + }); + if (uploadPolicyError) { + setError(uploadPolicyError); + return; + } + let normalizedSkuImages: AssetFile[]; try { normalizedSkuImages = skuImages.map((image, index) => { const skuCode = String(image.skuCode || '').trim(); - if (!skuCode) { - throw new Error(`请为第 ${index + 1} 张 SKU 图填写 SKU 编号`); - } - + if (!skuCode) throw new Error(`请为第 ${index + 1} 张 SKU 图填写 SKU 编号`); const barcodes = parseBarcodeInput(image.barcodeInput || ''); if (!noBarcodes && barcodes.length === 0) { throw new Error(`请为 SKU 图 ${image.file.name} 至少填写 1 个产品条码`); } - - return { - ...image, - skuCode, - barcodeInput: barcodes.join('\n'), - barcodes, - }; + return { ...image, skuCode, barcodeInput: barcodes.join('\n'), barcodes }; }); } catch (validationError) { setError(validationError instanceof Error ? validationError.message : 'SKU 条码信息校验失败'); @@ -490,22 +641,34 @@ export default function ProductPackageUploader({ setError(`产品条码 ${barcode} 不能同时绑定两张 SKU 图`); return; } - barcodeOwners.set(barcode, image.id); } } setUploading(true); setUploadProgress(0); + progressRef.current = 0; setProcessingStep(''); setError(''); + cancelledRef.current = false; + uploadStopRef.current = false; + jobAcceptedRef.current = false; + jobPollAbortRef.current?.abort(); let createdMaterialId: string | null = null; const uploadedFileIds: string[] = []; let completed = false; + let jobAccepted = false; + let serverPreviewFallbackUsed = false; + + const setProgress = (nextProgress: number) => { + if (!mountedRef.current) return; + const next = Math.max(progressRef.current, Math.min(100, Math.round(nextProgress))); + progressRef.current = next; + setUploadProgress(next); + }; try { - // 1. 初始化上传 setProcessingStep('初始化上传...'); const initResponse = await fetch('/api/materials/upload/product-package/init', { method: 'POST', @@ -524,7 +687,7 @@ export default function ProductPackageUploader({ }); if (!initResponse.ok) { - const errorData = await initResponse.json(); + const errorData = await initResponse.json().catch(() => ({})); throw new Error(errorData.error || '初始化失败'); } @@ -532,171 +695,292 @@ export default function ProductPackageUploader({ const materialId = initData.materialId; createdMaterialId = materialId; - // 2. 上传所有文件(并行上传以提高速度) - const totalFiles = - mainImages.length + skuImages.length + detailImages.length + psdFiles.length; - let uploadedCount = 0; + const uploadTasks: Array<{ + group: UploadGroup; + asset: AssetFile; + sortOrder: number; + skuCode?: string; + barcodes?: string[]; + }> = [ + ...mainImages.map((asset, sortOrder) => ({ group: 'main' as const, asset, sortOrder })), + ...normalizedSkuImages.map((asset, sortOrder) => ({ + group: 'sku' as const, + asset, + sortOrder, + skuCode: asset.skuCode, + barcodes: asset.barcodes || [], + })), + ...detailImages.map((asset, sortOrder) => ({ group: 'detail' as const, asset, sortOrder })), + ...psdFiles.map((asset, sortOrder) => ({ group: 'psd' as const, asset, sortOrder })), + ]; - // 进度更新函数(线程安全) - const updateProgress = () => { - uploadedCount++; - setUploadProgress(Math.round((uploadedCount / totalFiles) * 100)); + let totalWorkBytes = uploadTasks.reduce((sum, task) => sum + task.asset.file.size, 0); + let completedWorkBytes = 0; + let reservedPreviewCount = 0; + let reservedPreviewBytes = 0; + const progressByRequest = new Map(); + const reportFileProgress = (requestKey: string, fileSize: number, progress: number) => { + const uploadedBytes = Math.min(fileSize, Math.max(0, fileSize * progress / 100)); + const previousBytes = progressByRequest.get(requestKey) || 0; + if (uploadedBytes <= previousBytes) return; + progressByRequest.set(requestKey, uploadedBytes); + completedWorkBytes += uploadedBytes - previousBytes; + if (totalWorkBytes > 0) setProgress(completedWorkBytes / totalWorkBytes * 90); }; - // 并行上传资产(使用分片上传) - const uploadAssets = async ( - assets: AssetFile[], - assetType: string - ): Promise> => { - if (assets.length === 0) return []; + const fileConcurrency = MAX_FILE_CONCURRENCY; - return runWithConcurrency(assets, MAX_FILE_CONCURRENCY, async (asset, index) => { + let browserPreviewBusy = false; + const browserPreviewWaiters: Array<() => void> = []; + const withBrowserPreviewSlot = async (worker: () => Promise): Promise => { + if (browserPreviewBusy) { + await new Promise((resolve) => browserPreviewWaiters.push(resolve)); + } + browserPreviewBusy = true; + try { + if (cancelledRef.current || uploadStopRef.current) throw new Error('上传已取消'); + return await worker(); + } finally { + browserPreviewBusy = false; + browserPreviewWaiters.shift()?.(); + } + }; + + const uploadTask = async (task: typeof uploadTasks[number], taskIndex: number): Promise => { + if (cancelledRef.current || uploadStopRef.current) throw new Error('上传已取消'); + const { group, asset } = task; + const requestPrefix = `${taskIndex}-${asset.id}`; + let previewAssets: NonNullable = []; + + if (group !== 'psd') { + let browserPreviews: TaskDetailBrowserPreview[] = []; + let browserPreviewFailed = false; try { - const uploadId = await uploadFileWithChunks(asset.file); - uploadedFileIds.push(uploadId); - // 只在上传完成时更新进度 - updateProgress(); - return { - fileName: asset.file.name, - fileSize: asset.file.size, - uploadId, - sortOrder: index, - }; - } catch (error) { - console.error(`[Upload] Failed to upload ${assetType} asset ${index}:`, error); - throw new Error(`上传${assetType}图片 ${asset.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`); + browserPreviews = await withBrowserPreviewSlot(() => + prepareTaskDetailImagePreview( + asset.file, + group as TaskDetailAssetKind, + (label) => { + if (mountedRef.current) setProcessingStep(`${asset.file.name}:${label}`); + }, + ), + ); + } catch (previewError) { + if (cancelledRef.current || uploadStopRef.current) throw previewError; + if (!isPerformanceSurveyBrowserCapabilityError(previewError)) throw previewError; + const reason = previewError instanceof Error ? previewError.message : String(previewError); + console.warn(`[Upload] 浏览器预览处理失败,继续上传原图并交由服务器生成预览:${asset.file.name}`, reason); + browserPreviewFailed = true; + serverPreviewFallbackUsed = true; + if (mountedRef.current) { + setProcessingStep(`${asset.file.name}:浏览器预览不可用,继续上传原图,服务器将自动生成预览`); + } } - }); + + if (browserPreviews.length === 0 && !browserPreviewFailed) { + throw new PerformanceSurveyBrowserImageError( + 'PROCESSING_FAILED', + `图片「${asset.file.name}」未生成浏览器预览,请重试或重新导出图片`, + ); + } + + if (cancelledRef.current || uploadStopRef.current) throw new Error('上传已取消'); + + const previewBatchBytes = browserPreviews.reduce((sum, preview) => sum + preview.file.size, 0); + if ( + reservedPreviewCount + browserPreviews.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewCount + || reservedPreviewBytes + previewBatchBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewBytes + ) { + throw new Error('本次图片生成的预览切片过多或过大,请减少详情长图数量后分批提交'); + } + reservedPreviewCount += browserPreviews.length; + reservedPreviewBytes += previewBatchBytes; + + // 预览生成失败只影响优化链路,不应阻断原图上传。完成接口会在缺少 + // 客户端预览时进入受限的服务器兜底处理;网络上传错误仍然直接抛出, + // 由外层回滚本次上传,避免把网络错误伪装成浏览器兼容问题。 + for (let previewIndex = 0; previewIndex < browserPreviews.length; previewIndex += 1) { + const preview: TaskDetailBrowserPreview = browserPreviews[previewIndex]; + totalWorkBytes += preview.file.size; + const previewKey = `${requestPrefix}-preview-${previewIndex}`; + const previewUploadId = await uploadFileWithChunks( + preview.file, + (progress) => reportFileProgress(previewKey, preview.file.size, progress), + () => cancelledRef.current || uploadStopRef.current, + previewKey, + ); + uploadedFileIds.push(previewUploadId); + previewAssets.push({ + uploadId: previewUploadId, + fileName: preview.file.name, + fileSize: preview.file.size, + width: preview.width, + height: preview.height, + kind: preview.kind, + sourceName: preview.sourceName, + sourceIndex: preview.sourceIndex, + sliceIndex: preview.sliceIndex, + sliceCount: preview.sliceCount, + }); + } + } + + const originalUploadId = await uploadFileWithChunks( + asset.file, + (progress) => reportFileProgress(`${requestPrefix}-original`, asset.file.size, progress), + () => cancelledRef.current || uploadStopRef.current, + `${requestPrefix}-original`, + ); + uploadedFileIds.push(originalUploadId); + return { + group, + asset: { + fileName: asset.file.name, + fileSize: asset.file.size, + uploadId: originalUploadId, + sortOrder: task.sortOrder, + ...(previewAssets.length > 0 + ? { + previewAssets, + previewUploadId: previewAssets[0].uploadId, + previewFileName: previewAssets[0].fileName, + previewFileSize: previewAssets[0].fileSize, + } + : {}), + ...(group === 'sku' ? { skuCode: task.skuCode, barcodes: task.barcodes || [] } : {}), + }, + }; }; - // 并行上传PSD文件(使用分片上传) - const uploadPsdFiles = async (): Promise> => { - if (psdFiles.length === 0) return []; + setProcessingStep('浏览器预处理并上传素材中...'); + const uploadedResults = await runWithConcurrency(uploadTasks, fileConcurrency, uploadTask); + if (cancelledRef.current) throw new Error('上传已取消'); - return runWithConcurrency(psdFiles, MAX_FILE_CONCURRENCY, async (psdFileItem, index) => { - try { - const psdFile = psdFileItem.file; - const uploadId = await uploadFileWithChunks(psdFile); - uploadedFileIds.push(uploadId); - // 只在上传完成时更新进度 - updateProgress(); - return { - fileName: psdFile.name, - fileSize: psdFile.size, - uploadId, - }; - } catch (error) { - console.error(`[Upload] Failed to upload PSD file ${index}:`, error); - throw new Error(`上传PSD文件 ${psdFileItem.file.name} 失败: ${error instanceof Error ? error.message : String(error)}`); - } - }); - }; + const sortAssets = (group: UploadGroup) => uploadedResults + .filter((result) => result.group === group) + .sort((left, right) => left.asset.sortOrder - right.asset.sortOrder) + .map((result) => result.asset); + const mainAssets = sortAssets('main'); + const skuAssets = sortAssets('sku'); + const detailAssets = sortAssets('detail'); + const psdAssets = sortAssets('psd'); - // 顺序上传不同资产类型,避免一次性触发过多并发请求导致会话不稳定 - setProcessingStep('上传主图中...'); - const mainAssets = await uploadAssets(mainImages, 'main'); - - setProcessingStep('上传SKU图中...'); - const uploadedSkuAssets = await uploadAssets(normalizedSkuImages, 'sku'); - const skuAssets = uploadedSkuAssets.map((asset, index) => ({ - ...asset, - skuCode: normalizedSkuImages[index].skuCode, - barcodes: normalizedSkuImages[index].barcodes || [], - })); - - setProcessingStep('上传详情图中...'); - const detailAssets = await uploadAssets(detailImages, 'detail'); - - setProcessingStep('上传PSD中...'); - const psdAssetsArray = await uploadPsdFiles(); - - // PSD资产格式:支持数组(complete接口支持数组格式) - const psdAssets = psdAssetsArray.length > 0 ? psdAssetsArray : undefined; - - // 3. 完成上传(传递uploadId,后端会处理文件) - setProcessingStep('打包文件中...'); - setUploadProgress(95); + setProcessingStep('已上传,正在提交后台处理任务...'); + setProgress(90); const completeResponse = await fetch('/api/materials/upload/product-package/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId, - taskNo, // 传递taskNo,上传完成后写入数据库 + taskNo, dingtalkApprovalNo: dingtalkApprovalNo.trim() || undefined, noBarcodes, + allowServerPreviewFallback: serverPreviewFallbackUsed, assets: { main: mainAssets, sku: skuAssets, detail: detailAssets, - psd: psdAssets, // 数组格式(complete接口支持) + psd: psdAssets.length > 0 ? psdAssets : undefined, }, }), }); + const completePayload = await completeResponse.json().catch(() => ({})); if (!completeResponse.ok) { - const errorData = await completeResponse.json().catch(() => ({})); - const errorMessage = errorData.error || '完成上传失败'; - console.error('[Upload] Complete failed:', errorMessage, errorData); + const errorMessage = completePayload.error || '完成上传失败'; + console.error('[Upload] Complete failed:', errorMessage, completePayload); throw new Error(errorMessage); } - setProcessingStep('生成预览图中...'); + const jobId = completePayload?.data?.jobId; + if (!jobId) { + throw new Error('后台处理任务创建失败,请重试'); + } + jobAccepted = true; + jobAcceptedRef.current = true; + // 用户在 complete 请求返回前取消或关闭弹窗时,服务端可能已经接单。 + // 此时保留 accepted 状态避免误删任务,但绝不能再创建最长 30 分钟的隐形轮询。 + if (cancelledRef.current || !mountedRef.current) { + throw new Error('上传已取消'); + } + if (jobId) { + const pollController = new AbortController(); + jobPollAbortRef.current = pollController; + await pollTaskDetailUploadJob(jobId, { + signal: pollController.signal, + onStatus: (status) => { + if (!mountedRef.current) return; + const backendProgress = Number.isFinite(status.progress) ? Math.max(0, Math.min(100, status.progress || 0)) : 0; + setProgress(90 + backendProgress * 0.1); + setProcessingStep(status.stage || (status.status === 'queued' ? '后台排队处理中...' : '后台处理中...')); + }, + }); + } - // 短暂延迟以显示处理完成状态 - await new Promise(resolve => setTimeout(resolve, 300)); - - setUploadProgress(100); + setProgress(100); setProcessingStep('完成!'); completed = true; - - // 清理预览URL - const cleanup = () => { - mainImages.forEach((img) => { - URL.revokeObjectURL(img.preview); - }); - skuImages.forEach((img) => { - URL.revokeObjectURL(img.preview); - }); - detailImages.forEach((img) => { - URL.revokeObjectURL(img.preview); - }); - psdFiles.forEach((img) => { - URL.revokeObjectURL(img.preview); - }); - }; - cleanup(); - - setTimeout(() => { + revokeAssetPreviews([mainImages, skuImages, detailImages, psdFiles]); + completionTimerRef.current = setTimeout(() => { + if (!mountedRef.current) return; setProcessingStep(''); onSuccess(); }, 500); } catch (error: unknown) { console.error('上传失败:', error); - if (createdMaterialId && !completed) { + if (createdMaterialId && !completed && !jobAccepted) { try { await fetch('/api/materials/upload/product-package/abort', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - materialId: createdMaterialId, - uploadIds: uploadedFileIds, - }), + body: JSON.stringify({ materialId: createdMaterialId, uploadIds: uploadedFileIds }), }); } catch (abortError) { console.error('[Upload] 失败回滚未完成素材失败:', abortError); } } - const { parseApiError } = await import('@/lib/error-handler'); - const { message } = parseApiError(error); - setError(message); - setProcessingStep(''); + if (cancelledRef.current || (error instanceof Error && error.message === '上传已取消')) { + if (mountedRef.current) { + setError(jobAccepted || jobAcceptedRef.current + ? '已停止等待,后台任务仍在服务器处理中,可稍后查看素材状态' + : '已取消上传'); + setProcessingStep(''); + } + } else { + const { parseApiError } = await import('@/lib/error-handler'); + const { message } = parseApiError(error); + if (mountedRef.current) { + const isFailedJob = error instanceof TaskDetailUploadJobError && error.jobStatus === 'failed'; + setError(jobAccepted && !isFailedJob ? `文件已上传,后台任务仍在处理:${message}` : message); + setProcessingStep(''); + } + } } finally { - setUploading(false); + activeXhrsRef.current.forEach((xhr) => xhr.abort()); + activeXhrsRef.current.clear(); + activeXhrSetsRef.current.clear(); + jobPollAbortRef.current = null; + uploadStopRef.current = true; + if (mountedRef.current) setUploading(false); } }; + const handleCancelUpload = () => { + if (!uploading) { + onCancel(); + return; + } + cancelledRef.current = true; + uploadStopRef.current = true; + activeXhrsRef.current.forEach((xhr) => xhr.abort()); + activeXhrsRef.current.clear(); + activeXhrSetsRef.current.clear(); + jobPollAbortRef.current?.abort(); + setProcessingStep(jobAcceptedRef.current ? '已停止等待,后台任务继续处理...' : '正在取消上传...'); + }; + return (
handleImageSelect(e.target.files, setMainImages)} className="hidden" disabled={uploading} /> -
+
{mainImages.map((img) => (
- +
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
handleDragLeave(e, setIsSkuDragging)} onDragOver={handleDragOver} onDrop={(e) => handleDrop(e, setSkuImages, setIsSkuDragging, 'image')} + onKeyDown={(event) => handleDropZoneKeyDown(event, () => skuInputRef.current?.click())} > handleImageSelect(e.target.files, setSkuImages)} className="hidden" disabled={uploading} /> -
+
{skuImages.map((img) => (
- +
- JPG / PNG / WebP,支持拖拽 + JPG / PNG / GIF / WebP,支持拖拽
{/* 详情图排序组件 */} @@ -990,7 +1291,10 @@ export default function ProductPackageUploader({ {/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
{ handleDrop(e, setDetailImages, setIsPsdDragging, 'psd'); }} + onKeyDown={(event) => handleDropZoneKeyDown(event, () => psdInputRef.current?.click())} > setPsdFiles((prev) => prev.filter((f) => f.id !== psdFile.id))} + aria-label={`删除源文件 ${psdFile.file.name}`} className="text-red-400 hover:text-red-600" disabled={uploading} type="button" @@ -1049,7 +1355,7 @@ export default function ProductPackageUploader({ {/* 错误提示 */} {error && ( -
+
{error}
)} @@ -1057,30 +1363,37 @@ export default function ProductPackageUploader({ {/* 上传进度 */} {uploading && (
-
+
-
- {uploadProgress}% +
+ {uploadProgress}% {processingStep && ( - {processingStep} + {processingStep} )}
)} {/* 操作按钮 */} -
+
+
-
+
+
); } diff --git a/components/upload/drag-drop-utils.test.ts b/components/upload/drag-drop-utils.test.ts index e65f6bb..e82114d 100644 --- a/components/upload/drag-drop-utils.test.ts +++ b/components/upload/drag-drop-utils.test.ts @@ -1,6 +1,7 @@ import { hasAllowedFileExtension, hasDragLeftDropZone, + handleDropZoneKeyDown, isSupportedImageFile, prepareFileDrag, validateImageFiles, @@ -69,6 +70,35 @@ describe('drag-drop-utils', () => { expect(event.dataTransfer.dropEffect).toBe('none') }) + test('上传区获得焦点时按 Enter 或空格应打开文件选择器', () => { + const dropZone = document.createElement('div') + const openFilePicker = jest.fn() + + handleDropZoneKeyDown({ + key: 'Enter', + preventDefault: jest.fn(), + currentTarget: dropZone, + target: dropZone, + } as unknown as React.KeyboardEvent, openFilePicker) + + expect(openFilePicker).toHaveBeenCalledTimes(1) + }) + + test('上传区内的按钮按键不应再次打开文件选择器', () => { + const dropZone = document.createElement('div') + const child = document.createElement('button') + const openFilePicker = jest.fn() + + handleDropZoneKeyDown({ + key: ' ', + preventDefault: jest.fn(), + currentTarget: dropZone, + target: child, + } as unknown as React.KeyboardEvent, openFilePicker) + + expect(openFilePicker).not.toHaveBeenCalled() + }) + test('Windows 未提供 MIME 时应按常见图片扩展名识别', () => { const file = new File(['image'], 'preview.PNG', { type: '' }) @@ -87,6 +117,19 @@ describe('drag-drop-utils', () => { expect(result.errors.join(';')).toContain('large.jpg') }) + test('业务链路可按浏览器处理能力限制图片格式', () => { + const jpg = new File(['image'], '1.jpg', { type: 'image/jpeg' }) + const heic = new File(['image'], 'phone.heic', { type: 'image/heic' }) + + const result = validateImageFiles([jpg, heic], { + allowedExtensions: ['jpg', 'jpeg', 'png', 'gif', 'webp'], + }) + + expect(result.acceptedFiles).toEqual([jpg]) + expect(result.errors.join(';')).toContain('phone.heic') + expect(result.errors.join(';')).toContain('JPG、JPEG、PNG、GIF、WEBP') + }) + test('图片数量限制应考虑已经选择的图片', () => { const files = [ new File(['1'], '1.png', { type: 'image/png' }), diff --git a/components/upload/drag-drop-utils.ts b/components/upload/drag-drop-utils.ts index f435e59..1dce4b7 100644 --- a/components/upload/drag-drop-utils.ts +++ b/components/upload/drag-drop-utils.ts @@ -14,6 +14,11 @@ export interface ImageFileValidationOptions { maxBytes?: number maxCount?: number existingCount?: number + /** + * 可选的业务格式白名单。浏览器的 image/* MIME 范围比实际处理能力更宽, + * 需要在特定上传链路中按扩展名提前拦截不兼容格式。 + */ + allowedExtensions?: readonly string[] } export interface ImageFileValidationResult { @@ -38,6 +43,19 @@ export function prepareFileDrag(event: React.DragEvent, disabled = fals event.dataTransfer.dropEffect = disabled ? 'none' : 'copy' } +/** + * 让拖拽上传区域也能通过键盘打开文件选择器,避免只能用鼠标点击。 + */ +export function handleDropZoneKeyDown( + event: React.KeyboardEvent, + openFilePicker: () => void, +): void { + if (event.target !== event.currentTarget) return + if (event.key !== 'Enter' && event.key !== ' ') return + event.preventDefault() + openFilePicker() +} + export function isSupportedImageFile(file: File): boolean { if (file.type) { return file.type.startsWith('image/') @@ -60,13 +78,16 @@ export function validateImageFiles( files: FileList | File[], options: ImageFileValidationOptions = {}, ): ImageFileValidationResult { - const { maxBytes, maxCount, existingCount = 0 } = options + const { maxBytes, maxCount, existingCount = 0, allowedExtensions } = options const unsupportedNames: string[] = [] const oversizedNames: string[] = [] const validFiles: File[] = [] for (const file of Array.from(files)) { - if (!isSupportedImageFile(file)) { + const isValidImage = allowedExtensions + ? hasAllowedFileExtension(file, allowedExtensions) + : isSupportedImageFile(file) + if (!isValidImage) { unsupportedNames.push(file.name) continue } @@ -87,7 +108,10 @@ export function validateImageFiles( const errors: string[] = [] if (unsupportedNames.length > 0) { - errors.push(`以下文件不是支持的图片格式:${unsupportedNames.join('、')}`) + const supportedLabel = allowedExtensions?.length + ? `,请上传 ${allowedExtensions.map((extension) => extension.replace(/^\./, '').toUpperCase()).join('、')} 格式` + : '' + errors.push(`以下文件不是支持的图片格式:${unsupportedNames.join('、')}${supportedLabel}`) } if (oversizedNames.length > 0 && maxBytes !== undefined) { const maxMegabytes = Math.round(maxBytes / 1024 / 1024) diff --git a/docs/生产图片视频内存事故与处理记录-2026-07-31.md b/docs/生产图片视频内存事故与处理记录-2026-07-31.md index 04ffa43..b0bb2dc 100644 --- a/docs/生产图片视频内存事故与处理记录-2026-07-31.md +++ b/docs/生产图片视频内存事故与处理记录-2026-07-31.md @@ -42,3 +42,46 @@ - 持续采集容器 RSS、cgroup `memory.events`、宿主机可用内存和 OOM 日志。 本次排查未重启、杀进程、清理生产数据或修改生产部署脚本。 + +## 2026-08-01 第二阶段处理记录 + +### 新确认的根因 + +- 用户在 Edge 中看到“浏览器预览处理失败”并不等于 Edge 不受支持。原链路把“浏览器能力不足、图片损坏、图片超限”等不同原因混成了同一个提示,导致用户更换浏览器后仍然失败。 +- 任务详情上传在请求内直接完成图片预览、切图、压缩和打包,浏览器断开、进程重启或多用户同时上传时,容易出现长时间等待、任务丢失和服务器内存尖峰。 +- 生产日志中的大量 `P2024` 主要来自素材筛选与部门权限查询放大,并不是本轮图片处理再次触发 OOM。素材筛选一次请求曾重复建立多套查询上下文,部门权限也存在逐部门查询。 +- 同一任务号并发完成时缺少数据库级互斥,存在两个素材同时占用同一任务号的竞争窗口。 +- Edge 服务器兜底链路曾存在客户端与服务器限制不一致:超宽长图、动画图、预览总量和源文件总量可能在文件全部上传后才失败。 +- 同一批同名 PSD 的永久文件名只依赖毫秒时间,极端情况下可能覆盖;PSD 上传会话也可能在完成或失败后残留。 + +### 已完成的代码处理 + +- 浏览器一次只处理一张图片;网络层允许 2 个文件并行、每个文件最多 2 个分片请求,在上传速度和内存峰值之间取平衡。 +- 浏览器能力不足时上传原图,由服务器受限兜底;损坏图片、格式不支持、宽度/像素/切片数量超限会在客户端直接拦截,不再伪装成浏览器兼容问题。 +- 上传完成改为持久化后台任务:接口快速返回任务编号,页面轮询状态;进程重启后可恢复,卡死任务有心跳与超时恢复。 +- 全站服务器图片 worker 同时只处理 1 个任务;单用户最多排队 3 个、全站最多排队 20 个,避免多容器并发重新制造内存尖峰。 +- 任务详情服务器兜底统一为:源图宽度不超过 12,000px、高度不超过 90,000px、总像素不超过 6,000 万;输出宽度不超过 1,600px、单片高度不超过 1,500px、单图最多 60 片。 +- 任务详情动画 GIF/WebP 在服务器兜底时只处理首帧;其它图片处理链路仍默认拒绝动画,避免扩大影响范围。 +- 服务端重新校验真实文件格式、尺寸、动画帧、文件数量和大小;源文件总量 1GB 与客户端预览总量 512MB 分开计算。 +- 上传取消和失败会等待并发请求全部收口后执行一次完整回滚;大量会话清理采用最多 12 路受控并发。 +- 同名 PSD 使用带序号和随机值的唯一永久文件名;PSD 上传会话纳入成功与失败清理。 +- 素材筛选复用同一个查询上下文,并把数据库查询数从 13 次降到 7 次;部门权限改为一次批量加载并在内存中解析,减少数据库连接占用。 +- 任务号完成写入增加 PostgreSQL 事务级互斥;直接主管匹配同时配置工号和钉钉 ID 时改为两者都必须匹配。 + +### 本轮验证证据 + +- 全量 Jest:135 个测试套件、873 项测试全部通过。 +- TypeScript 类型检查通过。 +- Prisma 数据模型校验通过。 +- 全项目 ESLint:0 个错误;剩余提示均为项目原有提示,本轮修改未新增阻断项。 +- `$impeccable` 前端反模式扫描:0 项命中。 +- Next.js 正式生产构建编译成功并生成 `BUILD_ID` 与 standalone 产物。本地隔离工作树使用外部 `node_modules` 联接,因此日志中出现一次 traced files 联接权限提示;生产 Docker 构建不使用该本地联接。 + +### 尚未关闭但不阻断本轮发布的事项 + +- 极端上限任务可能包含 1,140 个上传会话,后台 worker 当前逐个核验 Redis/元数据。它不占用 PostgreSQL 连接,也不阻塞 HTTP 返回,但极端任务的“校验中”阶段会较长;后续应压测后再决定使用 8~12 路有限并发或 Redis 批量读取。 +- 素材月份筛选仍会读取符合条件的 `createdAt` 后在应用内汇总。当前已经显著减少查询次数,但数据量继续增长后应改为数据库按月聚合并增加短时缓存。 +- 上线后必须观察 `P2024` 数量、数据库连接数、素材筛选耗时、后台任务队列长度、容器 RSS 和宿主机可用内存;若 `P2024` 仍持续出现,需要继续定位剩余长事务或连接池等待。 +- 宿主机 `middlewared` 异常内存、无 Swap 等运维风险仍未由代码解决,继续按本文“仍需运维处理的风险”执行。 + +截至本记录更新时,本轮代码已完成本地验证,尚未合并到 `main`,也尚未部署生产。 diff --git a/instrumentation.ts b/instrumentation.ts index 7c0f7f1..bb08442 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -19,5 +19,30 @@ export async function register() { } catch (error) { console.error('[Instrumentation] 启动清理任务失败:', error) } + + const recoveryGlobal = globalThis as typeof globalThis & { + __taskDetailUploadRecoveryStarted?: boolean + } + if (!recoveryGlobal.__taskDetailUploadRecoveryStarted) { + recoveryGlobal.__taskDetailUploadRecoveryStarted = true + // 先注册周期任务,再做首次恢复。数据库启动瞬时不可用时,后续周期仍会重试, + // 不会因为一次失败让 processing/queued 任务永久无人处理。 + setInterval(() => { + void import('./lib/task-detail-upload-jobs') + .then(({ recoverTaskDetailUploadJobs }) => recoverTaskDetailUploadJobs()) + .catch((error) => { + console.error('[Instrumentation] 任务-详情后台队列周期恢复失败:', error) + }) + }, 60_000) + } + + try { + const { recoverTaskDetailUploadJobs } = await import('./lib/task-detail-upload-jobs') + await recoverTaskDetailUploadJobs() + console.log('[Instrumentation] 任务-详情后台队列恢复检查完成') + } catch (error) { + // 迁移未执行或数据库暂不可用时只记录;周期恢复已提前注册,会继续重试。 + console.error('[Instrumentation] 任务-详情后台队列恢复失败:', error) + } } } diff --git a/lib/__tests__/chunk-upload.test.ts b/lib/__tests__/chunk-upload.test.ts index 348de89..c458195 100644 --- a/lib/__tests__/chunk-upload.test.ts +++ b/lib/__tests__/chunk-upload.test.ts @@ -1,4 +1,5 @@ import { chunkUpload, getAllPendingUploads } from '@/lib/chunk-upload' +import { getOptimalConcurrency } from '@/lib/chunk-upload-config' jest.mock('@/lib/chunk-upload-config', () => ({ getOptimalChunkSize: jest.fn().mockResolvedValue(1024 * 1024), @@ -210,4 +211,29 @@ describe('chunkUpload', () => { ) await expect(getAllPendingUploads()).resolves.toEqual([]) }) + + test('显式指定分片并发时不再读取网络自动并发配置', async () => { + ;(global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ uploadId: 'upload-explicit-concurrency' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ message: '清理成功' }), + }) + + const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' }) + + await expect( + chunkUpload( + file, + { materialType: '任务-详情' }, + { maxRetries: 1, retryDelay: 0, chunkConcurrency: 2 }, + ), + ).rejects.toThrow('上传分片超过入口服务器限制') + + expect(getOptimalConcurrency).not.toHaveBeenCalled() + expect(MockXMLHttpRequest.instances).toHaveLength(1) + }) }) diff --git a/lib/__tests__/department-permission-batch.test.ts b/lib/__tests__/department-permission-batch.test.ts new file mode 100644 index 0000000..8b6ccd9 --- /dev/null +++ b/lib/__tests__/department-permission-batch.test.ts @@ -0,0 +1,168 @@ +/** @jest-environment node */ + +import { + getDepartmentPermissions, + getUserViewableMaterialTypes, +} from '@/lib/department-permission' +import { prisma } from '@/lib/prisma' + +jest.mock('@/lib/prisma', () => ({ + prisma: { + user: { + findUnique: jest.fn(), + }, + material: { + findMany: jest.fn(), + }, + department: { + findMany: jest.fn(), + }, + departmentMaterialPermission: { + findMany: jest.fn(), + findUnique: jest.fn(), + }, + }, +})) + +const departments = [ + { id: 'parent', name: '视觉中心', parentId: null }, + { id: 'child', name: '视觉三部', parentId: 'parent' }, + { id: 'linked', name: '共享权限部', parentId: null }, +] + +function permission(input: Partial<{ + department: string + materialType: string + canView: boolean + canDownload: boolean + canShare: boolean + canDownloadSource: boolean + linkedToDepartment: string | null +}>) { + return { + department: '视觉三部', + materialType: '详情模板', + canView: true, + canDownload: false, + canShare: true, + canDownloadSource: false, + linkedToDepartment: null, + ...input, + } +} + +describe('department permission batch resolver', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isSuperAdmin: false }) + ;(prisma.material.findMany as jest.Mock).mockResolvedValue([ + { materialType: '详情模板' }, + ]) + ;(prisma.department.findMany as jest.Mock).mockResolvedValue(departments) + }) + + test('一次批量查询保持直接权限、关联部门、父部门继承和视频兼容语义', async () => { + ;(prisma.departmentMaterialPermission.findMany as jest.Mock).mockResolvedValue([ + permission({ + department: '视觉三部', + materialType: '标准模型', + canView: false, + canDownload: true, + }), + permission({ + department: '视觉三部', + materialType: '详情模板', + canView: false, + linkedToDepartment: '共享权限部', + }), + permission({ + department: '共享权限部', + materialType: '详情模板', + canView: true, + canDownload: true, + canDownloadSource: true, + }), + permission({ + department: '视觉中心', + materialType: '淘系-逛逛视频', + canView: true, + }), + ]) + + const result = await getDepartmentPermissions( + '视觉三部', + departments, + ['标准模型', '详情模板', '种草视频'], + ) + + expect(result['标准模型']).toMatchObject({ + department: '视觉三部', + materialType: '标准模型', + canView: false, + canDownload: true, + }) + expect(result['详情模板']).toMatchObject({ + department: '视觉三部', + materialType: '详情模板', + canView: true, + canDownload: true, + canDownloadSource: true, + linkedToDepartment: '共享权限部', + }) + expect(result['种草视频']).toMatchObject({ + department: '视觉三部', + materialType: '种草视频', + canView: true, + linkedToDepartment: '视觉中心', + }) + expect(prisma.departmentMaterialPermission.findMany).toHaveBeenCalledTimes(1) + expect(prisma.departmentMaterialPermission.findUnique).not.toHaveBeenCalled() + }) + + test('关联部门缺少对应配置时保留当前部门自己的权限', async () => { + ;(prisma.departmentMaterialPermission.findMany as jest.Mock).mockResolvedValue([ + permission({ + department: '视觉三部', + materialType: '详情模板', + canView: false, + canDownload: true, + linkedToDepartment: '共享权限部', + }), + ]) + + const result = await getDepartmentPermissions( + '视觉三部', + departments, + ['详情模板'], + ) + + expect(result['详情模板']).toMatchObject({ + department: '视觉三部', + canView: false, + canDownload: true, + linkedToDepartment: '共享权限部', + }) + }) + + test('同一用户的并发可见类型查询合并为一轮数据库读取', async () => { + ;(prisma.departmentMaterialPermission.findMany as jest.Mock).mockResolvedValue([ + permission({ + department: '视觉中心', + materialType: '详情模板', + canView: true, + }), + ]) + + const results = await Promise.all( + Array.from({ length: 13 }, () => + getUserViewableMaterialTypes('视觉三部', 'USER', null, 'user-1'), + ), + ) + + expect(results).toEqual(Array.from({ length: 13 }, () => ['详情模板'])) + expect(prisma.user.findUnique).toHaveBeenCalledTimes(1) + expect(prisma.department.findMany).toHaveBeenCalledTimes(1) + expect(prisma.material.findMany).toHaveBeenCalledTimes(1) + expect(prisma.departmentMaterialPermission.findMany).toHaveBeenCalledTimes(1) + }) +}) diff --git a/lib/__tests__/image-processor-slice.test.ts b/lib/__tests__/image-processor-slice.test.ts new file mode 100644 index 0000000..9da64d5 --- /dev/null +++ b/lib/__tests__/image-processor-slice.test.ts @@ -0,0 +1,122 @@ +/** @jest-environment node */ + +import sharp from 'sharp' +import { + assertProcessableMetadata, + processImage, + sliceImage, +} from '@/lib/image-processor' + +describe('sliceImage width guard', () => { + test('宽图服务器兜底会先等比缩到 1600px 再切片', async () => { + const source = await sharp({ + create: { + width: 2000, + height: 2000, + channels: 3, + background: { r: 220, g: 220, b: 220 }, + }, + }).png().toBuffer() + + const slices = await sliceImage(source, 1500, 200, 1600) + const metadata = await Promise.all(slices.map((slice) => sharp(slice).metadata())) + + expect(slices.length).toBeGreaterThan(0) + expect(slices.length).toBeLessThanOrEqual(60) + for (const item of metadata) { + expect(item.format).toBe('webp') + expect(item.width).toBeLessThanOrEqual(1600) + expect(item.height).toBeLessThanOrEqual(1500) + } + }) + + test('任务详情专用门禁允许超过全局 20000px 但仍在 60 片内的长图', async () => { + const source = await sharp({ + create: { + width: 100, + height: 25_000, + channels: 3, + background: { r: 210, g: 210, b: 210 }, + }, + }).png().toBuffer() + + await expect(sliceImage(source, 1500, 200, 1600)).rejects.toThrow('图片切割失败') + + const slices = await sliceImage(source, 1500, 200, 1600, { + maxInputWidth: 12_000, + maxInputHeight: 90_000, + maxInputPixels: 60_000_000, + allowAnimatedFirstFrame: true, + }) + const metadata = await Promise.all(slices.map((slice) => sharp(slice).metadata())) + + expect(slices).toHaveLength(17) + expect(metadata.every((item) => (item.width || 0) <= 1600 && (item.height || 0) <= 1500)).toBe(true) + }) + + test('动图在任务详情兜底中安全取首帧,默认链路仍拒绝', async () => { + const width = 16 + const height = 16 + const redFrame = Buffer.alloc(width * height * 4) + const blueFrame = Buffer.alloc(width * height * 4) + for (let index = 0; index < width * height; index += 1) { + redFrame[index * 4] = 255 + redFrame[index * 4 + 3] = 255 + blueFrame[index * 4 + 2] = 255 + blueFrame[index * 4 + 3] = 255 + } + const animatedGif = await sharp(Buffer.concat([redFrame, blueFrame]), { + raw: { width, height: height * 2, pageHeight: height, channels: 4 }, + }).gif({ loop: 0, delay: [100, 100] }).toBuffer() + + await expect(processImage(animatedGif)).rejects.toThrow('图片处理失败') + + const preview = await processImage(animatedGif, { + maxWidth: 800, + maxHeight: 1200, + maxSizeKB: 200, + format: 'webp', + allowAnimatedFirstFrame: true, + maxInputWidth: 12_000, + maxInputHeight: 90_000, + maxInputPixels: 60_000_000, + }) + const metadata = await sharp(preview, { animated: true }).metadata() + + expect(metadata.format).toBe('webp') + expect(metadata.pages ?? 1).toBe(1) + }) + + test('任务详情服务器兜底与浏览器保持 12000x90000/60M 输入边界', () => { + const taskDetailLimits = { + maxInputWidth: 12_000, + maxInputHeight: 90_000, + maxInputPixels: 60_000_000, + allowAnimatedFirstFrame: true, + } + + expect(() => assertProcessableMetadata({ + width: 12_001, + height: 1_000, + pages: 1, + } as sharp.Metadata, taskDetailLimits)).toThrow('图片尺寸过大') + + expect(() => assertProcessableMetadata({ + width: 800, + height: 75_000, + pages: 1, + } as sharp.Metadata, taskDetailLimits)).not.toThrow() + + expect(() => assertProcessableMetadata({ + width: 800, + height: 75_001, + pages: 1, + } as sharp.Metadata, taskDetailLimits)).toThrow('图片像素总量过大') + + expect(() => assertProcessableMetadata({ + width: 1, + height: 90_001, + pages: 1, + } as sharp.Metadata, taskDetailLimits)).toThrow('图片尺寸过大') + }) +}) diff --git a/lib/__tests__/material-query.test.ts b/lib/__tests__/material-query.test.ts index 6fa2121..0b3ca40 100644 --- a/lib/__tests__/material-query.test.ts +++ b/lib/__tests__/material-query.test.ts @@ -1,5 +1,14 @@ import { prisma } from '@/lib/prisma' -import { buildMaterialSearchOrConditions } from '@/lib/material-query' +import { + buildMaterialSearchOrConditions, + buildMaterialWhereFromSearchParams, + createMaterialQueryContext, +} from '@/lib/material-query' +import { + getUserViewableMaterialTypes, + isSuperAdmin, + isSystemAdmin, +} from '@/lib/department-permission' jest.mock('@/lib/prisma', () => ({ prisma: { @@ -23,6 +32,9 @@ describe('material search conditions', () => { jest.clearAllMocks() ;(prisma.materialSku.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.user.findMany as jest.Mock).mockResolvedValue([]) + ;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue(['任务-详情']) + ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) + ;(isSystemAdmin as unknown as jest.Mock).mockReturnValue(false) }) test('完整素材 UUID 应使用精确匹配', async () => { @@ -39,4 +51,66 @@ describe('material search conditions', () => { expect(conditions).toEqual(expect.arrayContaining([{ id: materialId }])) }) + + test('同一请求的多组筛选条件复用权限和搜索上下文', async () => { + const searchParams = new URLSearchParams('search=GBGJ-BG0070&category=顾家') + const user = { + id: 'user-1', + role: 'USER', + department: '品牌创意部', + } + + const context = await createMaterialQueryContext(searchParams, user) + const first = await buildMaterialWhereFromSearchParams(searchParams, user, { context }) + const second = await buildMaterialWhereFromSearchParams(searchParams, user, { + context, + exclude: 'category', + }) + + expect(first.empty).toBe(false) + expect(first.where.categoryName).toBe('顾家') + expect(second.where.categoryName).toBeUndefined() + expect(getUserViewableMaterialTypes).toHaveBeenCalledTimes(1) + expect(isSuperAdmin).toHaveBeenCalledTimes(1) + expect(prisma.materialSku.findMany).toHaveBeenCalledTimes(1) + expect(prisma.user.findMany).toHaveBeenCalledTimes(1) + }) + + test('复用上下文后仍拒绝普通用户查看回收站素材', async () => { + await expect( + createMaterialQueryContext( + new URLSearchParams('showDeleted=true'), + { id: 'user-1', role: 'USER', department: '品牌创意部' }, + ), + ).rejects.toMatchObject({ + code: 'FORBIDDEN_DELETED_MATERIALS', + status: 403, + }) + }) + + test('复用上下文后仍拦截用户无权查看的素材类型', async () => { + const searchParams = new URLSearchParams('materialType=3D场景') + const user = { + id: 'user-1', + role: 'USER', + department: '品牌创意部', + } + const context = await createMaterialQueryContext(searchParams, user) + + const result = await buildMaterialWhereFromSearchParams(searchParams, user, { context }) + + expect(result.empty).toBe(true) + }) + + test('超级管理员不读取部门素材权限', async () => { + ;(isSuperAdmin as jest.Mock).mockResolvedValue(true) + + const context = await createMaterialQueryContext( + new URLSearchParams(), + { id: 'super-admin', role: 'ADMIN' }, + ) + + expect(context.isSuperAdmin).toBe(true) + expect(getUserViewableMaterialTypes).not.toHaveBeenCalled() + }) }) diff --git a/lib/__tests__/materials-facets-route.test.ts b/lib/__tests__/materials-facets-route.test.ts new file mode 100644 index 0000000..e3e8b34 --- /dev/null +++ b/lib/__tests__/materials-facets-route.test.ts @@ -0,0 +1,128 @@ +/** @jest-environment node */ + +import { getServerSession } from 'next-auth' +import { prisma } from '@/lib/prisma' +import { GET } from '@/app/api/materials/facets/route' +import { + buildMaterialWhereFromSearchParams, + createMaterialQueryContext, +} from '@/lib/material-query' + +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) +jest.mock('@/lib/auth', () => ({ authOptions: {} })) +jest.mock('@/lib/prisma', () => ({ + prisma: { + material: { + count: jest.fn(), + findMany: jest.fn(), + groupBy: jest.fn(), + }, + }, +})) +jest.mock('@/lib/material-query', () => { + class MaterialQueryError extends Error { + status: number + code: string + + constructor(status: number, code: string, message: string) { + super(message) + this.status = status + this.code = code + } + } + + return { + buildMaterialWhereFromSearchParams: jest.fn(), + createMaterialQueryContext: jest.fn(), + MaterialQueryError, + normalizeMaterialTypeForUi: (value: string) => value, + normalizeSelectableMaterialTypes: (values: string[]) => values, + } +}) + +describe('materials facets route', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(getServerSession as jest.Mock).mockResolvedValue({ + user: { id: 'user-1', department: '品牌创意部', role: 'USER' }, + }) + ;(createMaterialQueryContext as jest.Mock).mockResolvedValue({ + canAccessDeleted: false, + expandedViewableTypes: ['任务-详情'], + isSuperAdmin: false, + }) + ;(buildMaterialWhereFromSearchParams as jest.Mock).mockResolvedValue({ + empty: false, + where: { deletedAt: null, materialType: '任务-详情' }, + }) + }) + + test('一次请求只解析一次权限上下文,把统计查询降为 7 条且并发限制为 3', async () => { + let activeQueries = 0 + let maxActiveQueries = 0 + const runQuery = async (result: T): Promise => { + activeQueries += 1 + maxActiveQueries = Math.max(maxActiveQueries, activeQueries) + await new Promise((resolve) => setTimeout(resolve, 1)) + activeQueries -= 1 + return result + } + + ;(prisma.material.count as jest.Mock).mockImplementation(() => runQuery(0)) + ;(prisma.material.findMany as jest.Mock).mockImplementation(() => runQuery([])) + ;(prisma.material.groupBy as jest.Mock).mockImplementation(() => runQuery([])) + + const response = await GET({ + nextUrl: { + searchParams: new URLSearchParams('materialType=任务-详情'), + }, + } as any) + + expect(response.status).toBe(200) + expect(createMaterialQueryContext).toHaveBeenCalledTimes(1) + expect(buildMaterialWhereFromSearchParams).toHaveBeenCalledTimes(7) + expect(prisma.material.count).toHaveBeenCalledTimes(1) + expect(prisma.material.groupBy).toHaveBeenCalledTimes(5) + expect(prisma.material.findMany).toHaveBeenCalledTimes(1) + expect(maxActiveQueries).toBeLessThanOrEqual(3) + + const context = (createMaterialQueryContext as jest.Mock).mock.results[0].value + const resolvedContext = await context + for (const call of (buildMaterialWhereFromSearchParams as jest.Mock).mock.calls) { + expect(call[2]).toEqual(expect.objectContaining({ context: resolvedContext })) + } + }) + + test('各维度总数直接复用分组计数并保留空值素材', async () => { + ;(prisma.material.count as jest.Mock).mockResolvedValue(9) + ;(prisma.material.groupBy as jest.Mock) + .mockResolvedValueOnce([ + { categoryName: '客厅', _count: { _all: 2 } }, + { categoryName: null, _count: { _all: 1 } }, + ]) + .mockResolvedValueOnce([{ sceneName: '室内', _count: { _all: 3 } }]) + .mockResolvedValueOnce([{ platformName: '站内', _count: { _all: 4 } }]) + .mockResolvedValueOnce([{ productCategoryName: '桌类', _count: { _all: 5 } }]) + .mockResolvedValueOnce([{ materialType: '任务-详情', _count: { _all: 6 } }]) + ;(prisma.material.findMany as jest.Mock).mockResolvedValue([ + { createdAt: new Date('2026-07-01T00:00:00.000Z') }, + { createdAt: new Date('2026-07-02T00:00:00.000Z') }, + ]) + + const response = await GET({ + nextUrl: { searchParams: new URLSearchParams() }, + } as any) + const body = await response.json() + + expect(body.total).toBe(9) + expect(body.categories).toEqual([{ id: '客厅', name: '客厅', count: 2 }]) + expect(body.totals).toEqual({ + materialType: 6, + category: 3, + scene: 3, + platform: 4, + productCategory: 5, + month: 2, + }) + }) +}) diff --git a/lib/__tests__/performance-survey-image-error.test.ts b/lib/__tests__/performance-survey-image-error.test.ts new file mode 100644 index 0000000..48f4607 --- /dev/null +++ b/lib/__tests__/performance-survey-image-error.test.ts @@ -0,0 +1,35 @@ +import { + isPerformanceSurveyBrowserCapabilityError, + PerformanceSurveyBrowserImageError, + type PerformanceSurveyBrowserImageErrorCode, +} from '@/lib/performance-survey-image-error' + +describe('performance-survey-image-error', () => { + test.each([ + 'BROWSER_CAPABILITY_UNAVAILABLE', + 'BROWSER_DECODE_UNAVAILABLE', + 'WORKER_UNAVAILABLE', + 'CANVAS_UNAVAILABLE', + 'CANVAS_EXPORT_UNAVAILABLE', + ])('%s 可进入浏览器能力不足的服务器兜底', (code) => { + expect(isPerformanceSurveyBrowserCapabilityError( + new PerformanceSurveyBrowserImageError(code, '浏览器能力不足'), + )).toBe(true) + }) + + test.each([ + 'SOURCE_FORMAT_UNSUPPORTED', + 'SOURCE_DECODE_FAILED', + 'SOURCE_DIMENSIONS_INVALID', + 'SOURCE_WIDTH_LIMIT', + 'SOURCE_PIXEL_LIMIT', + 'SOURCE_SLICE_LIMIT', + 'SOURCE_COUNT_LIMIT', + 'NO_SOURCE_IMAGES', + 'PROCESSING_FAILED', + ])('%s 必须阻断,不能冒充浏览器兼容问题', (code) => { + expect(isPerformanceSurveyBrowserCapabilityError( + new PerformanceSurveyBrowserImageError(code, '源图或处理策略错误'), + )).toBe(false) + }) +}) diff --git a/lib/__tests__/performance-survey-image-source-guard.test.ts b/lib/__tests__/performance-survey-image-source-guard.test.ts index a12e266..7977c13 100644 --- a/lib/__tests__/performance-survey-image-source-guard.test.ts +++ b/lib/__tests__/performance-survey-image-source-guard.test.ts @@ -2,7 +2,7 @@ import { assertPerformanceSurveyBrowserSourceImageDimensions, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS, readPerformanceSurveySourceImageDimensions, - PerformanceSurveySourceImageError, + TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS, } from '@/lib/performance-survey-image-source-guard' function pngHeader(width: number, height: number) { @@ -33,7 +33,7 @@ describe('performance survey browser source image guard', () => { expect(() => assertPerformanceSurveyBrowserSourceImageDimensions( { width: PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxWidth + 1, height: 100 }, 'too-wide.png', - )).toThrow(new PerformanceSurveySourceImageError('图片「too-wide.png」宽度超过 12000px')) + )).toThrow('图片「too-wide.png」宽度超过 12000px') expect(() => assertPerformanceSurveyBrowserSourceImageDimensions( { width: 10000, height: 12001 }, 'too-many-pixels.png', @@ -50,4 +50,17 @@ describe('performance survey browser source image guard', () => { 'too-tall.png', )).toThrow('处理后需要 61 张切片') }) + + test('任务详情像素门禁与服务器 Edge 兜底的 60M 限制一致', () => { + const dimensions = { width: 10_000, height: 7_000 } + expect(() => assertPerformanceSurveyBrowserSourceImageDimensions( + dimensions, + 'performance-survey.png', + )).not.toThrow() + expect(() => assertPerformanceSurveyBrowserSourceImageDimensions( + dimensions, + 'task-detail.png', + { maxPixels: TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS }, + )).toThrow('像素总量超过 60,000,000') + }) }) diff --git a/lib/__tests__/performance-survey-service-policy.test.ts b/lib/__tests__/performance-survey-service-policy.test.ts index 34424c7..da2b7a3 100644 --- a/lib/__tests__/performance-survey-service-policy.test.ts +++ b/lib/__tests__/performance-survey-service-policy.test.ts @@ -82,6 +82,7 @@ jest.mock('@/lib/performance-survey-position-config', () => { jest.mock('@/lib/performance-survey-report-sync', () => ({ ensurePerformanceSurveyDailyEmployeeSyncStarted: jest.fn(), ensurePerformanceSurveyEmployeeProfileForUser: jest.fn(), + getActivePerformanceSurveyEmployeeProfileWhere: jest.fn(() => ({ employmentStatus: { not: 'inactive' } })), getPerformanceSurveyAccountProfileWhere: jest.fn(async () => ({ id: { not: '__hidden__' } })), previewPerformanceSurveyEmployeeSync: jest.fn(), syncPerformanceSurveyEmployees: jest.fn(), @@ -1487,6 +1488,10 @@ describe('performance survey service policies', () => { ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) .mockResolvedValueOnce([creator, target]) .mockResolvedValueOnce([creator]) + ;(prisma.user.findMany as jest.Mock).mockResolvedValueOnce([{ + id: baseUser.id, + dingtalkUserId: baseUser.dingtalkUserId, + }]) ;(prisma.user.findFirst as jest.Mock).mockResolvedValue({ id: baseUser.id, dingtalkUserId: baseUser.dingtalkUserId, @@ -1609,7 +1614,7 @@ describe('performance survey service policies', () => { }) const evaluatorQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[1][0] - expect(evaluatorQuery.where.AND[1].OR).toContainEqual({ dingtalkUserId: evaluator.dingtalkUserId }) + expect(evaluatorQuery.where.AND[1]).toEqual({ dingtalkUserId: evaluator.dingtalkUserId }) const assignmentRows = transactionClient.performanceSurveyAssignment.createMany.mock.calls[0][0].data expect(assignmentRows).toEqual([ expect.objectContaining({ @@ -1712,7 +1717,16 @@ describe('performance survey service policies', () => { ]) }) - test('直属主管模板遇到人员档案缺少主管唯一值时明确拒绝创建', async () => { + test.each([ + ['缺少主组织', []], + [ + '存在多个主组织', + [ + { sourceDeptId: '1085808623' }, + { sourceDeptId: '1085808624' }, + ], + ], + ])('直属主管模板在人员档案缺少主管唯一值且%s时明确拒绝创建', async (_label, primaryMemberships) => { const target = { ...baseEmployee, id: 'employee-target', @@ -1734,6 +1748,8 @@ describe('performance survey service policies', () => { questions: [], scoreBands: [], }) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock) + .mockResolvedValueOnce(primaryMemberships) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValueOnce([target]) await expect(createPerformanceSurvey(session, { @@ -1742,13 +1758,13 @@ describe('performance survey service policies', () => { assessmentMonth: '2026-07', targetEmployeeIds: [target.id], })).rejects.toEqual(new PerformanceSurveyApiError( - '无法确定缺主管人员的直属主管,请先校准人员档案', + '无法确定缺主管人员所属的主组织,请先校准人员档案', 409, )) expect(prisma.$transaction).not.toHaveBeenCalled() }) - test('直属主管模板不按同部门 M 级人员猜测主管', async () => { + test('直属主管唯一值缺失时按主组织唯一 M 级人员解析主管', async () => { const target = { ...baseEmployee, id: 'employee-yijiang', @@ -1759,6 +1775,7 @@ describe('performance survey service policies', () => { levelType: 'P', positionLevel: 'P4', managerDingtalkUserId: null, + managerJobNumber: null, } const uniqueManager = { ...baseEmployee, @@ -1769,6 +1786,8 @@ describe('performance survey service policies', () => { dingtalkUserId: 'ding-wuxie', levelType: 'M', positionLevel: 'M1', + isLeadership: true, + sourcePayload: { materialUserId: 'user-wuxie' }, } ;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target) ;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({ @@ -1780,14 +1799,19 @@ describe('performance survey service policies', () => { questions: [], scoreBands: [], }) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock) + .mockResolvedValueOnce([{ sourceDeptId: '1085808623' }]) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) .mockResolvedValueOnce([target]) .mockResolvedValueOnce([uniqueManager]) - .mockResolvedValueOnce([uniqueManager]) - ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ + ;(prisma.user.findMany as jest.Mock).mockResolvedValueOnce([{ id: 'user-wuxie', dingtalkUserId: uniqueManager.dingtalkUserId, }]) + ;(prisma.user.findFirst as jest.Mock).mockResolvedValue({ + id: 'user-wuxie', + dingtalkUserId: uniqueManager.dingtalkUserId, + }) ;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({ templateCode: 'work_cooperation', creatorEmployeeId: target.id, @@ -1803,6 +1827,72 @@ describe('performance survey service policies', () => { }], })) + await createPerformanceSurvey(session, { + templateCode: 'work_cooperation', + positionGroupCode: 'brand_designer', + assessmentMonth: '2026-07', + targetEmployeeIds: [target.id], + openNow: false, + }) + + expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).toHaveBeenCalledWith({ + where: { + employeeProfileId: target.id, + isPrimary: true, + }, + select: { sourceDeptId: true }, + take: 2, + }) + const managerQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[1][0] + expect(managerQuery.where.AND).toContainEqual({ + departmentMemberships: { + some: { + sourceDeptId: '1085808623', + isPrimary: true, + }, + }, + }) + const assignmentRows = transactionClient.performanceSurveyAssignment.createMany.mock.calls[0][0].data + expect(assignmentRows).toEqual([ + expect.objectContaining({ + evaluatorEmployeeId: uniqueManager.id, + evaluatorDingtalkUserId: uniqueManager.dingtalkUserId, + evaluatorName: uniqueManager.displayName, + reviewerSource: 'direct_manager', + }), + ]) + }) + + test('主管唯一值已配置但账户不可用时不回退组织推断', async () => { + const target = { + ...baseEmployee, + id: 'employee-target', + displayName: '被考核人', + dingtalkUserId: 'ding-target', + managerDingtalkUserId: 'ding-declared-manager', + } + const manager = { + ...baseEmployee, + id: 'employee-manager', + displayName: '档案直属主管', + dingtalkUserId: 'ding-declared-manager', + sourcePayload: { materialUserId: 'user-manager' }, + } + ;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target) + ;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({ + code: 'work_cooperation', + name: '工作配合度满意度调研', + shortName: '工作配合度', + surveyType: 'person_period', + weight: 0.2, + questions: [], + scoreBands: [], + }) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) + .mockResolvedValueOnce([target]) + .mockResolvedValueOnce([manager]) + ;(prisma.user.findMany as jest.Mock).mockResolvedValueOnce([]) + await expect(createPerformanceSurvey(session, { templateCode: 'work_cooperation', positionGroupCode: 'brand_designer', @@ -1810,13 +1900,106 @@ describe('performance survey service policies', () => { targetEmployeeIds: [target.id], openNow: false, })).rejects.toEqual(new PerformanceSurveyApiError( - '无法确定忆江的直属主管,请先校准人员档案', + '被考核人的直属主管尚无可用账户,请先校准人员档案', + 409, + )) + expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).not.toHaveBeenCalled() + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + test.each([ + { + label: '钉钉身份匹配但工号冲突', + }, + { + label: '工号匹配但钉钉身份冲突', + }, + ])('主管双标识存在时,$label必须拒绝匹配', async () => { + const target = { + ...baseEmployee, + id: 'employee-target', + displayName: '被考核人', + dingtalkUserId: 'ding-target', + managerDingtalkUserId: 'ding-declared-manager', + managerJobNumber: 'JOB-DECLARED', + } + ;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target) + ;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({ + code: 'work_cooperation', + name: '工作配合度满意度调研', + shortName: '工作配合度', + surveyType: 'person_period', + weight: 0.2, + questions: [], + scoreBands: [], + }) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) + .mockResolvedValueOnce([target]) + // 数据库必须同时满足两个标识;任一冲突都会返回空结果。 + .mockResolvedValueOnce([]) + + await expect(createPerformanceSurvey(session, { + templateCode: 'work_cooperation', + positionGroupCode: 'brand_designer', + assessmentMonth: '2026-07', + targetEmployeeIds: [target.id], + openNow: false, + })).rejects.toEqual(new PerformanceSurveyApiError( + '无法唯一匹配被考核人的直属主管', + 409, + )) + + const managerQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[1][0] + expect(managerQuery.where.AND).toContainEqual({ + dingtalkUserId: target.managerDingtalkUserId, + jobNumber: target.managerJobNumber, + }) + expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).not.toHaveBeenCalled() + expect(prisma.$transaction).not.toHaveBeenCalled() + }) + + test('主管唯一值缺失但主组织存在多名 M 级人员时拒绝猜测', async () => { + const target = { + ...baseEmployee, + id: 'employee-target', + displayName: '被考核人', + dingtalkUserId: 'ding-target', + managerDingtalkUserId: null, + managerJobNumber: null, + } + ;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(target) + ;(getPerformanceSurveyTemplate as jest.Mock).mockResolvedValue({ + code: 'work_cooperation', + name: '工作配合度满意度调研', + shortName: '工作配合度', + surveyType: 'person_period', + weight: 0.2, + questions: [], + scoreBands: [], + }) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock) + .mockResolvedValueOnce([{ sourceDeptId: '1085808623' }]) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) + .mockResolvedValueOnce([target]) + .mockResolvedValueOnce([ + { ...baseEmployee, id: 'manager-1', levelType: 'M', positionLevel: 'M1' }, + { ...baseEmployee, id: 'manager-2', levelType: 'M', positionLevel: 'M2' }, + ]) + + await expect(createPerformanceSurvey(session, { + templateCode: 'work_cooperation', + positionGroupCode: 'brand_designer', + assessmentMonth: '2026-07', + targetEmployeeIds: [target.id], + openNow: false, + })).rejects.toEqual(new PerformanceSurveyApiError( + '被考核人所属组织内存在多名M级人员,请明确配置直属主管', 409, )) expect(prisma.$transaction).not.toHaveBeenCalled() }) - test('人员姓名不再触发固定评分人规则,缺主管仍按档案错误处理', async () => { + test('人员姓名不触发固定评分人规则,缺主管时仍按组织规则处理', async () => { const yuanzhe = { ...baseEmployee, id: 'employee-yuanzhe', @@ -1838,7 +2021,11 @@ describe('performance survey service policies', () => { questions: [], scoreBands: [], }) - ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValueOnce([yuanzhe]) + ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock) + .mockResolvedValueOnce([{ sourceDeptId: '1085808623' }]) + ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock) + .mockResolvedValueOnce([yuanzhe]) + .mockResolvedValueOnce([]) await expect(createPerformanceSurvey(session, { templateCode: 'work_cooperation', @@ -1846,7 +2033,7 @@ describe('performance survey service policies', () => { assessmentMonth: '2026-07', targetEmployeeIds: [yuanzhe.id], })).rejects.toEqual(new PerformanceSurveyApiError( - '无法确定元泽的直属主管,请先校准人员档案', + '元泽所属组织内没有可用的M级主管,请先校准人员档案', 409, )) expect(prisma.$transaction).not.toHaveBeenCalled() @@ -1896,10 +2083,10 @@ describe('performance survey service policies', () => { .mockResolvedValueOnce([manager]) ;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockReset() ;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(yuanzhe) - ;(prisma.user.findMany as jest.Mock).mockResolvedValue([ - { id: 'user-1', dingtalkUserId: 'ding-yuanzhe' }, - { id: 'user-manager', dingtalkUserId: 'ding-manager' }, - ]) + ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ + id: 'user-manager', + dingtalkUserId: 'ding-manager', + }]) ;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({ templateCode: 'visual_brand_tone', targets: [{ @@ -1989,10 +2176,10 @@ describe('performance survey service policies', () => { .mockResolvedValueOnce([target]) .mockResolvedValueOnce([manager]) ;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(yuanzhe) - ;(prisma.user.findMany as jest.Mock).mockResolvedValue([ - { id: 'user-target', dingtalkUserId }, - { id: 'user-yuanzhe', dingtalkUserId: '27102756692071120919' }, - ]) + ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ + id: 'user-manager', + dingtalkUserId: 'ding-declared-manager', + }]) ;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({ templateCode: 'visual_brand_tone', targets: [{ @@ -2076,11 +2263,10 @@ describe('performance survey service policies', () => { .mockResolvedValueOnce([target]) .mockResolvedValueOnce([guanshi]) ;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(yuanzhe) - ;(prisma.user.findMany as jest.Mock).mockResolvedValue([ - { id: 'user-target', dingtalkUserId: 'ding-photographer' }, - { id: 'user-yuanzhe', dingtalkUserId: '27102756692071120919' }, - { id: 'user-guanshi', dingtalkUserId: '17642061233583361' }, - ]) + ;(prisma.user.findMany as jest.Mock).mockResolvedValue([{ + id: 'user-guanshi', + dingtalkUserId: '17642061233583361', + }]) ;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue(buildSurvey({ templateCode: 'visual_brand_tone', targets: [{ @@ -2103,7 +2289,7 @@ describe('performance survey service policies', () => { }) const managerQuery = (prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mock.calls[1][0] - expect(managerQuery.where.AND[1].OR).toContainEqual({ dingtalkUserId: '17642061233583361' }) + expect(managerQuery.where.AND[1]).toEqual({ dingtalkUserId: '17642061233583361' }) const assignmentRows = transactionClient.performanceSurveyAssignment.createMany.mock.calls[0][0].data expect(assignmentRows).toEqual(expect.arrayContaining([ expect.objectContaining({ diff --git a/lib/__tests__/product-package-abort-route-security.test.ts b/lib/__tests__/product-package-abort-route-security.test.ts index 00fb4ee..685c957 100644 --- a/lib/__tests__/product-package-abort-route-security.test.ts +++ b/lib/__tests__/product-package-abort-route-security.test.ts @@ -4,7 +4,7 @@ import { POST } from '@/app/api/materials/upload/product-package/abort/route' import { getServerSession } from 'next-auth' import { prisma } from '@/lib/prisma' import { - deleteUploadSession, + deleteOwnedUploadSession, getOwnedUploadSession, } from '@/lib/upload-sessions' import { isPathFreeOfSymbolicLinks } from '@/lib/path-security' @@ -23,6 +23,7 @@ jest.mock('@/lib/auth', () => ({ jest.mock('@/lib/prisma', () => ({ prisma: { + $transaction: jest.fn(), material: { findUnique: jest.fn(), delete: jest.fn(), @@ -30,11 +31,14 @@ jest.mock('@/lib/prisma', () => ({ materialAsset: { count: jest.fn(), }, + productPackageUploadJob: { + findFirst: jest.fn(), + }, }, })) jest.mock('@/lib/upload-sessions', () => ({ - deleteUploadSession: jest.fn(), + deleteOwnedUploadSession: jest.fn(), getOwnedUploadSession: jest.fn(), isSafeUploadId: jest.fn((uploadId: string) => /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(String(uploadId || '').trim()), @@ -69,6 +73,13 @@ describe('product-package abort route security', () => { }) ;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0) ;(prisma.material.delete as jest.Mock).mockResolvedValue({ id: 'material-1' }) + ;(prisma.productPackageUploadJob.findFirst as jest.Mock).mockResolvedValue(null) + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback({ + $queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]), + material: prisma.material, + materialAsset: prisma.materialAsset, + productPackageUploadJob: prisma.productPackageUploadJob, + })) ;(getOwnedUploadSession as jest.Mock).mockResolvedValue({ fileName: 'detail.zip', fileSize: 1024, @@ -77,7 +88,7 @@ describe('product-package abort route security', () => { createdAt: Date.now(), userId: 'user-1', }) - ;(deleteUploadSession as jest.Mock).mockResolvedValue(undefined) + ;(deleteOwnedUploadSession as jest.Mock).mockResolvedValue(true) ;(isPathFreeOfSymbolicLinks as jest.Mock).mockResolvedValue(true) ;(unlink as jest.Mock).mockRejectedValue( Object.assign(new Error('not found'), { code: 'ENOENT' }), @@ -125,14 +136,14 @@ describe('product-package abort route security', () => { expect(getOwnedUploadSession).toHaveBeenCalledTimes(2) expect(unlink).not.toHaveBeenCalled() expect(rm).not.toHaveBeenCalled() - expect(deleteUploadSession).not.toHaveBeenCalled() + expect(deleteOwnedUploadSession).not.toHaveBeenCalled() expect(prisma.material.delete).not.toHaveBeenCalled() }) - test('单次上传会话数量超过上限时不得进入清理', async () => { + test('单次上传会话数量超过合法最大提交上限时不得进入清理', async () => { const response = await POST(createRequest({ materialId: 'material-1', - uploadIds: Array.from({ length: 201 }, (_, index) => `upload-${index}`), + uploadIds: Array.from({ length: 1141 }, (_, index) => `upload-${index}`), })) expect(response.status).toBe(400) @@ -140,6 +151,44 @@ describe('product-package abort route security', () => { expect(prisma.material.delete).not.toHaveBeenCalled() }) + test('详情长图产生超过 200 个合法预览会话时仍可完整回滚', async () => { + const uploadIds = Array.from({ length: 244 }, (_, index) => `upload-${index}`) + let activeSessionChecks = 0 + let maxActiveSessionChecks = 0 + ;(getOwnedUploadSession as jest.Mock).mockImplementation(async () => { + activeSessionChecks += 1 + maxActiveSessionChecks = Math.max(maxActiveSessionChecks, activeSessionChecks) + await new Promise((resolve) => setImmediate(resolve)) + activeSessionChecks -= 1 + return { + fileName: 'detail.zip', + fileSize: 1024, + totalChunks: 1, + chunks: new Map(), + createdAt: Date.now(), + userId: 'user-1', + } + }) + const response = await POST(createRequest({ + materialId: 'material-1', + uploadIds, + })) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual(expect.objectContaining({ + success: true, + deleted: true, + cleanedUploadIds: 244, + })) + expect(getOwnedUploadSession).toHaveBeenCalledTimes(244) + expect(maxActiveSessionChecks).toBeGreaterThan(1) + expect(maxActiveSessionChecks).toBeLessThanOrEqual(12) + expect(unlink).toHaveBeenCalledTimes(244) + expect(rm).toHaveBeenCalledTimes(244) + expect(deleteOwnedUploadSession).toHaveBeenCalledTimes(244) + }) + test('会话文件名导致组装路径越界时不得执行任何删除', async () => { ;(getOwnedUploadSession as jest.Mock).mockResolvedValueOnce({ fileName: `x${'\\..'.repeat(6)}\\sentinel.txt`, @@ -180,7 +229,8 @@ describe('product-package abort route security', () => { expect(unlink).toHaveBeenCalledTimes(2) expect(rm).toHaveBeenCalledTimes(2) expect(prisma.material.delete).toHaveBeenCalledWith({ where: { id: 'material-1' } }) - expect(deleteUploadSession).toHaveBeenCalledTimes(2) + expect(deleteOwnedUploadSession).toHaveBeenNthCalledWith(1, 'upload-1', 'user-1') + expect(deleteOwnedUploadSession).toHaveBeenNthCalledWith(2, 'upload-2', 'user-1') for (const [targetPath] of (rm as jest.Mock).mock.calls) { expect(targetPath).toContain(`uploads${require('path').sep}temp${require('path').sep}upload-`) diff --git a/lib/__tests__/product-package-complete-route-security.test.ts b/lib/__tests__/product-package-complete-route-security.test.ts index d869813..cec5b0c 100644 --- a/lib/__tests__/product-package-complete-route-security.test.ts +++ b/lib/__tests__/product-package-complete-route-security.test.ts @@ -6,6 +6,11 @@ import { POST } from '@/app/api/materials/upload/product-package/complete/route' import { completeProductPackageUpload, } from '@/lib/product-package-completion' +import { + createTaskDetailUploadJob, + getLatestTaskDetailUploadJobStatus, + TaskDetailUploadQueueCapacityError, +} from '@/lib/task-detail-upload-jobs' jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) jest.mock('@/lib/auth', () => ({ authOptions: {} })) @@ -23,15 +28,37 @@ jest.mock('@/lib/product-package-completion', () => { completeProductPackageUpload: jest.fn(), } }) +jest.mock('@/lib/task-detail-upload-jobs', () => ({ + createTaskDetailUploadJob: jest.fn(), + getLatestTaskDetailUploadJobStatus: jest.fn().mockResolvedValue(null), + TaskDetailUploadQueueCapacityError: class TaskDetailUploadQueueCapacityError extends Error { + scope: 'user' | 'global' + statusCode: 429 | 503 + + constructor(scope: 'user' | 'global') { + super(scope === 'user' ? '您的等待任务已满' : '全局等待队列已满') + this.scope = scope + this.statusCode = scope === 'user' ? 429 : 503 + } + }, +})) describe('product-package complete public route security', () => { beforeEach(() => { jest.clearAllMocks() ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } }) + ;(getLatestTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue(null) ;(completeProductPackageUpload as jest.Mock).mockResolvedValue({ materialId: 'material-1', taskNo: null, isFullSet: false, }) + ;(createTaskDetailUploadJob as jest.Mock).mockResolvedValue({ + jobId: 'job-1', + materialId: 'material-1', + status: 'queued', + stage: 'queued', + progress: 0, + }) }) test.each([ @@ -92,4 +119,298 @@ describe('product-package complete public route security', () => { expect(body.error).toContain('缺少需求运营人员') expect(completeProductPackageUpload).not.toHaveBeenCalled() }) + + test('缺少出图方式的新流程素材不能完成上传', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + materialType: '任务-详情', + taskDetailRequesterUserId: 'requester-1', + taskDetailSourceType: null, + }) + + const response = await POST({ + json: jest.fn().mockResolvedValue({ + materialId: 'material-1', + assets: { main: [] }, + }), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toContain('出图方式') + expect(completeProductPackageUpload).not.toHaveBeenCalled() + }) + + test('合法任务详情上传会快速入队并返回 jobId', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + materialType: '任务-详情', + taskDetailRequesterUserId: 'requester-1', + taskDetailSourceType: '实拍', + zipFilePath: '', + }) + + const response = await POST({ + json: jest.fn().mockResolvedValue({ + materialId: 'material-1', + assets: { + main: [{ + uploadId: 'upload-main', + fileName: 'main.png', + fileSize: 10, + previewAssets: [{ + uploadId: 'upload-preview', + fileName: 'main-preview.webp', + fileSize: 5, + width: 800, + height: 800, + kind: 'preview', + }], + }], + }, + }), + } as any) + const body = await response.json() + + expect(response.status).toBe(202) + expect(body.data.jobId).toBe('job-1') + expect(createTaskDetailUploadJob).toHaveBeenCalledWith(expect.objectContaining({ + materialId: 'material-1', + operatorUserId: 'user-1', + })) + expect(completeProductPackageUpload).not.toHaveBeenCalled() + }) + + test('客户端预览失败时仍可带原图入队,由后台生成预览', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + materialType: '任务-详情', + taskDetailRequesterUserId: 'requester-1', + taskDetailSourceType: '实拍', + zipFilePath: '', + _count: { assets: 0 }, + }) + + const response = await POST({ + json: jest.fn().mockResolvedValue({ + materialId: 'material-1', + allowServerPreviewFallback: true, + assets: { + main: [{ + uploadId: 'upload-main', + fileName: 'main.jpg', + fileSize: 10, + }], + }, + }), + } as any) + const body = await response.json() + + expect(response.status).toBe(202) + expect(body.data.jobId).toBe('job-1') + expect(createTaskDetailUploadJob).toHaveBeenCalledWith(expect.objectContaining({ + materialId: 'material-1', + assets: expect.objectContaining({ + main: [expect.objectContaining({ uploadId: 'upload-main' })], + }), + })) + }) + + test('缺少客户端预览且未声明兜底时仍拒绝完成请求', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + materialType: '任务-详情', + taskDetailRequesterUserId: 'requester-1', + taskDetailSourceType: '实拍', + zipFilePath: '', + _count: { assets: 0 }, + }) + + const response = await POST({ + json: jest.fn().mockResolvedValue({ + materialId: 'material-1', + assets: { + main: [{ uploadId: 'upload-main', fileName: 'main.jpg', fileSize: 10 }], + }, + }), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toContain('客户端预览图缺失') + expect(createTaskDetailUploadJob).not.toHaveBeenCalled() + }) + + test('已有部分持久化结果的素材不能重复入队', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + materialType: '任务-详情', + taskDetailRequesterUserId: 'requester-1', + taskDetailSourceType: '实拍', + zipFilePath: 'materials/product-package/material-1/all.zip', + }) + + const response = await POST({ + json: jest.fn().mockResolvedValue({ + materialId: 'material-1', + assets: { + main: [{ + uploadId: 'upload-main', + fileName: 'main.png', + fileSize: 10, + previewAssets: [{ + uploadId: 'upload-preview', + fileName: 'main.webp', + fileSize: 5, + width: 800, + height: 800, + kind: 'preview', + }], + }], + }, + }), + } as any) + + expect(response.status).toBe(409) + expect(createTaskDetailUploadJob).not.toHaveBeenCalled() + }) + + test('重复提交同一素材时返回已有后台任务,避免丢失 202 响应后重复上传', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + materialType: '任务-详情', + taskDetailRequesterUserId: 'requester-1', + taskDetailSourceType: '实拍', + zipFilePath: '', + _count: { assets: 0 }, + }) + ;(getLatestTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue({ + jobId: 'job-existing', + materialId: 'material-1', + status: 'processing', + stage: 'zips', + progress: 80, + }) + + const response = await POST({ + json: jest.fn().mockResolvedValue({ + materialId: 'material-1', + assets: { main: [] }, + }), + } as any) + const body = await response.json() + + expect(response.status).toBe(202) + expect(body.data.jobId).toBe('job-existing') + expect(createTaskDetailUploadJob).not.toHaveBeenCalled() + }) + + test('首次 202 丢失且后台已完成时,即使素材已有 ZIP 也返回原任务', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + materialType: '任务-详情', + taskDetailRequesterUserId: 'requester-1', + taskDetailSourceType: '实拍', + zipFilePath: 'materials/product-package/material-1/all.zip', + _count: { assets: 5 }, + }) + ;(getLatestTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue({ + jobId: 'job-completed', + materialId: 'material-1', + status: 'completed', + stage: 'completed', + progress: 100, + }) + + const response = await POST({ + json: jest.fn().mockResolvedValue({ materialId: 'material-1', assets: {} }), + } as any) + const body = await response.json() + + expect(response.status).toBe(202) + expect(body.data).toEqual(expect.objectContaining({ + jobId: 'job-completed', + status: 'completed', + progress: 100, + })) + expect(createTaskDetailUploadJob).not.toHaveBeenCalled() + }) + + test('直接 HTTP 提交超过单组图片上限时在查询素材前拒绝', async () => { + const response = await POST({ + json: jest.fn().mockResolvedValue({ + materialId: 'material-1', + allowServerPreviewFallback: true, + assets: { + main: Array.from({ length: 61 }, (_, index) => ({ + uploadId: `upload-main-${index}`, + fileName: `${index}.jpg`, + fileSize: 1, + })), + }, + }), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toContain('主图最多上传 60 张') + expect(prisma.material.findUnique).not.toHaveBeenCalled() + }) + + test('直接 HTTP 用大量小 PSD 绕过图片计数时拒绝', async () => { + const response = await POST({ + json: jest.fn().mockResolvedValue({ + materialId: 'material-1', + assets: { + psd: Array.from({ length: 21 }, (_, index) => ({ + uploadId: `upload-psd-${index}`, + fileName: `${index}.psd`, + fileSize: 1, + })), + }, + }), + } as any) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toContain('PSD 源文件最多上传 20 个') + expect(prisma.material.findUnique).not.toHaveBeenCalled() + }) + + test('用户等待队列已满时返回 429 和重试提示', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ + id: 'material-1', + userId: 'user-1', + materialType: '任务-详情', + taskDetailRequesterUserId: 'requester-1', + taskDetailSourceType: '实拍', + zipFilePath: '', + _count: { assets: 0 }, + }) + ;(createTaskDetailUploadJob as jest.Mock).mockRejectedValue( + new TaskDetailUploadQueueCapacityError('user'), + ) + + const response = await POST({ + json: jest.fn().mockResolvedValue({ + materialId: 'material-1', + allowServerPreviewFallback: true, + assets: { + main: [{ uploadId: 'upload-main', fileName: 'main.jpg', fileSize: 10 }], + }, + }), + } as any) + const body = await response.json() + + expect(response.status).toBe(429) + expect(response.headers.get('retry-after')).toBe('60') + expect(body.code).toBe('UPLOAD_QUEUE_FULL') + }) }) diff --git a/lib/__tests__/product-package-completion.test.ts b/lib/__tests__/product-package-completion.test.ts index e7c5dd6..0cdd439 100644 --- a/lib/__tests__/product-package-completion.test.ts +++ b/lib/__tests__/product-package-completion.test.ts @@ -1,6 +1,9 @@ /** @jest-environment node */ import { + assertClientPreviewWebpMetadata, + assertOwnedProductPackageUploadSessions, + assertTaskDetailUploadAssetLimits, completeProductPackageUpload, parsePublicProductPackageAssets, } from '@/lib/product-package-completion' @@ -10,9 +13,17 @@ import { queueTaskDetailFullSetNotification, } from '@/lib/external-auto-listing' import { createMaterialLog } from '@/lib/material-log' -import { createZipFromFiles } from '@/lib/task-material-processor' -import { getOwnedUploadSession } from '@/lib/upload-sessions' +import { + cleanupOnError, + cleanupTempFiles, + createZipFromFiles, + saveProductPackageAssets, +} from '@/lib/task-material-processor' +import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions' import { assignTaskNoOnComplete } from '@/lib/product-package' +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import path from 'path' jest.mock('@/lib/prisma', () => ({ prisma: { @@ -20,6 +31,11 @@ jest.mock('@/lib/prisma', () => ({ findUnique: jest.fn(), update: jest.fn(), }, + materialAsset: { + count: jest.fn(), + deleteMany: jest.fn(), + }, + $transaction: jest.fn(), }, })) @@ -80,6 +96,11 @@ jest.mock('@/lib/image-processor', () => ({ sliceImage: jest.fn(async (buffer: Buffer) => [buffer]), })) +jest.mock('@/lib/remote-upload-sync', () => ({ + isRemoteUploadSyncEnabled: jest.fn(() => false), + syncStoredUploadFileToRemote: jest.fn().mockResolvedValue(null), +})) + jest.mock('sharp', () => ({ __esModule: true, default: jest.fn(() => ({ @@ -101,6 +122,12 @@ describe('product-package-completion', () => { }, }) ;(prisma.material.update as jest.Mock).mockResolvedValue(undefined) + ;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0) + ;(prisma.materialAsset.deleteMany as jest.Mock).mockResolvedValue({ count: 0 }) + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: (tx: any) => unknown) => callback({ + materialAsset: { deleteMany: prisma.materialAsset.deleteMany }, + material: { update: prisma.material.update }, + })) global.fetch = jest.fn(async () => new Response(Buffer.from('image-binary'), { status: 200, @@ -111,6 +138,134 @@ describe('product-package-completion', () => { ) as any }) + test('后台处理入口再次执行图片、PSD 和预览数量门禁', () => { + expect(() => assertTaskDetailUploadAssetLimits({ + main: Array.from({ length: 61 }, (_, index) => ({ + uploadId: `main-${index}`, + fileName: `${index}.jpg`, + fileSize: 1, + })), + })).toThrow('主图最多上传 60 张') + + expect(() => assertTaskDetailUploadAssetLimits({ + psd: Array.from({ length: 21 }, (_, index) => ({ + uploadId: `psd-${index}`, + fileName: `${index}.psd`, + fileSize: 1, + })), + })).toThrow('PSD 源文件最多上传 20 个') + + const imageGroup = (prefix: string, count: number) => Array.from({ length: count }, (_, index) => ({ + uploadId: `${prefix}-${index}`, + fileName: `${prefix}-${index}.jpg`, + fileSize: 1, + })) + expect(() => assertTaskDetailUploadAssetLimits({ + main: imageGroup('main', 41), + sku: imageGroup('sku', 40), + detail: imageGroup('detail', 40), + })).toThrow('本次图片最多上传 120 张') + + const previews = Array.from({ length: 60 }, (_, index) => ({ + uploadId: `preview-${index}`, + fileName: `${index}.webp`, + fileSize: 1, + width: 1, + height: 1, + kind: 'preview', + })) + expect(() => assertTaskDetailUploadAssetLimits({ + main: Array.from({ length: 17 }, (_, index) => ({ + uploadId: `source-${index}`, + fileName: `${index}.jpg`, + fileSize: 1, + previewAssets: previews.map((preview, previewIndex) => ({ + ...preview, + uploadId: `${index}-${previewIndex}`, + })), + })), + })).toThrow('客户端预览图数量过多') + }) + + test('真实会话复核将源文件和预览图字节分别计算', async () => { + const sourceBytes = 1024 * 1024 * 1024 + ;(getOwnedUploadSession as jest.Mock).mockImplementation(async (uploadId: string) => ({ + userId: 'user-1', + fileSize: uploadId === 'preview-1' ? 1 : sourceBytes - 1, + })) + + await expect(assertOwnedProductPackageUploadSessions({ + main: [{ + fileUrl: 'https://img.example/main.png', + fileName: 'main.png', + fileSize: 1, + previewAssets: [{ + uploadId: 'preview-1', + fileName: 'preview.webp', + fileSize: 1, + width: 1, + height: 1, + kind: 'preview', + }], + }], + psd: [{ + uploadId: 'source-1', + fileName: 'source.psd', + fileSize: sourceBytes - 1, + }], + }, 'user-1')).resolves.toBeUndefined() + }) + + test('客户端 WebP 预览使用文件路径读取真实元数据并匹配申报尺寸', async () => { + const metadata = jest.fn().mockResolvedValue({ + format: 'webp', + width: 800, + height: 600, + pages: 1, + }) + const sharpModule = { default: jest.fn(() => ({ metadata })) } as any + + await expect(assertClientPreviewWebpMetadata({ + uploadId: 'preview-1', + fileName: 'preview.webp', + fileSize: 10, + width: 800, + height: 600, + kind: 'preview', + }, 'D:\\uploads\\preview.webp', sharpModule)).resolves.toBeUndefined() + + expect(sharpModule.default).toHaveBeenCalledWith( + 'D:\\uploads\\preview.webp', + expect.objectContaining({ + limitInputPixels: 2_400_000, + sequentialRead: true, + failOn: 'error', + }), + ) + }) + + test.each([ + [{ format: 'jpeg', width: 800, height: 600, pages: 1 }, { width: 800, height: 600 }, '内容不是有效 WebP'], + [{ format: 'webp', width: 800, height: 600, pages: 2 }, { width: 800, height: 600 }, '不允许使用动画 WebP'], + [{ format: 'webp', width: 801, height: 600, pages: 1 }, { width: 800, height: 600 }, '真实尺寸超出限制'], + [{ format: 'webp', width: 799, height: 600, pages: 1 }, { width: 800, height: 600 }, '申报尺寸与真实尺寸不一致'], + ])('拒绝伪造或超限客户端预览 %#', async (metadataValue, declared, message) => { + const sharpModule = { + default: jest.fn(() => ({ + metadata: jest.fn().mockResolvedValue(metadataValue), + })), + } as any + + await expect(assertClientPreviewWebpMetadata({ + uploadId: 'preview-malicious', + fileName: 'malicious.webp', + fileSize: 10, + width: declared.width, + height: declared.height, + kind: 'preview', + }, 'D:\\uploads\\malicious.webp', sharpModule)).rejects.toThrow(message) + }) + test('全套任务详情完成后应触发自动上架通知排队', async () => { const result = await completeProductPackageUpload({ materialId: 'material-1', @@ -165,6 +320,41 @@ describe('product-package-completion', () => { expect(createMaterialLog).toHaveBeenCalled() }) + test('任务详情直接完成时缺少出图方式应拒绝处理', async () => { + ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({ + id: 'material-1', + materialType: '任务-详情', + taskDetailSourceType: null, + productModel: 'SPU-001', + categoryName: '旗舰店', + previewUrl: '', + user: { + realName: '张三', + username: 'zhangsan', + }, + }) + + await expect( + completeProductPackageUpload({ + materialId: 'material-1', + taskNo: null, + operatorUserId: 'user-1', + assets: { + main: [ + { + fileUrl: 'https://img.example/main.png', + fileName: 'main.png', + fileSize: 10, + sortOrder: 0, + }, + ], + }, + }), + ).rejects.toThrow('任务-详情必须选择出图方式') + + expect(createZipFromFiles).not.toHaveBeenCalled() + }) + test('SKU 图缺少 skuCode 时应直接报错', async () => { await expect( completeProductPackageUpload({ @@ -347,4 +537,123 @@ describe('product-package-completion', () => { }, })).rejects.toThrow('不属于当前用户') }) + + test('同批同名 PSD 不会覆盖,成功后会清理全部上传会话', async () => { + const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-psd-success-')) + const previousUploadDir = process.env.UPLOAD_DIR + process.env.UPLOAD_DIR = uploadRoot + try { + const firstSource = path.join(uploadRoot, 'first-upload.tmp') + const secondSource = path.join(uploadRoot, 'second-upload.tmp') + await writeFile(firstSource, 'first') + await writeFile(secondSource, 'second') + ;(getOwnedUploadSession as jest.Mock).mockImplementation(async (uploadId: string) => ({ + userId: 'user-1', + fileSize: uploadId === 'psd-1' ? 5 : 6, + })) + ;(getUploadedFile as jest.Mock).mockImplementation(async (uploadId: string) => ( + uploadId === 'psd-1' ? firstSource : secondSource + )) + ;(createZipFromFiles as jest.Mock).mockResolvedValue({ + filePath: 'zip-main', + fileName: 'main.zip', + fileSize: 11, + }) + + await expect(completeProductPackageUpload({ + materialId: 'material-1', + taskNo: null, + operatorUserId: 'user-1', + assets: { + main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }], + psd: [ + { uploadId: 'psd-1', fileName: 'same.psd', fileSize: 5 }, + { uploadId: 'psd-2', fileName: 'same.psd', fileSize: 6 }, + ], + }, + })).resolves.toEqual(expect.objectContaining({ materialId: 'material-1' })) + + const psdInfos = (saveProductPackageAssets as jest.Mock).mock.calls.at(-1)?.[3] + expect(psdInfos).toHaveLength(2) + expect(psdInfos[0].filePath).not.toBe(psdInfos[1].filePath) + await expect(readFile(path.join(uploadRoot, 'materials', path.basename(psdInfos[0].filePath)), 'utf8')) + .resolves.toBe('first') + await expect(readFile(path.join(uploadRoot, 'materials', path.basename(psdInfos[1].filePath)), 'utf8')) + .resolves.toBe('second') + expect((cleanupTempFiles as jest.Mock).mock.calls.at(-1)?.[1]).toEqual( + expect.arrayContaining(['psd-1', 'psd-2']), + ) + } finally { + if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR + else process.env.UPLOAD_DIR = previousUploadDir + await rm(uploadRoot, { recursive: true, force: true }) + } + }) + + test('PSD 持久化失败时也会清理已验证的上传会话', async () => { + const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-psd-failure-')) + const previousUploadDir = process.env.UPLOAD_DIR + process.env.UPLOAD_DIR = uploadRoot + try { + ;(getOwnedUploadSession as jest.Mock).mockResolvedValue({ + userId: 'user-1', + fileSize: 5, + }) + ;(getUploadedFile as jest.Mock).mockResolvedValue(null) + ;(createZipFromFiles as jest.Mock).mockResolvedValue({ + filePath: 'zip-main', + fileName: 'main.zip', + fileSize: 11, + }) + + await expect(completeProductPackageUpload({ + materialId: 'material-1', + taskNo: null, + operatorUserId: 'user-1', + assets: { + main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }], + psd: [{ uploadId: 'psd-failed', fileName: 'failed.psd', fileSize: 5 }], + }, + })).rejects.toThrow('PSD 文件缺失或未上传完成') + + expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toContain('psd-failed') + } finally { + if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR + else process.env.UPLOAD_DIR = previousUploadDir + await rm(uploadRoot, { recursive: true, force: true }) + } + }) + + test('数据库资产已保存但完成标记失败时会补偿并清理文件', async () => { + ;(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' }, + }) + ;(assignTaskNoOnComplete as jest.Mock).mockRejectedValueOnce(new Error('任务号已存在')) + + await expect(completeProductPackageUpload({ + materialId: 'material-1', + taskNo: 'TASK202601030184', + dingtalkApprovalNo: null, + operatorUserId: 'user-1', + assets: { + main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }], + }, + })).rejects.toThrow('任务号已存在') + + expect(prisma.materialAsset.deleteMany).toHaveBeenCalledWith({ where: { materialId: 'material-1' } }) + expect(prisma.$transaction).toHaveBeenCalled() + }) }) diff --git a/lib/__tests__/product-package-init-route.test.ts b/lib/__tests__/product-package-init-route.test.ts index b7f98bf..7d4135b 100644 --- a/lib/__tests__/product-package-init-route.test.ts +++ b/lib/__tests__/product-package-init-route.test.ts @@ -53,7 +53,7 @@ describe('product-package init route', () => { }) }) - test('未填写出图方式时也应允许初始化任务详情上传', async () => { + test('未填写出图方式时应拒绝初始化任务详情上传', async () => { const request = new NextRequest('http://localhost/api/materials/upload/product-package/init', { method: 'POST', body: JSON.stringify({ @@ -70,14 +70,10 @@ describe('product-package init route', () => { const response = await POST(request) const body = await response.json() - expect(response.status).toBe(200) - expect(body.success).toBe(true) - expect(prisma.material.create).toHaveBeenCalledWith({ - data: expect.objectContaining({ - materialType: '任务-详情', - taskDetailSourceType: null, - }), - }) + expect(response.status).toBe(400) + expect(body.success).toBe(false) + expect(body.error).toContain('出图方式') + expect(prisma.material.create).not.toHaveBeenCalled() }) test('非法出图方式应返回 400', async () => { diff --git a/lib/__tests__/product-package-status-route.test.ts b/lib/__tests__/product-package-status-route.test.ts new file mode 100644 index 0000000..c21a588 --- /dev/null +++ b/lib/__tests__/product-package-status-route.test.ts @@ -0,0 +1,57 @@ +/** @jest-environment node */ + +import { getServerSession } from 'next-auth' +import { GET } from '@/app/api/materials/upload/product-package/status/route' +import { + getLatestTaskDetailUploadJobStatus, + getTaskDetailUploadJobStatus, +} from '@/lib/task-detail-upload-jobs' + +jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) +jest.mock('@/lib/auth', () => ({ authOptions: {} })) +jest.mock('@/lib/task-detail-upload-jobs', () => ({ + getLatestTaskDetailUploadJobStatus: jest.fn(), + getTaskDetailUploadJobStatus: jest.fn(), +})) + +function requestWithQuery(query: string) { + const url = new URL(`http://localhost/api/materials/upload/product-package/status${query}`) + return { nextUrl: { searchParams: url.searchParams } } as any +} + +describe('product-package status route', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } }) + }) + + test('未登录拒绝查询', async () => { + ;(getServerSession as jest.Mock).mockResolvedValue(null) + const response = await GET(requestWithQuery('?jobId=job-1')) + expect(response.status).toBe(401) + }) + + test('按 jobId 查询时保持用户隔离', async () => { + ;(getTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue(null) + const response = await GET(requestWithQuery('?jobId=job-1')) + expect(response.status).toBe(404) + expect(getTaskDetailUploadJobStatus).toHaveBeenCalledWith('job-1', 'user-1') + }) + + test('关闭上传页后可按 materialId 找到最新任务', async () => { + ;(getLatestTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue({ + jobId: 'job-1', + materialId: 'material-1', + status: 'processing', + stage: 'zips', + progress: 80, + }) + const response = await GET(requestWithQuery('?materialId=material-1')) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body.data.jobId).toBe('job-1') + expect(getLatestTaskDetailUploadJobStatus).toHaveBeenCalledWith('material-1', 'user-1') + expect(getTaskDetailUploadJobStatus).not.toHaveBeenCalled() + }) +}) diff --git a/lib/__tests__/product-package-task-no-lock.test.ts b/lib/__tests__/product-package-task-no-lock.test.ts new file mode 100644 index 0000000..63b2e99 --- /dev/null +++ b/lib/__tests__/product-package-task-no-lock.test.ts @@ -0,0 +1,82 @@ +/** @jest-environment node */ + +import { + acquireTaskNoAssignmentLock, + assignTaskNoOnComplete, +} from '@/lib/product-package' +import { prisma } from '@/lib/prisma' + +jest.mock('@/lib/prisma', () => ({ + prisma: { + $transaction: jest.fn(), + material: { + findFirst: jest.fn(), + findMany: jest.fn(), + }, + }, +})) + +describe('任务号完成写入并发锁', () => { + beforeEach(() => { + jest.clearAllMocks() + ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(null) + ;(prisma.material.findMany as jest.Mock).mockResolvedValue([]) + }) + + test('advisory lock 只返回 Prisma 可反序列化的整数列', async () => { + const tx = { $queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]) } as any + + await acquireTaskNoAssignmentLock(tx, 'TASK202604230001') + + const [template, lockName] = tx.$queryRaw.mock.calls[0] + const sql = Array.from(template as TemplateStringsArray).join('?') + expect(sql).toContain('SELECT 1 AS locked') + expect(sql).toContain('pg_advisory_xact_lock(hashtext(?)') + expect(lockName).toBe('task-no:TASK202604230001') + }) + + test('两个素材并发完成同一任务号时只能一个写入成功', async () => { + let assignedMaterialId: string | null = null + let transactionTail = Promise.resolve() + const tx = { + $queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]), + material: { + findFirst: jest.fn(async () => assignedMaterialId + ? { + id: assignedMaterialId, + productModel: 'SPU-EXISTING', + materialType: '任务-详情', + } + : null), + update: jest.fn(async ({ where }: { where: { id: string } }) => { + assignedMaterialId = where.id + return { id: where.id } + }), + }, + } + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => { + const previous = transactionTail + let release!: () => void + transactionTail = new Promise((resolve) => { release = resolve }) + await previous + try { + return await callback(tx) + } finally { + release() + } + }) + + const results = await Promise.allSettled([ + assignTaskNoOnComplete('material-a', 'TASK202604230001'), + assignTaskNoOnComplete('material-b', 'TASK202604230001'), + ]) + + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + const rejected = results.find((result): result is PromiseRejectedResult => result.status === 'rejected') + expect(rejected?.reason).toEqual(new Error( + '任务号 TASK202604230001 已存在,对应素材:SPU-EXISTING(任务-详情)', + )) + expect(tx.material.update).toHaveBeenCalledTimes(1) + expect(tx.$queryRaw).toHaveBeenCalledTimes(2) + }) +}) diff --git a/lib/__tests__/task-detail-upload-jobs.test.ts b/lib/__tests__/task-detail-upload-jobs.test.ts new file mode 100644 index 0000000..b84c0cb --- /dev/null +++ b/lib/__tests__/task-detail-upload-jobs.test.ts @@ -0,0 +1,307 @@ +/** @jest-environment node */ + +import type { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { + __taskDetailUploadJobTesting, + acquireTaskDetailUploadLock, + createTaskDetailUploadJob, + recoverTaskDetailUploadJobs, + TaskDetailUploadQueueCapacityError, + triggerTaskDetailUploadJob, +} from '@/lib/task-detail-upload-jobs' +import { completeProductPackageUpload } from '@/lib/product-package-completion' + +jest.mock('@/lib/prisma', () => ({ + prisma: { + $transaction: jest.fn(), + productPackageUploadJob: { + findFirst: jest.fn(), + findUnique: jest.fn(), + findMany: jest.fn(), + count: jest.fn(), + create: jest.fn(), + updateMany: jest.fn(), + }, + materialAsset: { + count: jest.fn(), + }, + material: { + update: jest.fn(), + updateMany: jest.fn(), + }, + }, +})) + +jest.mock('@/lib/product-package-completion', () => ({ + assertTaskDetailUploadAssetLimits: jest.fn(), + completeProductPackageUpload: jest.fn(), +})) + +const now = new Date('2026-08-01T00:00:00.000Z') + +function job(overrides: Record = {}) { + return { + id: 'job-1', + materialId: 'material-1', + operatorUserId: 'user-1', + taskNo: null, + dingtalkApprovalNo: null, + assets: { main: [] }, + options: null, + status: 'queued', + stage: 'queued', + progress: 0, + errorMessage: null, + createdAt: now, + startedAt: null, + completedAt: null, + updatedAt: now, + ...overrides, + } +} + +function transactionClient(options: { activeCount?: number; claimedCount?: number } = {}) { + return { + $queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]), + productPackageUploadJob: { + count: jest.fn().mockResolvedValue(options.activeCount ?? 0), + updateMany: jest.fn().mockResolvedValue({ count: options.claimedCount ?? 1 }), + findFirst: jest.fn(), + create: jest.fn(), + }, + material: { + update: jest.fn(), + }, + } as any +} + +async function flushMicrotasks(rounds = 8) { + for (let index = 0; index < rounds; index += 1) await Promise.resolve() +} + +describe('task-detail upload queue', () => { + beforeEach(() => { + jest.clearAllMocks() + __taskDetailUploadJobTesting.resetRuntimeState() + ;(prisma.productPackageUploadJob.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.productPackageUploadJob.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) + ;(prisma.material.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) + ;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0) + ;(completeProductPackageUpload as jest.Mock).mockResolvedValue(undefined) + }) + + afterEach(() => { + __taskDetailUploadJobTesting.resetRuntimeState() + jest.useRealTimers() + }) + + test('advisory lock 只返回 Prisma 可反序列化的整数列', async () => { + const tx = { + $queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]), + } as unknown as Prisma.TransactionClient + + await acquireTaskDetailUploadLock(tx, 'product-package-upload-create') + + const [template, lockName] = (tx.$queryRaw as jest.Mock).mock.calls[0] + const sql = Array.from(template as TemplateStringsArray).join('?') + expect(sql).toContain('SELECT 1 AS locked') + expect(sql).toContain('pg_advisory_xact_lock(hashtext(?)') + expect(sql.trim()).not.toMatch(/^SELECT\s+pg_advisory_xact_lock/i) + expect(lockName).toBe('product-package-upload-create') + }) + + test('重复 create 复用同一任务且不新增队列记录', async () => { + const existing = job({ status: 'completed', stage: 'completed', progress: 100, completedAt: now }) + const tx = transactionClient() + tx.productPackageUploadJob.findFirst.mockResolvedValue(existing) + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx)) + ;(prisma.productPackageUploadJob.findUnique as jest.Mock).mockResolvedValue(existing) + + const input = { + materialId: 'material-1', + operatorUserId: 'user-1', + assets: { main: [] }, + } + const first = await createTaskDetailUploadJob(input) + const second = await createTaskDetailUploadJob(input) + await flushMicrotasks() + + expect(first.jobId).toBe('job-1') + expect(second.jobId).toBe('job-1') + expect(tx.productPackageUploadJob.create).not.toHaveBeenCalled() + expect(tx.productPackageUploadJob.count).not.toHaveBeenCalled() + }) + + test.each([ + ['user', [3], TaskDetailUploadQueueCapacityError, 429], + ['global', [2, 20], TaskDetailUploadQueueCapacityError, 503], + ])('等待队列 %s 容量满时拒绝创建', async (scope, counts, errorType, statusCode) => { + const tx = transactionClient() + tx.productPackageUploadJob.findFirst.mockResolvedValue(null) + tx.productPackageUploadJob.count.mockImplementation(async () => counts.shift() ?? 0) + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx)) + + await expect(createTaskDetailUploadJob({ + materialId: `material-${scope}`, + operatorUserId: 'user-1', + assets: { main: [] }, + })).rejects.toMatchObject({ + constructor: errorType, + scope, + statusCode, + }) + expect(tx.productPackageUploadJob.create).not.toHaveBeenCalled() + }) + + test('跨容器领取在 advisory lock 内检查全局 processing 并使用 queued 条件更新', async () => { + const firstTx = transactionClient({ activeCount: 0, claimedCount: 1 }) + const secondTx = transactionClient({ activeCount: 1, claimedCount: 1 }) + ;(prisma.$transaction as jest.Mock) + .mockImplementationOnce(async (callback) => callback(firstTx)) + .mockImplementationOnce(async (callback) => callback(secondTx)) + + await expect(__taskDetailUploadJobTesting.claimTaskDetailUploadJob('job-1')).resolves.toBe(true) + await expect(__taskDetailUploadJobTesting.claimTaskDetailUploadJob('job-2')).resolves.toBe(false) + + expect(firstTx.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'job-1', status: 'queued' }, + data: expect.objectContaining({ status: 'processing' }), + })) + expect(secondTx.productPackageUploadJob.updateMany).not.toHaveBeenCalled() + }) + + test('已提交素材会直接补记 completed,不会重跑已清理的上传会话', async () => { + ;(prisma.productPackageUploadJob.findUnique as jest.Mock).mockResolvedValue({ + id: 'job-1', + status: 'processing', + materialId: 'material-1', + material: { taskStatus: 'completed', zipFilePath: 'all.zip' }, + }) + ;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(3) + + await expect( + __taskDetailUploadJobTesting.reconcileCommittedTaskDetailUploadJob('job-1'), + ).resolves.toBe(true) + + expect(prisma.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ status: 'completed', progress: 100 }), + })) + expect(completeProductPackageUpload).not.toHaveBeenCalled() + }) + + test('worker 处理期间每 30 秒写一次 heartbeat,完成后清除并写 completed', async () => { + jest.useFakeTimers() + const tx = transactionClient({ activeCount: 0, claimedCount: 1 }) + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx)) + ;(prisma.productPackageUploadJob.findUnique as jest.Mock) + .mockResolvedValueOnce({ + id: 'job-1', + status: 'queued', + materialId: 'material-1', + material: { taskStatus: 'uploading', zipFilePath: '' }, + }) + .mockResolvedValueOnce(job()) + let finish!: () => void + ;(completeProductPackageUpload as jest.Mock).mockImplementation(() => new Promise((resolve) => { + finish = resolve + })) + + const processing = __taskDetailUploadJobTesting.processOneTaskDetailUploadJob('job-1') + await flushMicrotasks() + await jest.advanceTimersByTimeAsync(30_000) + + expect(prisma.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'job-1', status: 'processing' }, + data: { updatedAt: expect.any(Date) }, + })) + + finish() + await expect(processing).resolves.toBe('processed') + expect(prisma.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ status: 'completed', progress: 100 }), + })) + }) + + test('处理失败会写 failed 并同步素材状态', async () => { + const tx = transactionClient({ activeCount: 0, claimedCount: 1 }) + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx)) + ;(prisma.productPackageUploadJob.findUnique as jest.Mock) + .mockResolvedValueOnce({ + id: 'job-1', + status: 'queued', + materialId: 'material-1', + material: { taskStatus: 'uploading', zipFilePath: '' }, + }) + .mockResolvedValueOnce(job()) + ;(completeProductPackageUpload as jest.Mock).mockRejectedValue(new Error('恶意预览被拒绝')) + + await expect( + __taskDetailUploadJobTesting.processOneTaskDetailUploadJob('job-1'), + ).resolves.toBe('processed') + + expect(prisma.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + status: 'failed', + stage: 'failed', + errorMessage: '恶意预览被拒绝', + }), + })) + expect(prisma.material.updateMany).toHaveBeenCalledWith({ + where: { id: 'material-1', taskStatus: { not: 'completed' } }, + data: { taskStatus: 'failed' }, + }) + }) + + test('stale processing 标记失败,queued 任务恢复入内存队列', async () => { + ;(prisma.productPackageUploadJob.findMany as jest.Mock) + .mockResolvedValueOnce([{ id: 'stale-1', materialId: 'material-stale' }]) + .mockResolvedValueOnce([]) + ;(prisma.productPackageUploadJob.findUnique as jest.Mock).mockResolvedValue({ + id: 'stale-1', + status: 'processing', + materialId: 'material-stale', + material: { taskStatus: 'uploading', zipFilePath: '' }, + }) + + await expect(recoverTaskDetailUploadJobs()).resolves.toBe(0) + + expect(prisma.productPackageUploadJob.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'stale-1', status: 'processing' }, + data: expect.objectContaining({ status: 'failed', stage: 'failed' }), + })) + expect(prisma.material.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { id: 'material-stale', taskStatus: { not: 'completed' } }, + data: { taskStatus: 'failed' }, + })) + }) + + test('全局已有 processing 时 20 个 queued 只保留一个 retry timer,不形成 per-job 自旋', async () => { + jest.useFakeTimers() + const tx = transactionClient({ activeCount: 1, claimedCount: 0 }) + ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx)) + ;(prisma.productPackageUploadJob.findUnique as jest.Mock) + .mockResolvedValueOnce({ + id: 'job-0', + status: 'queued', + materialId: 'material-0', + material: { taskStatus: 'uploading', zipFilePath: '' }, + }) + .mockResolvedValueOnce({ status: 'queued' }) + + for (let index = 0; index < 20; index += 1) triggerTaskDetailUploadJob(`job-${index}`) + await flushMicrotasks(20) + + const state = __taskDetailUploadJobTesting.getRuntimeState() + expect(state.pendingJobIds).toHaveLength(20) + expect(state.retryScheduled).toBe(true) + expect(state.globallyBackpressured).toBe(true) + expect(jest.getTimerCount()).toBe(1) + + // recovery/新请求在背压期间只能补 pending,不能再创建第二条重试链。 + triggerTaskDetailUploadJob('job-extra') + await flushMicrotasks() + expect(__taskDetailUploadJobTesting.getRuntimeState().pendingJobIds).toHaveLength(21) + expect(jest.getTimerCount()).toBe(1) + }) +}) diff --git a/lib/chunk-upload.ts b/lib/chunk-upload.ts index 70983b4..cbc5e7e 100644 --- a/lib/chunk-upload.ts +++ b/lib/chunk-upload.ts @@ -20,6 +20,7 @@ interface ChunkUploadOptions { isCancelled?: () => boolean // 取消检查回调,返回 true 时停止上传 onActiveXhrChange?: (xhrs: Set) => void // 暴露所有活跃的XHR请求集合(用于批量取消) requestHeaders?: Record // 供钉钉等独立登录态透传认证头 + chunkConcurrency?: number // 单次文件上传的并发块数;不传时继续按网络环境自动选择 } interface ChunkUploadState { @@ -541,13 +542,15 @@ export async function chunkUpload( } // 动态并发上传配置(根据网络环境自动调整) - let defaultConcurrency = 2 // 默认2并发 - try { - defaultConcurrency = await getOptimalConcurrency() - } catch (error) { - console.warn('[ChunkUpload] 获取最优并发数失败,使用默认值(2):', error) + let defaultConcurrency = options.chunkConcurrency ?? 2 // 默认2并发 + if (options.chunkConcurrency === undefined) { + try { + defaultConcurrency = await getOptimalConcurrency() + } catch (error) { + console.warn('[ChunkUpload] 获取最优并发数失败,使用默认值(2):', error) + } } - const CONCURRENT_CHUNKS = defaultConcurrency + const CONCURRENT_CHUNKS = Math.max(1, Math.min(8, Math.floor(Number(defaultConcurrency) || 1))) // 创建待上传chunk队列 const pendingChunks: number[] = [] diff --git a/lib/department-permission.ts b/lib/department-permission.ts index 3eb9a34..b927622 100644 --- a/lib/department-permission.ts +++ b/lib/department-permission.ts @@ -2,6 +2,9 @@ import { prisma } from './prisma'; import { filterSelectableMaterialTypes, MATERIAL_TYPE_OPTIONS } from './material-type-options'; import { getCompatibleVideoMaterialTypes, isVideoMaterialType } from './video-material-types'; +const superAdminChecksInFlight = new Map>(); +const viewableMaterialTypesInFlight = new Map>(); + /** * 判断是否是超级管理员(从数据库读取) */ @@ -10,12 +13,24 @@ export async function isSuperAdmin(userId: string | null | undefined): Promise user?.isSuperAdmin ?? false); + + superAdminChecksInFlight.set(userId, check); + try { + return await check; + } finally { + if (superAdminChecksInFlight.get(userId) === check) { + superAdminChecksInFlight.delete(userId); + } + } } /** @@ -139,6 +154,130 @@ export interface DepartmentPermission { linkedToDepartment: string | null; } +type DepartmentRecord = { + id: string; + name: string; + parentId?: string | null; +}; + +type DepartmentPermissionRecord = { + department: string; + materialType: string; + canView: boolean; + canDownload: boolean; + canShare: boolean; + canDownloadSource: boolean; + linkedToDepartment: string | null; +}; + +function getDepartmentPermissionKey(department: string, materialType: string): string { + return `${department}\u0000${materialType}`; +} + +function findDepartmentPermissionRecordFromMap( + permissionMap: Map, + department: string, + materialType: string, +): DepartmentPermissionRecord | null { + const normalizedType = String(materialType || '').trim(); + const lookupTypes = isVideoMaterialType(normalizedType) + ? getCompatibleVideoMaterialTypes(normalizedType) + : [normalizedType]; + + for (const lookupType of lookupTypes) { + const permission = permissionMap.get(getDepartmentPermissionKey(department, lookupType)); + if (permission) { + return permission; + } + } + + return null; +} + +function resolveDepartmentPermissionFromMap( + department: string, + materialType: string, + permissionMap: Map, + departmentByName: Map, + departmentById: Map, + visitedDepartments: Set = new Set(), +): DepartmentPermission | null { + if (visitedDepartments.has(department)) { + return null; + } + + const nextVisitedDepartments = new Set(visitedDepartments); + nextVisitedDepartments.add(department); + + const permission = findDepartmentPermissionRecordFromMap( + permissionMap, + department, + materialType, + ); + + if (permission?.linkedToDepartment) { + const linkedPermission = findDepartmentPermissionRecordFromMap( + permissionMap, + permission.linkedToDepartment, + materialType, + ); + + if (linkedPermission) { + return { + department, + materialType, + canView: linkedPermission.canView, + canDownload: linkedPermission.canDownload, + canShare: linkedPermission.canShare, + canDownloadSource: linkedPermission.canDownloadSource, + linkedToDepartment: permission.linkedToDepartment, + }; + } + } + + if (permission) { + return { + department: permission.department, + materialType: permission.materialType, + canView: permission.canView, + canDownload: permission.canDownload, + canShare: permission.canShare, + canDownloadSource: permission.canDownloadSource, + linkedToDepartment: permission.linkedToDepartment, + }; + } + + const currentDepartment = departmentByName.get(department); + const parentDepartment = currentDepartment?.parentId + ? departmentById.get(currentDepartment.parentId) + : undefined; + if (!parentDepartment) { + return null; + } + + const parentPermission = resolveDepartmentPermissionFromMap( + parentDepartment.name, + materialType, + permissionMap, + departmentByName, + departmentById, + nextVisitedDepartments, + ); + if (!parentPermission) { + return null; + } + + return { + department, + materialType, + canView: parentPermission.canView, + canDownload: parentPermission.canDownload, + canShare: parentPermission.canShare, + canDownloadSource: parentPermission.canDownloadSource, + linkedToDepartment: parentPermission.linkedToDepartment || parentDepartment.name, + }; +} + async function findDepartmentPermissionRecord( department: string, materialType: string @@ -219,8 +358,6 @@ export async function getDepartmentPermission( // 权限继承:如果没有找到当前部门的权限配置,尝试从父部门继承 if (departments && departments.length > 0) { - const { buildDepartmentDescendants, isDepartmentWithinScope } = await import('@/lib/departments'); - // 构建部门映射 const deptMap = new Map(); departments.forEach(dept => { @@ -233,8 +370,6 @@ export async function getDepartmentPermission( // 查找父部门 const parentDept = departments.find(d => d.id === currentDept.parentId); if (parentDept) { - console.log(`[Permission Inheritance] 部门 ${department} 无权限配置,尝试从父部门 ${parentDept.name} 继承 ${materialType} 权限`); - // 递归获取父部门的权限 const parentPermission = await getDepartmentPermission( parentDept.name, @@ -245,7 +380,6 @@ export async function getDepartmentPermission( // 如果父部门有权限配置,继承父部门的权限 if (parentPermission) { - console.log(`[Permission Inheritance] 成功从父部门 ${parentDept.name} 继承权限: canDownload=${parentPermission.canDownload}`); return { department, materialType, @@ -255,19 +389,12 @@ export async function getDepartmentPermission( canDownloadSource: parentPermission.canDownloadSource, linkedToDepartment: parentPermission.linkedToDepartment || parentDept.name, }; - } else { - console.log(`[Permission Inheritance] 父部门 ${parentDept.name} 也没有 ${materialType} 权限配置`); } - } else { - console.log(`[Permission Inheritance] 找不到父部门 (parentId=${currentDept.parentId})`); } - } else { - console.log(`[Permission Inheritance] 部门 ${department} 没有父部门`); } } // 如果都没有找到,返回 null(表示没有权限配置) - console.log(`[Permission Inheritance] 部门 ${department} 对于 ${materialType} 没有找到任何权限配置`); return null; } @@ -287,21 +414,40 @@ export async function getDepartmentPermissions( // 如果没有提供素材类型列表,从数据库获取所有素材类型 const allMaterialTypes = materialTypes || await getAllMaterialTypes(); - // 获取部门后裔映射(用于加速权限继承检查) - const { buildDepartmentDescendants } = await import('@/lib/departments'); - const descendantMap = departments && departments.length > 0 - ? buildDepartmentDescendants(departments) - : undefined; + const permissionRecords = await prisma.departmentMaterialPermission.findMany({ + select: { + department: true, + materialType: true, + canView: true, + canDownload: true, + canShare: true, + canDownloadSource: true, + linkedToDepartment: true, + }, + }); + const permissionMap = new Map( + permissionRecords.map((permission) => [ + getDepartmentPermissionKey(permission.department, permission.materialType), + permission, + ]), + ); + const departmentByName = new Map(); + const departmentById = new Map(); + for (const departmentRecord of departments || []) { + departmentByName.set(departmentRecord.name, departmentRecord); + departmentById.set(departmentRecord.id, departmentRecord); + } const result: Record = {}; - // 为每种素材类型获取权限(包括继承的权限) + // 权限记录一次性批量读取后,在内存中解析直接配置、关联部门和父部门继承。 for (const materialType of allMaterialTypes) { - const permission = await getDepartmentPermission( + const permission = resolveDepartmentPermissionFromMap( department, materialType, - departments, - descendantMap + permissionMap, + departmentByName, + departmentById, ); if (permission) { @@ -340,6 +486,37 @@ export async function getUserViewableMaterialTypes( userRole: string, managedDepartment: string | null | undefined, userId?: string | null | undefined +): Promise { + const requestKey = JSON.stringify([ + userDepartment || '', + userRole || '', + managedDepartment || '', + userId || '', + ]); + const existingRequest = viewableMaterialTypesInFlight.get(requestKey); + if (existingRequest) { + return [...(await existingRequest)]; + } + + const request = loadUserViewableMaterialTypes( + userDepartment, + managedDepartment, + userId, + ); + viewableMaterialTypesInFlight.set(requestKey, request); + try { + return [...(await request)]; + } finally { + if (viewableMaterialTypesInFlight.get(requestKey) === request) { + viewableMaterialTypesInFlight.delete(requestKey); + } + } +} + +async function loadUserViewableMaterialTypes( + userDepartment: string | null | undefined, + managedDepartment: string | null | undefined, + userId?: string | null | undefined, ): Promise { // 超级管理员可以看到所有素材类型 if (userId && await isSuperAdmin(userId)) { diff --git a/lib/image-processor.ts b/lib/image-processor.ts index 68c0de8..61949f9 100644 --- a/lib/image-processor.ts +++ b/lib/image-processor.ts @@ -16,14 +16,28 @@ function createSharpInput(buffer: Buffer) { }) } -function assertProcessableMetadata(metadata: sharp.Metadata) { +export function assertProcessableMetadata( + metadata: sharp.Metadata, + options: { + allowAnimatedFirstFrame?: boolean + maxInputWidth?: number + maxInputHeight?: number + maxInputPixels?: number + } = {}, +) { if (!metadata.width || !metadata.height) { throw new Error('无法获取图片尺寸') } - if (metadata.width > IMAGE_PROCESSING_MAX_DIMENSION || metadata.height > IMAGE_PROCESSING_MAX_DIMENSION) { + const maxInputWidth = options.maxInputWidth || IMAGE_PROCESSING_MAX_DIMENSION + const maxInputHeight = options.maxInputHeight || IMAGE_PROCESSING_MAX_DIMENSION + const maxInputPixels = options.maxInputPixels || IMAGE_PROCESSING_MAX_PIXELS + if (metadata.width > maxInputWidth || metadata.height > maxInputHeight) { throw new Error('图片尺寸过大') } - if (Number(metadata.pages || 1) > 1) { + if (metadata.width * metadata.height > maxInputPixels) { + throw new Error('图片像素总量过大') + } + if (!options.allowAnimatedFirstFrame && Number(metadata.pages || 1) > 1) { throw new Error('不支持动图或多页图片') } } @@ -33,6 +47,11 @@ interface ProcessImageOptions { maxWidth?: number // 最大宽度(像素) maxHeight?: number // 最大高度(像素) format?: 'webp' | 'jpeg' | 'png' + // 仅用于已有独立安全门禁的调用链路,默认仍拒绝动图和超长维度。 + allowAnimatedFirstFrame?: boolean + maxInputWidth?: number + maxInputHeight?: number + maxInputPixels?: number } /** @@ -50,6 +69,10 @@ export async function processImage( maxWidth = 600, // 默认600像素 maxHeight = 800, // 默认800像素(适配300x400显示) format = 'webp', // 默认webP格式 + allowAnimatedFirstFrame = false, + maxInputWidth = IMAGE_PROCESSING_MAX_DIMENSION, + maxInputHeight = IMAGE_PROCESSING_MAX_DIMENSION, + maxInputPixels = IMAGE_PROCESSING_MAX_PIXELS, } = options return withImageProcessingSlot(async () => { @@ -57,7 +80,12 @@ export async function processImage( // 获取原始图片信息 const image = createSharpInput(buffer) const metadata = await image.metadata() - assertProcessableMetadata(metadata) + assertProcessableMetadata(metadata, { + allowAnimatedFirstFrame, + maxInputWidth, + maxInputHeight, + maxInputPixels, + }) // 计算新尺寸(保持比例,同时限制宽度和高度) let width = metadata.width || maxWidth @@ -127,61 +155,79 @@ export function getProcessedFileName(originalName: string): string { * @param buffer 原始图片buffer * @param maxHeight 每个片段的最大高度(默认1500px) * @param maxSizeKB 每个切片的最大文件大小(默认500KB) + * @param maxWidth 可选的切片最大宽度;会先等比缩放再按输出高度切片 * @returns 切割后的图片buffer数组 */ export async function sliceImage( buffer: Buffer, maxHeight: number = 1500, - maxSizeKB: number = 500 + maxSizeKB: number = 500, + maxWidth?: number, + options: { + allowAnimatedFirstFrame?: boolean + maxInputWidth?: number + maxInputHeight?: number + maxInputPixels?: number + } = {}, ): Promise { return withImageProcessingSlot(async () => { try { const image = createSharpInput(buffer) const metadata = await image.metadata() - assertProcessableMetadata(metadata) + assertProcessableMetadata(metadata, options) const { width, height } = metadata + const outputScale = maxWidth && width > maxWidth ? maxWidth / width : 1 + const outputWidth = Math.max(1, Math.round(width * outputScale)) + const outputHeight = Math.max(1, Math.round(height * outputScale)) const slices: Buffer[] = [] const maxBytes = maxSizeKB * 1024 + const createOutputPipeline = () => { + const pipeline = createSharpInput(buffer) + return outputScale < 1 + ? pipeline.resize({ width: outputWidth, withoutEnlargement: true }) + : pipeline + } + // 如果图片高度不超过maxHeight,直接返回原图(压缩处理) - if (height <= maxHeight) { + if (outputHeight <= maxHeight) { let quality = 90 - let processedBuffer = await createSharpInput(buffer) + let processedBuffer = await createOutputPipeline() .webp({ quality }) .toBuffer() // 如果文件还是太大,逐步降低质量 while (processedBuffer.length > maxBytes && quality > 20) { quality -= 10 - processedBuffer = await createSharpInput(buffer) + processedBuffer = await createOutputPipeline() .webp({ quality }) .toBuffer() } - console.log(`图片无需切割: ${width}x${height}, 压缩到 ${(processedBuffer.length / 1024).toFixed(2)}KB, 质量: ${quality}`) + console.log(`图片无需切割: ${width}x${height} -> ${outputWidth}x${outputHeight}, 压缩到 ${(processedBuffer.length / 1024).toFixed(2)}KB, 质量: ${quality}`) return [processedBuffer] } // 计算需要切割的片数 - const sliceCount = Math.ceil(height / maxHeight) + const sliceCount = Math.ceil(outputHeight / maxHeight) if (sliceCount > IMAGE_PROCESSING_MAX_SLICES) { throw new Error(`图片过长,最多支持 ${IMAGE_PROCESSING_MAX_SLICES} 个切片`) } - console.log(`开始切割图片: ${width}x${height} -> ${sliceCount}个片段,每片限制${maxSizeKB}KB`) + console.log(`开始切割图片: ${width}x${height} -> ${outputWidth}x${outputHeight}, ${sliceCount}个片段,每片限制${maxSizeKB}KB`) // 切割图片 for (let i = 0; i < sliceCount; i++) { const top = i * maxHeight - const extractHeight = Math.min(maxHeight, height - top) + const extractHeight = Math.min(maxHeight, outputHeight - top) // 初始质量 let quality = 90 - let sliceBuffer = await createSharpInput(buffer) + let sliceBuffer = await createOutputPipeline() .extract({ left: 0, top: top, - width: width, + width: outputWidth, height: extractHeight }) .webp({ quality }) @@ -190,11 +236,11 @@ export async function sliceImage( // 如果文件还是太大,逐步降低质量 while (sliceBuffer.length > maxBytes && quality > 20) { quality -= 10 - sliceBuffer = await createSharpInput(buffer) + sliceBuffer = await createOutputPipeline() .extract({ left: 0, top: top, - width: width, + width: outputWidth, height: extractHeight }) .webp({ quality }) diff --git a/lib/material-query.ts b/lib/material-query.ts index 83dafe4..d8ac05d 100644 --- a/lib/material-query.ts +++ b/lib/material-query.ts @@ -31,6 +31,13 @@ export interface MaterialWhereResult { empty: boolean } +export interface MaterialQueryContext { + canAccessDeleted: boolean + expandedViewableTypes: string[] + isSuperAdmin: boolean + searchOrConditions?: any[] +} + export class MaterialQueryError extends Error { status: number code: string @@ -201,20 +208,69 @@ function applyMonthFilter(where: any, month: string): void { } } +export async function createMaterialQueryContext( + searchParams: URLSearchParams, + user: MaterialSessionUser | null | undefined, +): Promise { + if (!user?.id) { + throw new MaterialQueryError(401, 'UNAUTHORIZED', '未登录') + } + + const requestedDeleted = isDeletedMaterialRequest(searchParams) + const search = searchParams.get('search') || '' + const [superAdmin, searchOrConditions] = await Promise.all([ + isSessionUserSuperAdmin(user), + buildMaterialSearchOrConditions(search), + ]) + const canAccessDeleted = superAdmin || isSystemAdmin(user.role || '') + + if (requestedDeleted && !canAccessDeleted) { + throw new MaterialQueryError(403, 'FORBIDDEN_DELETED_MATERIALS', '无权限查看回收站素材') + } + + if (superAdmin) { + return { + canAccessDeleted, + expandedViewableTypes: [], + isSuperAdmin: true, + searchOrConditions, + } + } + + const viewableTypes = await getUserViewableMaterialTypes( + user.department || '', + user.role || 'USER', + user.managedDepartment || null, + // 上方已确认当前用户不是超级管理员,避免再次查询同一用户记录。 + null, + ) + + return { + canAccessDeleted, + expandedViewableTypes: expandCompatibleMaterialTypes(viewableTypes), + isSuperAdmin: false, + searchOrConditions, + } +} + export async function buildMaterialWhereFromSearchParams( searchParams: URLSearchParams, user: MaterialSessionUser | null | undefined, - options: { exclude?: MaterialFilterDimension } = {}, + options: { + context?: MaterialQueryContext + exclude?: MaterialFilterDimension + } = {}, ): Promise { if (!user?.id) { throw new MaterialQueryError(401, 'UNAUTHORIZED', '未登录') } + const context = options.context || await createMaterialQueryContext(searchParams, user) const where: any = {} const requestedDeleted = isDeletedMaterialRequest(searchParams) if (requestedDeleted) { - if (!(await canAccessDeletedMaterials(user))) { + if (!context.canAccessDeleted) { throw new MaterialQueryError(403, 'FORBIDDEN_DELETED_MATERIALS', '无权限查看回收站素材') } where.deletedAt = { not: null } @@ -222,7 +278,6 @@ export async function buildMaterialWhereFromSearchParams( where.deletedAt = null } - const search = searchParams.get('search') || '' const category = searchParams.get('category') || '' const scene = searchParams.get('scene') || '' const materialType = searchParams.get('materialType') || '' @@ -230,9 +285,8 @@ export async function buildMaterialWhereFromSearchParams( const productCategory = searchParams.get('productCategory') || '' const month = searchParams.get('month') || '' - const searchOrConditions = await buildMaterialSearchOrConditions(search) - if (searchOrConditions) { - where.OR = searchOrConditions + if (context.searchOrConditions) { + where.OR = context.searchOrConditions } if (category && options.exclude !== 'category') { @@ -260,17 +314,11 @@ export async function buildMaterialWhereFromSearchParams( where.materialType = materialTypeWhere(requestedTypes) } - if (await isSessionUserSuperAdmin(user)) { + if (context.isSuperAdmin) { return { where, empty: false } } - const viewableTypes = await getUserViewableMaterialTypes( - user.department || '', - user.role || 'USER', - user.managedDepartment || null, - user.id, - ) - const expandedViewableTypes = expandCompatibleMaterialTypes(viewableTypes) + const expandedViewableTypes = context.expandedViewableTypes if (expandedViewableTypes.length === 0) { return { where, empty: true } diff --git a/lib/performance-survey-image-browser.ts b/lib/performance-survey-image-browser.ts index 7e52f55..5adc593 100644 --- a/lib/performance-survey-image-browser.ts +++ b/lib/performance-survey-image-browser.ts @@ -1,4 +1,8 @@ import { sortPerformanceSurveyImagesByName } from '@/lib/performance-survey-image-order' +import { + PerformanceSurveyBrowserImageError, + type PerformanceSurveyBrowserImageErrorCode, +} from '@/lib/performance-survey-image-error' export type PerformanceSurveyProcessedImageKind = 'slice' | 'preview' @@ -28,6 +32,7 @@ type WorkerSuccessMessage = { type WorkerErrorMessage = { id: string ok: false + code?: PerformanceSurveyBrowserImageErrorCode error: string } @@ -40,13 +45,16 @@ function assertPerformanceSurveyBrowserImageSupport() { || typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined' ) { - throw new Error(DESKTOP_BROWSER_REQUIRED_MESSAGE) + throw new PerformanceSurveyBrowserImageError( + 'BROWSER_CAPABILITY_UNAVAILABLE', + DESKTOP_BROWSER_REQUIRED_MESSAGE, + ) } } function runImageWorker( files: File[], - profile: 'performance_survey' | 'work_review', + profile: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail', ): Promise { assertPerformanceSurveyBrowserImageSupport() @@ -55,7 +63,10 @@ function runImageWorker( try { worker = new Worker(new URL('./performance-survey-image.worker.ts', import.meta.url), { type: 'module' }) } catch { - reject(new Error('浏览器图片处理线程启动失败,请使用桌面版 Chrome 或 Edge 重试')) + reject(new PerformanceSurveyBrowserImageError( + 'WORKER_UNAVAILABLE', + '浏览器图片处理线程启动失败,请使用桌面版 Chrome 或 Edge 重试', + )) return } const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}` @@ -74,13 +85,26 @@ function runImageWorker( if (data.ok === true) { finish(() => resolve(data.images)) } else { - finish(() => reject(new Error(data.error || '浏览器图片处理失败'))) + finish(() => reject(new PerformanceSurveyBrowserImageError( + data.code || 'PROCESSING_FAILED', + data.error || '浏览器图片处理失败', + ))) } } worker.onerror = () => { - finish(() => reject(new Error('浏览器图片处理线程启动失败,请使用桌面版 Chrome 或 Edge 重试'))) + finish(() => reject(new PerformanceSurveyBrowserImageError( + 'WORKER_UNAVAILABLE', + '浏览器图片处理线程启动失败,请使用桌面版 Chrome 或 Edge 重试', + ))) + } + try { + worker.postMessage({ id: requestId, files, profile }) + } catch { + finish(() => reject(new PerformanceSurveyBrowserImageError( + 'WORKER_UNAVAILABLE', + '浏览器图片处理线程无法接收文件,请使用桌面版 Chrome 或 Edge 重试', + ))) } - worker.postMessage({ id: requestId, files, profile }) }) } @@ -88,7 +112,7 @@ export async function preparePerformanceSurveyImagesInBrowser( files: File[], onProgress?: (label: string) => void, options?: { - profile?: 'performance_survey' | 'work_review' + profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' }, ): Promise { if (files.length === 0) throw new Error('请至少选择 1 张图片') diff --git a/lib/performance-survey-image-error.ts b/lib/performance-survey-image-error.ts new file mode 100644 index 0000000..d3b2be6 --- /dev/null +++ b/lib/performance-survey-image-error.ts @@ -0,0 +1,41 @@ +export type PerformanceSurveyBrowserImageErrorCode = + | 'BROWSER_CAPABILITY_UNAVAILABLE' + | 'BROWSER_DECODE_UNAVAILABLE' + | 'WORKER_UNAVAILABLE' + | 'CANVAS_UNAVAILABLE' + | 'CANVAS_EXPORT_UNAVAILABLE' + | 'SOURCE_FORMAT_UNSUPPORTED' + | 'SOURCE_DECODE_FAILED' + | 'SOURCE_DIMENSIONS_INVALID' + | 'SOURCE_WIDTH_LIMIT' + | 'SOURCE_PIXEL_LIMIT' + | 'SOURCE_SLICE_LIMIT' + | 'SOURCE_COUNT_LIMIT' + | 'NO_SOURCE_IMAGES' + | 'PROCESSING_FAILED' + +const BROWSER_CAPABILITY_ERROR_CODES = new Set([ + 'BROWSER_CAPABILITY_UNAVAILABLE', + 'BROWSER_DECODE_UNAVAILABLE', + 'WORKER_UNAVAILABLE', + 'CANVAS_UNAVAILABLE', + 'CANVAS_EXPORT_UNAVAILABLE', +]) + +export class PerformanceSurveyBrowserImageError extends Error { + readonly code: PerformanceSurveyBrowserImageErrorCode + + constructor(code: PerformanceSurveyBrowserImageErrorCode, message: string) { + super(message) + this.name = 'PerformanceSurveyBrowserImageError' + this.code = code + Object.setPrototypeOf(this, PerformanceSurveyBrowserImageError.prototype) + } +} + +export function isPerformanceSurveyBrowserCapabilityError( + error: unknown, +): error is PerformanceSurveyBrowserImageError { + return error instanceof PerformanceSurveyBrowserImageError + && BROWSER_CAPABILITY_ERROR_CODES.has(error.code) +} diff --git a/lib/performance-survey-image-source-guard.ts b/lib/performance-survey-image-source-guard.ts index ad1de21..a8e34cd 100644 --- a/lib/performance-survey-image-source-guard.ts +++ b/lib/performance-survey-image-source-guard.ts @@ -1,3 +1,8 @@ +import { + PerformanceSurveyBrowserImageError, + type PerformanceSurveyBrowserImageErrorCode, +} from './performance-survey-image-error' + export const PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS = { maxWidth: 12_000, // 视觉评分的源图通常是 1600px 左右的长图。这里允许 1600×75,000 这类长图, @@ -7,14 +12,27 @@ export const PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS = { sliceHeight: 1_500, maxSlicesPerCase: 60, headerReadBytes: 256 * 1024, + // 少数 JPEG 的 EXIF/ICC 段会超过首段读取范围;扩大到 4MB 仍远小于解码整图的成本, + // 用于在浏览器解码前继续拦截超大像素图片。 + extendedHeaderReadBytes: 4 * 1024 * 1024, } as const +// 任务详情在 Edge 预处理失败时会交给服务器 Sharp 兜底;两端必须使用同一 +// 像素门禁,否则 60M~120M 的图片会在上传完成后才后台失败。 +export const TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS = 60_000_000 + export type PerformanceSurveySourceImageDimensions = { width: number height: number } -export class PerformanceSurveySourceImageError extends Error {} +export class PerformanceSurveySourceImageError extends PerformanceSurveyBrowserImageError { + constructor(code: PerformanceSurveyBrowserImageErrorCode, message: string) { + super(code, message) + this.name = 'PerformanceSurveySourceImageError' + Object.setPrototypeOf(this, PerformanceSurveySourceImageError.prototype) + } +} function readUInt16BE(bytes: Uint8Array, offset: number) { return (bytes[offset] << 8) | bytes[offset + 1] @@ -104,21 +122,26 @@ export function readPerformanceSurveySourceImageDimensions(bytes: Uint8Array) { export function assertPerformanceSurveyBrowserSourceImageDimensions( dimensions: PerformanceSurveySourceImageDimensions | null, sourceName: string, + options: { maxPixels?: number } = {}, ) { if (!dimensions || !Number.isInteger(dimensions.width) || !Number.isInteger(dimensions.height) || dimensions.width <= 0 || dimensions.height <= 0) { throw new PerformanceSurveySourceImageError( + 'SOURCE_DIMENSIONS_INVALID', `图片「${sourceName}」无法在解码前读取尺寸,请使用 PNG、JPG、GIF 或 WebP 格式`, ) } if (dimensions.width > PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxWidth) { throw new PerformanceSurveySourceImageError( + 'SOURCE_WIDTH_LIMIT', `图片「${sourceName}」宽度超过 ${PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxWidth}px`, ) } - if (dimensions.width * dimensions.height > PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels) { + const maxPixels = options.maxPixels || PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels + if (dimensions.width * dimensions.height > maxPixels) { throw new PerformanceSurveySourceImageError( - `图片「${sourceName}」像素总量超过 ${PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels.toLocaleString()},请先压缩或切图`, + 'SOURCE_PIXEL_LIMIT', + `图片「${sourceName}」像素总量超过 ${maxPixels.toLocaleString()},请先压缩或切图`, ) } const outputScale = Math.min(1, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxOutputWidth / dimensions.width) @@ -126,6 +149,7 @@ export function assertPerformanceSurveyBrowserSourceImageDimensions( const sliceCount = Math.ceil(outputHeight / PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.sliceHeight) if (sliceCount > PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxSlicesPerCase) { throw new PerformanceSurveySourceImageError( + 'SOURCE_SLICE_LIMIT', `图片「${sourceName}」处理后需要 ${sliceCount} 张切片,单个案例最多支持 ${PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxSlicesPerCase} 张`, ) } diff --git a/lib/performance-survey-image.worker.ts b/lib/performance-survey-image.worker.ts index ce47796..54bb109 100644 --- a/lib/performance-survey-image.worker.ts +++ b/lib/performance-survey-image.worker.ts @@ -4,14 +4,16 @@ import { assertPerformanceSurveyBrowserSourceImageDimensions, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS, readPerformanceSurveySourceImageDimensions, + TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS, } from './performance-survey-image-source-guard' +import { PerformanceSurveyBrowserImageError } from './performance-survey-image-error' export {} type WorkerRequest = { id: string files: File[] - profile?: 'performance_survey' | 'work_review' + profile?: 'performance_survey' | 'work_review' | 'task_detail' | 'task_detail_preview' | 'task_detail_detail' } type WorkerProcessedImage = { @@ -35,6 +37,11 @@ const MAX_ORDERED_SLICE_COUNT = 60 const PREVIEW_QUALITY = 0.68 let outputWidthLimit = 1_600 let sliceQuality = 0.76 +let previewWidth = PREVIEW_WIDTH +let previewHeight = PREVIEW_HEIGHT +let previewQuality = PREVIEW_QUALITY +let previewFit: 'cover' | 'inside' = 'cover' +let sourceMaxPixels: number = PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels const workerScope = self as unknown as DedicatedWorkerGlobalScope function removeFileExtension(fileName: string) { @@ -44,20 +51,68 @@ function removeFileExtension(fileName: string) { function getCanvasContext(canvas: OffscreenCanvas) { const context = canvas.getContext('2d', { alpha: false }) - if (!context) throw new Error('当前浏览器无法创建图片处理画布,请使用桌面版 Chrome 或 Edge') + if (!context) { + throw new PerformanceSurveyBrowserImageError( + 'CANVAS_UNAVAILABLE', + '当前浏览器无法创建图片处理画布,请使用桌面版 Chrome 或 Edge', + ) + } return context } +async function exportCanvasAsWebp(canvas: OffscreenCanvas, quality: number) { + try { + return await canvas.convertToBlob({ type: 'image/webp', quality }) + } catch { + throw new PerformanceSurveyBrowserImageError( + 'CANVAS_EXPORT_UNAVAILABLE', + '当前浏览器无法导出 WebP 预览,请使用桌面版 Chrome 或 Edge', + ) + } +} + async function decodeImage(file: File) { const header = new Uint8Array(await file.slice(0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.headerReadBytes).arrayBuffer()) - assertPerformanceSurveyBrowserSourceImageDimensions( - readPerformanceSurveySourceImageDimensions(header), - file.name, - ) + let headerDimensions = readPerformanceSurveySourceImageDimensions(header) + if (!headerDimensions && file.size > header.byteLength) { + // JPEG 的 EXIF/ICC 段可能超过 256KB。再读取有限的扩展头部,优先在解码前 + // 拦截超大像素图片;只有仍无法识别时才交给浏览器实际解码。 + const extendedHeader = new Uint8Array( + await file.slice(0, PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.extendedHeaderReadBytes).arrayBuffer(), + ) + headerDimensions = readPerformanceSurveySourceImageDimensions(extendedHeader) + } + // 常见图片会在文件头直接提供尺寸,先做保护性校验,避免超大图片进入浏览器解码。 + // JPEG 的 EXIF/ICC 段可能超过 256KB;头部暂时读不到 SOF 时交给浏览器实际解码, + // 再用解码后的尺寸做同一套限制,不能把合法图片误判成“不支持”。 + if (headerDimensions) { + assertPerformanceSurveyBrowserSourceImageDimensions(headerDimensions, file.name, { maxPixels: sourceMaxPixels }) + } + let bitmap: ImageBitmap try { - return await createImageBitmap(file) + bitmap = await createImageBitmap(file) } catch { - throw new Error(`图片「${file.name}」无法解码,请确认文件没有损坏并使用 PNG、JPG 或 WebP 格式`) + if (headerDimensions) { + throw new PerformanceSurveyBrowserImageError( + 'BROWSER_DECODE_UNAVAILABLE', + `当前浏览器无法解码图片「${file.name}」,将改由服务器安全处理`, + ) + } + throw new PerformanceSurveyBrowserImageError( + 'SOURCE_DECODE_FAILED', + `图片「${file.name}」无法解码,请确认文件没有损坏并使用 PNG、JPG、GIF 或 WebP 格式`, + ) + } + try { + assertPerformanceSurveyBrowserSourceImageDimensions( + { width: bitmap.width, height: bitmap.height }, + file.name, + { maxPixels: sourceMaxPixels }, + ) + return bitmap + } catch (error) { + bitmap.close() + throw error } } @@ -89,15 +144,25 @@ async function renderSlice( outputWidth, sliceHeight, ) - const blob = await canvas.convertToBlob({ type: 'image/webp', quality: sliceQuality }) + const blob = await exportCanvasAsWebp(canvas, sliceQuality) return { blob, width: outputWidth, height: sliceHeight } } async function renderPreview(firstSlice: Blob, sliceWidth: number, sliceHeight: number) { const bitmap = await createImageBitmap(firstSlice) try { - const targetWidth = Math.min(PREVIEW_WIDTH, sliceWidth) - const targetHeight = Math.min(PREVIEW_HEIGHT, sliceHeight) + const targetWidth = Math.min(previewWidth, sliceWidth) + const targetHeight = Math.min(previewHeight, sliceHeight) + if (previewFit === 'inside') { + const scale = Math.min(targetWidth / bitmap.width, targetHeight / bitmap.height, 1) + const width = Math.max(1, Math.round(bitmap.width * scale)) + const height = Math.max(1, Math.round(bitmap.height * scale)) + const canvas = new OffscreenCanvas(width, height) + const context = getCanvasContext(canvas) + context.drawImage(bitmap, 0, 0, bitmap.width, bitmap.height, 0, 0, width, height) + const blob = await exportCanvasAsWebp(canvas, previewQuality) + return { blob, width, height } + } const coverScale = Math.max(targetWidth / bitmap.width, targetHeight / bitmap.height) const sourceWidth = Math.min(bitmap.width, targetWidth / coverScale) const sourceHeight = Math.min(bitmap.height, targetHeight / coverScale) @@ -115,23 +180,28 @@ async function renderPreview(firstSlice: Blob, sliceWidth: number, sliceHeight: targetWidth, targetHeight, ) - const blob = await canvas.convertToBlob({ type: 'image/webp', quality: PREVIEW_QUALITY }) + const blob = await exportCanvasAsWebp(canvas, previewQuality) return { blob, width: targetWidth, height: targetHeight } } finally { bitmap.close() } } -async function processSingleOriginal(file: File): Promise { +async function processSingleOriginal(file: File, includePreview = true): Promise { const bitmap = await decodeImage(file) try { - if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`) + if (!bitmap.width || !bitmap.height) { + throw new PerformanceSurveyBrowserImageError('SOURCE_DIMENSIONS_INVALID', `图片「${file.name}」尺寸无效`) + } const scale = Math.min(1, outputWidthLimit / bitmap.width) const outputWidth = Math.max(1, Math.round(bitmap.width * scale)) const outputHeight = Math.max(1, Math.round(bitmap.height * scale)) const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT)) if (sliceCount > MAX_SLICES_PER_SOURCE) { - throw new Error(`图片「${file.name}」处理后需要 ${sliceCount} 个切片,单张长图最多支持 ${MAX_SLICES_PER_SOURCE} 个切片`) + throw new PerformanceSurveyBrowserImageError( + 'SOURCE_SLICE_LIMIT', + `图片「${file.name}」处理后需要 ${sliceCount} 个切片,单张长图最多支持 ${MAX_SLICES_PER_SOURCE} 个切片`, + ) } const baseName = removeFileExtension(file.name) const slices: WorkerProcessedImage[] = [] @@ -150,6 +220,7 @@ async function processSingleOriginal(file: File): Promise { + const bitmap = await decodeImage(file) + try { + if (!bitmap.width || !bitmap.height) { + throw new PerformanceSurveyBrowserImageError('SOURCE_DIMENSIONS_INVALID', `图片「${file.name}」尺寸无效`) + } + const preview = await renderPreviewFromBitmap(bitmap) + return [{ + ...preview, + fileName: `${removeFileExtension(file.name)}-preview.webp`, + mimeType: 'image/webp', + kind: 'preview', + sourceName: file.name, + sourceIndex: 0, + sliceIndex: 0, + sliceCount: 1, + }] + } finally { + bitmap.close() + } +} + async function processOrderedSlices(files: File[]): Promise { if (files.length > MAX_ORDERED_SLICE_COUNT) { - throw new Error(`单个案例最多上传 ${MAX_ORDERED_SLICE_COUNT} 张有序切片`) + throw new PerformanceSurveyBrowserImageError( + 'SOURCE_COUNT_LIMIT', + `单个案例最多上传 ${MAX_ORDERED_SLICE_COUNT} 张有序切片`, + ) } const plans: Array<{ @@ -187,13 +296,18 @@ async function processOrderedSlices(files: File[]): Promise MAX_SLICES_PER_SOURCE) { - throw new Error(`图片「${file.name}」处理后需要 ${sliceCount} 个切片,单张源图最多支持 ${MAX_SLICES_PER_SOURCE} 个切片`) + throw new PerformanceSurveyBrowserImageError( + 'SOURCE_SLICE_LIMIT', + `图片「${file.name}」处理后需要 ${sliceCount} 个切片,单张源图最多支持 ${MAX_SLICES_PER_SOURCE} 个切片`, + ) } totalSliceCount += sliceCount plans.push({ outputWidth, outputHeight, scale, sliceCount }) @@ -202,7 +316,10 @@ async function processOrderedSlices(files: File[]): Promise MAX_ORDERED_SLICE_COUNT) { - throw new Error(`这些图片处理后共需 ${totalSliceCount} 个切片,单个案例最多支持 ${MAX_ORDERED_SLICE_COUNT} 个切片`) + throw new PerformanceSurveyBrowserImageError( + 'SOURCE_SLICE_LIMIT', + `这些图片处理后共需 ${totalSliceCount} 个切片,单个案例最多支持 ${MAX_ORDERED_SLICE_COUNT} 个切片`, + ) } const processed: WorkerProcessedImage[] = [] @@ -243,20 +360,36 @@ async function processOrderedSlices(files: File[]): Promise) => { const { id, files, profile } = event.data || {} try { - if (!id || !Array.isArray(files) || files.length === 0) throw new Error('没有可处理的图片') + if (!id || !Array.isArray(files) || files.length === 0) { + throw new PerformanceSurveyBrowserImageError('NO_SOURCE_IMAGES', '没有可处理的图片') + } outputWidthLimit = profile === 'work_review' ? 3_200 : 1_600 sliceQuality = profile === 'work_review' ? 0.9 : 0.76 + const isTaskDetail = profile === 'task_detail' || profile === 'task_detail_preview' || profile === 'task_detail_detail' + sourceMaxPixels = isTaskDetail + ? TASK_DETAIL_BROWSER_SOURCE_MAX_PIXELS + : PERFORMANCE_SURVEY_BROWSER_SOURCE_LIMITS.maxPixels + previewWidth = isTaskDetail ? 800 : PREVIEW_WIDTH + previewHeight = isTaskDetail ? 1_200 : PREVIEW_HEIGHT + previewQuality = isTaskDetail ? 0.74 : PREVIEW_QUALITY + previewFit = isTaskDetail ? 'inside' : 'cover' if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') { - throw new Error('图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传') + throw new PerformanceSurveyBrowserImageError( + 'BROWSER_CAPABILITY_UNAVAILABLE', + '图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传', + ) } const images = files.length === 1 - ? await processSingleOriginal(files[0]) + ? profile === 'task_detail_preview' + ? await processSinglePreview(files[0]) + : await processSingleOriginal(files[0], profile !== 'task_detail_detail') : await processOrderedSlices(files) workerScope.postMessage({ id, ok: true, images }) } catch (error) { workerScope.postMessage({ id, ok: false, + code: error instanceof PerformanceSurveyBrowserImageError ? error.code : 'PROCESSING_FAILED', error: error instanceof Error ? error.message : '浏览器图片处理失败', }) } diff --git a/lib/performance-survey-service.ts b/lib/performance-survey-service.ts index b16b4d3..6168501 100644 --- a/lib/performance-survey-service.ts +++ b/lib/performance-survey-service.ts @@ -64,6 +64,7 @@ import { import { ensurePerformanceSurveyDailyEmployeeSyncStarted, ensurePerformanceSurveyEmployeeProfileForUser, + getActivePerformanceSurveyEmployeeProfileWhere, getPerformanceSurveyAccountProfileWhere, previewPerformanceSurveyEmployeeSync, syncPerformanceSurveyEmployees, @@ -5043,31 +5044,142 @@ async function resolveEmployeeEvaluator(employee: any) { } } +async function resolveAvailableDirectManagerProfile(targetEmployee: any, manager: any) { + const sourcePayload = manager?.sourcePayload && typeof manager.sourcePayload === 'object' + ? manager.sourcePayload as Record + : {} + const linkedUserId = clean(sourcePayload.materialUserId) || clean(sourcePayload.userId) + const managerDingtalkIdentity = clean(manager?.dingtalkUserId) + const managerEmail = clean(manager?.dingEmail) + const accountIdentityWhere = linkedUserId + ? { id: linkedUserId } + : managerDingtalkIdentity + ? { dingtalkUserId: managerDingtalkIdentity } + : managerEmail + ? { email: { equals: managerEmail, mode: 'insensitive' as const } } + : null + if (!accountIdentityWhere) { + throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管缺少可用登录身份,请先校准人员档案`, 409) + } + + const activeAccounts = await prisma.user.findMany({ + where: { + AND: [ + { + archivedAt: null, + status: 'active', + isActive: true, + }, + accountIdentityWhere, + ], + }, + select: { + id: true, + dingtalkUserId: true, + }, + take: 2, + }) + if (activeAccounts.length !== 1) { + throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管尚无可用账户,请先校准人员档案`, 409) + } + + const accountDingtalkUserId = clean(activeAccounts[0].dingtalkUserId) + if ( + managerDingtalkIdentity + && managerDingtalkIdentity !== accountDingtalkUserId + ) { + throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管钉钉身份不一致,请先校准人员档案`, 409) + } + const resolvedDingtalkUserId = managerDingtalkIdentity || accountDingtalkUserId + if (!resolvedDingtalkUserId) { + throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}的直属主管缺少钉钉唯一值,请先校准人员档案`, 409) + } + return { + ...manager, + dingtalkUserId: resolvedDingtalkUserId, + } +} + async function resolveDirectManagerForWorkflow(targetEmployee: any) { - const accountWhere = await getPerformanceSurveyAccountProfileWhere() const managerDingtalkUserId = clean(targetEmployee?.managerDingtalkUserId) const managerJobNumber = clean(targetEmployee?.managerJobNumber) - if (!managerDingtalkUserId && !managerJobNumber) { - throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}的直属主管,请先校准人员档案`, 409) + + // 上游已提供主管唯一值时只做精确匹配;值失效或冲突不能用组织推断掩盖。 + if (managerDingtalkUserId || managerJobNumber) { + const configuredManagerIdentity = { + ...(managerDingtalkUserId ? { dingtalkUserId: managerDingtalkUserId } : {}), + ...(managerJobNumber ? { jobNumber: managerJobNumber } : {}), + } + const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ + where: { + AND: [ + getActivePerformanceSurveyEmployeeProfileWhere(), + configuredManagerIdentity, + ], + }, + take: 2, + }) + if (managers.length !== 1) { + throw new PerformanceSurveyApiError(`无法唯一匹配${getEmployeeDisplayName(targetEmployee)}的直属主管`, 409) + } + return resolveAvailableDirectManagerProfile(targetEmployee, managers[0]) } + + // 只有主管唯一值完全缺失时,才按双方的主组织唯一值推断唯一在职 M 级人员。 + const targetEmployeeId = clean(targetEmployee?.id) + if (!targetEmployeeId) { + throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}所属的主组织,请先校准人员档案`, 409) + } + + const primaryMemberships = await prisma.performanceSurveyEmployeeDepartmentMembership.findMany({ + where: { + employeeProfileId: targetEmployeeId, + isPrimary: true, + }, + select: { sourceDeptId: true }, + take: 2, + }) + const primarySourceDeptIds = Array.from(new Set( + primaryMemberships + .map((membership) => clean(membership.sourceDeptId)) + .filter((sourceDeptId): sourceDeptId is string => Boolean(sourceDeptId)), + )) + if (primarySourceDeptIds.length !== 1) { + throw new PerformanceSurveyApiError(`无法确定${getEmployeeDisplayName(targetEmployee)}所属的主组织,请先校准人员档案`, 409) + } + const managers = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { AND: [ - accountWhere, + getActivePerformanceSurveyEmployeeProfileWhere(), + { id: { not: targetEmployeeId } }, { OR: [ - ...(managerDingtalkUserId ? [{ dingtalkUserId: managerDingtalkUserId }] : []), - ...(managerJobNumber ? [{ jobNumber: managerJobNumber }] : []), + { isLeadership: true }, + { levelType: { equals: 'M', mode: 'insensitive' } }, + { positionLevel: { startsWith: 'M', mode: 'insensitive' } }, ], }, + { + departmentMemberships: { + some: { + sourceDeptId: primarySourceDeptIds[0], + isPrimary: true, + }, + }, + }, ], }, take: 2, }) - if (managers.length !== 1) { - throw new PerformanceSurveyApiError(`无法唯一匹配${getEmployeeDisplayName(targetEmployee)}的直属主管`, 409) + if (managers.length === 0) { + throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内没有可用的M级主管,请先校准人员档案`, 409) } - return managers[0] + if (managers.length > 1) { + throw new PerformanceSurveyApiError(`${getEmployeeDisplayName(targetEmployee)}所属组织内存在多名M级人员,请明确配置直属主管`, 409) + } + + return resolveAvailableDirectManagerProfile(targetEmployee, managers[0]) } async function resolveConfiguredWorkflowEvaluators(input: { diff --git a/lib/product-package-completion.ts b/lib/product-package-completion.ts index 2f96db6..4bd2650 100644 --- a/lib/product-package-completion.ts +++ b/lib/product-package-completion.ts @@ -22,6 +22,9 @@ import { } from '@/lib/external-auto-listing' import path from 'path' 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 { getDingtalkApprovalNoError, normalizeDingtalkApprovalNo, @@ -36,6 +39,7 @@ export interface ProductPackageImageInput { sortOrder?: number skuCode?: string barcodes?: string[] + previewAssets?: ProductPackagePreviewInput[] } export interface ProductPackageSourceInput { @@ -46,6 +50,19 @@ export interface ProductPackageSourceInput { fileSize: number } +export interface ProductPackagePreviewInput { + uploadId: string + fileName: string + fileSize: number + width?: number + height?: number + kind?: string + sourceName?: string + sourceIndex?: number + sliceIndex?: number + sliceCount?: number +} + export interface ProductPackageAssetsInput { main?: ProductPackageImageInput[] sku?: ProductPackageImageInput[] @@ -63,11 +80,192 @@ export interface CompleteProductPackageUploadInput { allowSkuWithoutBarcodes?: boolean zipTimeoutMs?: number allZipTimeoutMs?: number + zipCompressionLevel?: number + preserveMaterialOnError?: boolean + onProgress?: (update: { stage: string; progress: number; message?: string }) => Promise | void } } +const TASK_DETAIL_SERVER_SOURCE_MAX_WIDTH = 12_000 +const TASK_DETAIL_SERVER_SOURCE_MAX_HEIGHT = + TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight * TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage +const TASK_DETAIL_SERVER_SOURCE_MAX_PIXELS = 60_000_000 + +type ProductPackageReadableAsset = ProductPackageImageInput | ProductPackageSourceInput | ProductPackagePreviewInput + type PublicProductPackageAsset = Record +/** + * 服务端与后台 worker 共用的任务详情上传容量门禁。 + * + * 客户端校验只改善交互,不能作为安全边界;这里必须在 HTTP 入队前以及后台 + * 真正处理前各执行一次,避免直接构造请求或篡改数据库任务绕过数量/大小限制。 + */ +export function assertTaskDetailUploadAssetLimits(assets: ProductPackageAssetsInput) { + const imageGroups: Array<[string, ProductPackageImageInput[]]> = [ + ['主图', assets.main || []], + ['SKU 图', assets.sku || []], + ['详情图', assets.detail || []], + ] + + for (const [label, group] of imageGroups) { + if (group.length > TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup) { + throw new Error(`${label}最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张,请分批提交`) + } + } + + const imageAssets = imageGroups.flatMap(([, group]) => group) + const psdAssets = assets.psd || [] + if (imageAssets.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount) { + throw new Error(`本次图片最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount} 张,请分批提交`) + } + if (psdAssets.length > TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount) { + throw new Error(`PSD 源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount} 个,请分批提交`) + } + + const sourceAssets = [...imageAssets, ...psdAssets] + if (sourceAssets.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount) { + throw new Error(`本次原图和源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount} 个,请分批提交`) + } + + let totalSourceBytes = 0 + for (const asset of sourceAssets) { + if (!Number.isFinite(asset.fileSize) || asset.fileSize <= 0) { + throw new Error(`文件大小无效: ${asset.fileName}`) + } + if ( + imageAssets.includes(asset as ProductPackageImageInput) + && asset.fileSize > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes + ) { + throw new Error( + `图片 ${asset.fileName} 超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制`, + ) + } + totalSourceBytes += asset.fileSize + } + if (totalSourceBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) { + throw new Error('本次上传素材总大小超过 1GB,请分批提交') + } + + let totalPreviewCount = 0 + let totalPreviewBytes = 0 + for (const asset of imageAssets) { + const previews = asset.previewAssets || [] + if (previews.length > TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage) { + throw new Error( + `图片 ${asset.fileName} 的预览切片超过 ${TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage} 张限制`, + ) + } + + const slicePreviews = previews.filter((preview) => preview.kind === 'slice') + if (slicePreviews.length > 0) { + const expectedCount = slicePreviews[0].sliceCount + if (!Number.isInteger(expectedCount) || expectedCount !== slicePreviews.length) { + throw new Error(`图片 ${asset.fileName} 的预览切片数量校验失败`) + } + const indices = slicePreviews + .map((preview) => preview.sliceIndex) + .sort((a, b) => (a ?? 0) - (b ?? 0)) + if (indices.some((value, index) => value !== index)) { + throw new Error(`图片 ${asset.fileName} 的预览切片顺序校验失败`) + } + } + + for (const preview of previews) { + totalPreviewCount += 1 + totalPreviewBytes += preview.fileSize + if (totalPreviewCount > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewCount) { + throw new Error('本次客户端预览图数量过多,请分批上传') + } + if ( + !Number.isFinite(preview.fileSize) + || preview.fileSize <= 0 + || preview.fileSize > TASK_DETAIL_UPLOAD_LIMITS.maxSinglePreviewBytes + ) { + throw new Error(`客户端预览图 ${preview.fileName} 超出大小限制`) + } + if (preview.kind !== 'preview' && preview.kind !== 'slice') { + throw new Error(`客户端预览图 ${preview.fileName} 类型无效`) + } + if (!Number.isInteger(preview.width) || !Number.isInteger(preview.height)) { + throw new Error(`客户端预览图 ${preview.fileName} 必须申报真实宽高`) + } + + const width = preview.width as number + const height = preview.height as number + const isSlice = preview.kind === 'slice' + const maxWidth = isSlice + ? TASK_DETAIL_UPLOAD_LIMITS.maxSliceWidth + : TASK_DETAIL_UPLOAD_LIMITS.maxPreviewWidth + const maxHeight = isSlice + ? TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight + : TASK_DETAIL_UPLOAD_LIMITS.maxPreviewHeight + const maxPixels = isSlice + ? TASK_DETAIL_UPLOAD_LIMITS.maxSlicePixels + : TASK_DETAIL_UPLOAD_LIMITS.maxPreviewPixels + if (width < 1 || height < 1 || width > maxWidth || height > maxHeight || width * height > maxPixels) { + throw new Error(`客户端预览图 ${preview.fileName} 尺寸超出限制`) + } + } + } + + if (totalPreviewBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewBytes) { + throw new Error('本次客户端预览图总大小超过 512MB,请分批提交') + } +} + +type SharpModule = { default: typeof import('sharp') } + +/** @internal 直接读取文件头元数据,避免为了校验客户端预览把完整文件载入内存。 */ +export async function assertClientPreviewWebpMetadata( + previewAsset: ProductPackagePreviewInput, + filePath: string, + providedSharp?: SharpModule, +) { + const sharpModule = providedSharp + || 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() + } catch { + throw new Error(`客户端预览图 ${previewAsset.fileName} 不是可安全读取的 WebP`) + } + + if (metadata.format !== 'webp') { + throw new Error(`客户端预览图 ${previewAsset.fileName} 内容不是有效 WebP`) + } + const width = Number(metadata.width) + const height = Number(metadata.height) + if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) { + throw new Error(`客户端预览图 ${previewAsset.fileName} 真实尺寸无效`) + } + const frameCount = Number(metadata.pages ?? 1) + if (!Number.isInteger(frameCount) || frameCount !== TASK_DETAIL_UPLOAD_LIMITS.maxPreviewAnimationFrames) { + throw new Error(`客户端预览图 ${previewAsset.fileName} 不允许使用动画 WebP`) + } + + const isSlice = previewAsset.kind === 'slice' + const maxWidth = isSlice + ? TASK_DETAIL_UPLOAD_LIMITS.maxSliceWidth + : TASK_DETAIL_UPLOAD_LIMITS.maxPreviewWidth + const maxHeight = isSlice + ? TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight + : TASK_DETAIL_UPLOAD_LIMITS.maxPreviewHeight + const maxPixels = isSlice + ? TASK_DETAIL_UPLOAD_LIMITS.maxSlicePixels + : TASK_DETAIL_UPLOAD_LIMITS.maxPreviewPixels + if (width > maxWidth || height > maxHeight || width * height > maxPixels) { + throw new Error(`客户端预览图 ${previewAsset.fileName} 真实尺寸超出限制`) + } + if (width !== previewAsset.width || height !== previewAsset.height) { + throw new Error(`客户端预览图 ${previewAsset.fileName} 申报尺寸与真实尺寸不一致`) + } +} + function parsePublicAssetGroup( value: unknown, group: 'main' | 'sku' | 'detail' | 'psd', @@ -120,6 +318,65 @@ function parsePublicAssetGroup( (barcode): barcode is string => typeof barcode === 'string', ) } + + const rawPreviewAssets = Array.isArray(asset.previewAssets) ? asset.previewAssets : [] + const previewAssets = rawPreviewAssets.map((rawPreviewAsset, previewIndex) => { + if (!rawPreviewAsset || typeof rawPreviewAsset !== 'object' || Array.isArray(rawPreviewAsset)) { + throw new Error(`${group} 第 ${index + 1} 个预览图 ${previewIndex + 1} 格式错误`) + } + + const preview = rawPreviewAsset as PublicProductPackageAsset + if ( + Object.prototype.hasOwnProperty.call(preview, 'localFilePath') || + Object.prototype.hasOwnProperty.call(preview, 'fileUrl') + ) { + throw new Error('公开上传接口只允许使用预览 uploadId') + } + const previewUploadId = typeof preview.uploadId === 'string' ? preview.uploadId.trim() : '' + const previewFileName = typeof preview.fileName === 'string' ? preview.fileName.trim() : '' + const previewFileSize = Number(preview.fileSize) + if (!previewUploadId || !previewFileName || !Number.isFinite(previewFileSize) || previewFileSize < 0) { + throw new Error(`${group} 第 ${index + 1} 个预览图参数无效`) + } + if (!/\.webp$/i.test(previewFileName)) { + throw new Error(`${group} 第 ${index + 1} 个预览图必须是 WebP 格式`) + } + + const parsedPreview: ProductPackagePreviewInput = { + uploadId: previewUploadId, + fileName: previewFileName, + fileSize: previewFileSize, + } + for (const field of ['width', 'height', 'sourceIndex', 'sliceIndex', 'sliceCount'] as const) { + if (Number.isInteger(preview[field]) && Number(preview[field]) >= 0) { + parsedPreview[field] = Number(preview[field]) + } + } + for (const field of ['kind', 'sourceName'] as const) { + if (typeof preview[field] === 'string' && preview[field].trim()) { + parsedPreview[field] = preview[field].trim() + } + } + return parsedPreview + }) + + const previewUploadId = typeof asset.previewUploadId === 'string' ? asset.previewUploadId.trim() : '' + if (previewAssets.length > 0) { + imageAsset.previewAssets = previewAssets + } else if (previewUploadId) { + const previewFileName = typeof asset.previewFileName === 'string' + ? asset.previewFileName.trim() + : `${fileName.replace(/\.[^.]+$/, '')}-preview.webp` + const previewFileSize = Number(asset.previewFileSize) + if (!previewFileName || !Number.isFinite(previewFileSize) || previewFileSize < 0) { + throw new Error(`${group} 第 ${index + 1} 个预览图参数无效`) + } + imageAsset.previewAssets = [{ + uploadId: previewUploadId, + fileName: previewFileName, + fileSize: previewFileSize, + }] + } } return parsedAsset @@ -132,29 +389,119 @@ export function parsePublicProductPackageAssets(value: unknown): ProductPackageA } const assets = value as Record - return { + const parsedAssets = { main: parsePublicAssetGroup(assets.main, 'main') as ProductPackageImageInput[] | undefined, sku: parsePublicAssetGroup(assets.sku, 'sku') as ProductPackageImageInput[] | undefined, detail: parsePublicAssetGroup(assets.detail, 'detail') as ProductPackageImageInput[] | undefined, psd: parsePublicAssetGroup(assets.psd, 'psd') as ProductPackageSourceInput[] | undefined, } + assertTaskDetailUploadAssetLimits(parsedAssets) + return parsedAssets } -async function assertOwnedProductPackageUploadSessions( +/** @internal 后台执行前按真实上传会话复核所有权和文件大小。 */ +export async function assertOwnedProductPackageUploadSessions( assets: ProductPackageAssetsInput, operatorUserId: string, ) { - const uploadIds = Array.from(new Set( - [assets.main, assets.sku, assets.detail, assets.psd] - .flatMap((group) => group || []) - .map((asset) => String(asset.uploadId || '').trim()) - .filter(Boolean), - )) + assertTaskDetailUploadAssetLimits(assets) + const imageAssets = [assets.main || [], assets.sku || [], assets.detail || []].flat() + const allAssets = [...imageAssets, ...(assets.psd || [])] + const seenUploadIds = new Set() + const maxImageBytes = TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes + const configuredMaxPreviewBytes = Number(process.env.TASK_DETAIL_PREVIEW_MAX_BYTES) + const maxPreviewBytes = Number.isFinite(configuredMaxPreviewBytes) && configuredMaxPreviewBytes > 0 + ? configuredMaxPreviewBytes + : 10 * 1024 * 1024 + const maxTotalSourceBytes = TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes + const maxTotalPreviewBytes = TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewBytes + const maxTotalPreviewCount = TASK_DETAIL_UPLOAD_LIMITS.maxTotalPreviewCount + let totalSourceBytes = 0 + let totalPreviewBytes = 0 + let totalPreviewCount = 0 - for (const uploadId of uploadIds) { - if (!(await getOwnedUploadSession(uploadId, operatorUserId))) { + for (const asset of allAssets) { + const uploadId = String(asset.uploadId || '').trim() + if (!uploadId) { + // 内部历史导入仍可传受信任的本地路径/已签名地址;公开 HTTP 路由在 + // parsePublicProductPackageAssets 阶段已经拒绝这两类来源。 + if (!('fileUrl' in asset && asset.fileUrl) && !('localFilePath' in asset && asset.localFilePath)) { + throw new Error(`资产缺少 uploadId: ${asset.fileName}`) + } + if (imageAssets.includes(asset) && asset.fileSize > maxImageBytes) { + throw new Error(`图片 ${asset.fileName} 超过单图 ${Math.round(maxImageBytes / 1024 / 1024)}MB 限制`) + } + totalSourceBytes += Math.max(0, asset.fileSize) + continue + } + if (seenUploadIds.has(uploadId)) { + throw new Error(`上传会话重复使用: ${asset.fileName}`) + } + const session = await getOwnedUploadSession(uploadId, operatorUserId) + if (!session) { throw new Error(`上传会话不存在、已失效或不属于当前用户: ${uploadId}`) } + seenUploadIds.add(uploadId) + if (session.fileSize !== asset.fileSize) { + throw new Error(`文件大小校验失败,请重新上传: ${asset.fileName}`) + } + if (imageAssets.includes(asset) && session.fileSize > maxImageBytes) { + throw new Error(`图片 ${asset.fileName} 超过单图 ${Math.round(maxImageBytes / 1024 / 1024)}MB 限制`) + } + totalSourceBytes += session.fileSize + } + + for (const asset of imageAssets) { + const previews = asset.previewAssets || [] + if (previews.length > 60) { + throw new Error(`图片 ${asset.fileName} 的预览切片超过 60 张限制`) + } + const slicePreviews = previews.filter((preview) => preview.kind === 'slice') + if (slicePreviews.length > 0) { + const expectedCount = slicePreviews[0].sliceCount + if (!Number.isInteger(expectedCount) || expectedCount !== slicePreviews.length) { + throw new Error(`图片 ${asset.fileName} 的预览切片数量校验失败`) + } + const indices = slicePreviews.map((preview) => preview.sliceIndex).sort((a, b) => (a ?? 0) - (b ?? 0)) + if (indices.some((value, index) => value !== index)) { + throw new Error(`图片 ${asset.fileName} 的预览切片顺序校验失败`) + } + } + + for (const preview of previews) { + if (seenUploadIds.has(preview.uploadId)) { + throw new Error(`客户端预览图上传会话重复使用: ${preview.fileName}`) + } + seenUploadIds.add(preview.uploadId) + totalPreviewCount += 1 + totalPreviewBytes += preview.fileSize + if (totalPreviewCount > maxTotalPreviewCount) { + throw new Error('本次客户端预览图数量过多,请分批上传') + } + if (preview.fileSize <= 0 || preview.fileSize > maxPreviewBytes) { + throw new Error(`客户端预览图 ${preview.fileName} 超出大小限制`) + } + if (preview.kind !== 'preview' && preview.kind !== 'slice') { + throw new Error(`客户端预览图 ${preview.fileName} 类型无效`) + } + if (preview.width !== undefined && (preview.width < 1 || preview.width > 16000)) { + throw new Error(`客户端预览图 ${preview.fileName} 尺寸无效`) + } + if (preview.height !== undefined && (preview.height < 1 || preview.height > 16000)) { + throw new Error(`客户端预览图 ${preview.fileName} 尺寸无效`) + } + const previewSession = await getOwnedUploadSession(preview.uploadId, operatorUserId) + if (!previewSession || previewSession.fileSize !== preview.fileSize) { + throw new Error(`预览图上传会话校验失败: ${preview.fileName}`) + } + } + } + + if (totalSourceBytes > maxTotalSourceBytes) { + throw new Error('本次上传素材总大小超过 1GB,请分批提交') + } + if (totalPreviewBytes > maxTotalPreviewBytes) { + throw new Error('本次客户端预览图总大小超过 512MB,请分批提交') } } @@ -248,14 +595,16 @@ async function downloadToTempFile(prefix: string, asset: ProductPackageImageInpu } async function readAssetBuffer( - asset: ProductPackageImageInput | ProductPackageSourceInput, + asset: ProductPackageReadableAsset, type: 'main' | 'sku' | 'detail' | 'psd', + assembledFiles?: Map, + tempFiles?: string[], ) { - if (asset.localFilePath) { + if ('localFilePath' in asset && asset.localFilePath) { return readFile(asset.localFilePath) } - if (asset.fileUrl) { + if ('fileUrl' in asset && asset.fileUrl) { const response = await fetch(asset.fileUrl) if (!response.ok) { throw new Error(`下载${type}预览原图失败: ${asset.fileName}`) @@ -264,21 +613,40 @@ async function readAssetBuffer( } if (asset.uploadId) { - const filePath = await getUploadedFile(asset.uploadId) - if (!filePath) { - throw new Error(`文件不存在: ${asset.fileName} (${asset.uploadId})`) - } + const filePath = await resolveUploadedAssetPath( + { uploadId: asset.uploadId, fileName: asset.fileName }, + assembledFiles, + tempFiles, + ) return readFile(filePath) } throw new Error(`无效的${type}资产: ${asset.fileName}`) } +async function resolveUploadedAssetPath( + asset: { uploadId: string; fileName: string }, + assembledFiles?: Map, + tempFiles?: string[], +) { + const cachedPath = assembledFiles?.get(asset.uploadId) + const filePath = cachedPath || await getUploadedFile(asset.uploadId) + if (!filePath) { + throw new Error(`文件不存在: ${asset.fileName} (${asset.uploadId})`) + } + assembledFiles?.set(asset.uploadId, filePath) + if (tempFiles && !tempFiles.includes(filePath)) { + tempFiles.push(filePath) + } + return filePath +} + async function collectAssetFiles( assets: ProductPackageImageInput[] | undefined, prefix: string, tempFiles: string[], uploadIds: string[], + assembledFiles?: Map, ) { const files: Array<{ path: string; name: string; size: number }> = [] @@ -288,17 +656,23 @@ async function collectAssetFiles( for (const asset of assets) { if (asset.uploadId) { - const filePath = await getUploadedFile(asset.uploadId) + const cachedPath = assembledFiles?.get(asset.uploadId) + const filePath = cachedPath || await getUploadedFile(asset.uploadId) if (!filePath) { throw new Error(`文件缺失或未上传完成: ${asset.fileName} (${asset.uploadId})`) } + assembledFiles?.set(asset.uploadId, filePath) files.push({ path: filePath, name: asset.fileName, size: asset.fileSize, }) - tempFiles.push(filePath) - uploadIds.push(asset.uploadId) + if (!tempFiles.includes(filePath)) { + tempFiles.push(filePath) + } + if (!uploadIds.includes(asset.uploadId)) { + uploadIds.push(asset.uploadId) + } continue } @@ -323,39 +697,129 @@ async function generatePreviewsFromAssets( assets: ProductPackageImageInput[] | undefined, type: 'main' | 'sku' | 'detail', uploadedPreviewUrls: string[], + assembledFiles?: Map, + tempFiles?: string[], + uploadIds?: string[], ) { if (!assets || assets.length === 0) { return [] } const previews: GeneratedProductPackagePreviewAsset[] = [] - const { compressImage, sliceImage } = await import('@/lib/image-processor') - const sharp = await import('sharp') + let compressImage: typeof import('@/lib/image-processor').compressImage | undefined + let sliceImage: typeof import('@/lib/image-processor').sliceImage | undefined + let sharp: { default: typeof import('sharp') } | undefined + + const getSharp = async () => { + if (!sharp) { + sharp = await import('sharp') as unknown as { default: typeof import('sharp') } + } + return sharp + } + + const getServerPreviewTools = async () => { + if (!compressImage || !sliceImage) { + const imageProcessor = await import('@/lib/image-processor') + compressImage = imageProcessor.compressImage + sliceImage = imageProcessor.sliceImage + } + return { compressImage, sliceImage, sharp: await getSharp() } + } + + const safePreviewFileName = (fileName: string, index: number, sliceIndex: number) => { + const safeName = fileName.replace(/[\/\\:*?"<>|\0\r\n]/g, '_').trim() + return safeName || `${type}-preview-${index}-${sliceIndex}.webp` + } for (let index = 0; index < assets.length; index += 1) { const asset = assets[index] + + if (asset.previewAssets && asset.previewAssets.length > 0) { + for (let sliceIndex = 0; sliceIndex < asset.previewAssets.length; sliceIndex += 1) { + const previewAsset = asset.previewAssets[sliceIndex] + if (uploadIds && !uploadIds.includes(previewAsset.uploadId)) { + uploadIds.push(previewAsset.uploadId) + } + const previewFilePath = await resolveUploadedAssetPath(previewAsset, assembledFiles, tempFiles) + await assertClientPreviewWebpMetadata(previewAsset, previewFilePath, await getSharp()) + const buffer = await readFile(previewFilePath) + const configuredMaxPreviewBytes = Number(process.env.TASK_DETAIL_PREVIEW_MAX_BYTES) + const maxPreviewBytes = Number.isFinite(configuredMaxPreviewBytes) && configuredMaxPreviewBytes > 0 + ? configuredMaxPreviewBytes + : 10 * 1024 * 1024 + if (buffer.length > maxPreviewBytes) { + throw new Error(`客户端预览图 ${previewAsset.fileName} 不能超过 ${Math.ceil(maxPreviewBytes / 1024 / 1024)}MB`) + } + if (!/\.webp$/i.test(previewAsset.fileName)) { + throw new Error(`客户端预览图 ${previewAsset.fileName} 必须是 WebP 格式`) + } + if ( + buffer.length < 12 + || buffer.toString('ascii', 0, 4) !== 'RIFF' + || buffer.toString('ascii', 8, 12) !== 'WEBP' + ) { + throw new Error(`客户端预览图 ${previewAsset.fileName} 内容不是有效 WebP`) + } + + const fileName = safePreviewFileName(previewAsset.fileName, index, sliceIndex) + const previewUrl = await uploadPublicDisplayToS3( + buffer, + buildTaskDetailPublicDisplayPath(materialId, type, `client-${Date.now()}-${index}-${sliceIndex}-${fileName}`), + 'image/webp', + ) + previews.push({ + fileUrl: previewUrl, + fileName, + sortOrder: previews.length, + ...(type === 'sku' + ? { + skuCode: asset.skuCode, + barcodes: asset.barcodes || [], + } + : {}), + }) + uploadedPreviewUrls.push(previewUrl) + } + continue + } + + const previewTools = await getServerPreviewTools() let buffer: Buffer - buffer = await readAssetBuffer(asset, type) + buffer = await readAssetBuffer(asset, type, assembledFiles, tempFiles) if (buffer.length > 200 * 1024 * 1024) { throw new Error(`预览源图片 ${asset.fileName} 不能超过 200MB`) } - - const metadata = await sharp.default(buffer, { + const metadata = await previewTools.sharp!.default(buffer, { limitInputPixels: 60_000_000, sequentialRead: true, failOn: 'error', }).metadata() const shouldSlice = type === 'detail' && metadata.height && metadata.height > 1500 const buffers = shouldSlice - ? await sliceImage(buffer, 1500, 200) + ? await previewTools.sliceImage!( + buffer, + TASK_DETAIL_UPLOAD_LIMITS.maxSliceHeight, + 200, + TASK_DETAIL_UPLOAD_LIMITS.maxSliceWidth, + { + allowAnimatedFirstFrame: true, + maxInputWidth: TASK_DETAIL_SERVER_SOURCE_MAX_WIDTH, + maxInputHeight: TASK_DETAIL_SERVER_SOURCE_MAX_HEIGHT, + maxInputPixels: TASK_DETAIL_SERVER_SOURCE_MAX_PIXELS, + }, + ) : [ - await compressImage(buffer, { + await previewTools.compressImage!(buffer, { maxWidth: 800, maxHeight: 1200, maxSizeKB: 200, format: 'webp', + allowAnimatedFirstFrame: true, + maxInputWidth: TASK_DETAIL_SERVER_SOURCE_MAX_WIDTH, + maxInputHeight: TASK_DETAIL_SERVER_SOURCE_MAX_HEIGHT, + maxInputPixels: TASK_DETAIL_SERVER_SOURCE_MAX_PIXELS, }), ] @@ -387,10 +851,24 @@ async function generatePreviewsFromAssets( return previews } +/** @internal 同名 PSD 也必须生成不同的持久化路径。 */ +export function buildPsdPermanentFileName( + materialId: string, + originalFileName: string, + assetIndex: number, + timestamp = Date.now(), + entropy = randomBytes(6).toString('hex'), +) { + const sanitizedFileName = originalFileName.replace(/[\/\\:*?"<>|]/g, '_') + return `psd-${materialId}-${timestamp}-${assetIndex}-${entropy}-${sanitizedFileName}` +} + async function movePsdFilesToPermanentDir( materialId: string, assets: ProductPackageSourceInput[] | undefined, tempFiles: string[], + uploadIds: string[], + assembledFiles?: Map, ) { const psdPermanentPaths: Array<{ filePath: string; fileName: string; fileSize: number }> = [] @@ -402,18 +880,17 @@ async function movePsdFilesToPermanentDir( const psdDir = path.join(getUploadRootDir(), 'materials') await mkdir(psdDir, { recursive: true }) - for (const psd of assets) { + for (let assetIndex = 0; assetIndex < assets.length; assetIndex += 1) { + const psd = assets[assetIndex] + const permanentFileName = buildPsdPermanentFileName(materialId, psd.fileName, assetIndex) + const permanentFilePath = path.join(psdDir, permanentFileName) + const relativePath = buildStoredUploadPath('materials', permanentFileName) + if (!psd.uploadId) { if (!psd.fileUrl && !psd.localFilePath) { throw new Error(`PSD 资产参数无效: ${psd.fileName}`) } - const timestamp = Date.now() - const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_') - const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}` - const permanentFilePath = path.join(psdDir, permanentFileName) - const relativePath = buildStoredUploadPath('materials', permanentFileName) - if (psd.localFilePath) { const sourceStat = await stat(psd.localFilePath) const expectedSize = psd.fileSize || sourceStat.size @@ -444,16 +921,16 @@ async function movePsdFilesToPermanentDir( continue } - const tempFilePath = await getUploadedFile(psd.uploadId) + if (!uploadIds.includes(psd.uploadId)) { + uploadIds.push(psd.uploadId) + } + + const cachedPath = assembledFiles?.get(psd.uploadId) + const tempFilePath = cachedPath || await getUploadedFile(psd.uploadId) if (!tempFilePath) { throw new Error(`PSD 文件缺失或未上传完成: ${psd.fileName} (${psd.uploadId})`) } - - const timestamp = Date.now() - const sanitizedFileName = psd.fileName.replace(/[\/\\:*?"<>|]/g, '_') - const permanentFileName = `psd-${materialId}-${timestamp}-${sanitizedFileName}` - const permanentFilePath = path.join(psdDir, permanentFileName) - const relativePath = buildStoredUploadPath('materials', permanentFileName) + assembledFiles?.set(psd.uploadId, tempFilePath) try { await rename(tempFilePath, permanentFilePath) @@ -478,8 +955,33 @@ export async function completeProductPackageUpload(input: CompleteProductPackage const { materialId, taskNo, dingtalkApprovalNo, assets, operatorUserId, options } = input const tempFiles: string[] = [] const uploadIds: string[] = [] + const assembledFiles = new Map() const uploadedPreviewUrls: string[] = [] const createdZipPaths: string[] = [] + let persistentStateCommitted = false + let databaseSaveCompleted = false + let originalMaterialState: { + previewUrl: string + zipFilePath: string + zipFileName: string + zipFileSize: bigint + taskNo: string | null + dingtalkApprovalNo: string | null + taskStatus: string | null + isFullSet: boolean + } | null = null + let canCompensateFreshMaterial = false + const startedAt = Date.now() + const reportProgress = async (stage: string, progress: number, message?: string) => { + console.log( + `[ProductPackage] material=${materialId} stage=${stage} progress=${progress} elapsedMs=${Date.now() - startedAt}`, + ) + try { + await options?.onProgress?.({ stage, progress, message }) + } catch (error) { + console.warn('[ProductPackage] progress update skipped:', error) + } + } const normalizedAssets = { main: assets.main || [], sku: normalizeSkuPreviewAssets(assets.sku, options), @@ -490,12 +992,19 @@ export async function completeProductPackageUpload(input: CompleteProductPackage const zipTimeoutMs = options?.zipTimeoutMs && options.zipTimeoutMs > 0 ? options.zipTimeoutMs : 30000 const allZipTimeoutMs = options?.allZipTimeoutMs && options.allZipTimeoutMs > 0 ? options.allZipTimeoutMs : 60000 + const configuredCompressionLevel = Number( + options?.zipCompressionLevel ?? process.env.TASK_DETAIL_ZIP_COMPRESSION_LEVEL ?? 1, + ) + const zipCompressionLevel = Number.isFinite(configuredCompressionLevel) + ? Math.max(0, Math.min(9, Math.round(configuredCompressionLevel))) + : 1 if (!materialId || !assets) { throw new Error('缺少必填参数') } await assertOwnedProductPackageUploadSessions(assets, operatorUserId) + await reportProgress('validating', 5) const totalImages = normalizedAssets.main.length + normalizedAssets.sku.length + normalizedAssets.detail.length @@ -514,6 +1023,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage } try { + await reportProgress('validating', 10) const material: any = await prisma.material.findUnique({ where: { id: materialId }, include: { @@ -530,18 +1040,52 @@ export async function completeProductPackageUpload(input: CompleteProductPackage throw new Error('素材不存在') } + originalMaterialState = { + previewUrl: material.previewUrl || '', + zipFilePath: material.zipFilePath || '', + zipFileName: material.zipFileName || '', + zipFileSize: typeof material.zipFileSize === 'bigint' ? material.zipFileSize : BigInt(material.zipFileSize || 0), + taskNo: material.taskNo || null, + dingtalkApprovalNo: material.dingtalkApprovalNo || null, + taskStatus: material.taskStatus || null, + isFullSet: Boolean(material.isFullSet), + } + // 初始化接口创建的素材没有任何持久化文件。只有这种“新素材”允许自动补偿, + // 避免重试旧素材时误删原有资产。 + let originalAssetCount = 0 + if (originalMaterialState.taskStatus === 'uploading') { + originalAssetCount = await prisma.materialAsset.count({ where: { materialId } }) + } + canCompensateFreshMaterial = Boolean( + !originalMaterialState.previewUrl && + !originalMaterialState.zipFilePath && + !originalMaterialState.taskNo && + (originalMaterialState.taskStatus === null || + (originalMaterialState.taskStatus === 'uploading' && originalAssetCount === 0)), + ) + + if ( + material.materialType === '任务-详情' && + !normalizeTaskDetailSourceType(material.taskDetailSourceType) + ) { + throw new Error('任务-详情必须选择出图方式(实拍、渲染或 AI)') + } + const authorName = material.user?.realName || material.user?.username || '未知作者' const categoryName = material.categoryName || '未知店铺' - const mainFiles = await collectAssetFiles(normalizedAssets.main, 'main', tempFiles, uploadIds) - const skuFiles = await collectAssetFiles(normalizedAssets.sku, 'sku', tempFiles, uploadIds) - const detailFiles = await collectAssetFiles(normalizedAssets.detail, 'detail', tempFiles, uploadIds) + await reportProgress('assembling', 15) + const mainFiles = await collectAssetFiles(normalizedAssets.main, 'main', tempFiles, uploadIds, assembledFiles) + const skuFiles = await collectAssetFiles(normalizedAssets.sku, 'sku', tempFiles, uploadIds, assembledFiles) + const detailFiles = await collectAssetFiles(normalizedAssets.detail, 'detail', tempFiles, uploadIds, assembledFiles) + await reportProgress('assembling', 30) const previewAssets = { - main: await generatePreviewsFromAssets(materialId, normalizedAssets.main, 'main', uploadedPreviewUrls), - sku: await generatePreviewsFromAssets(materialId, normalizedAssets.sku, 'sku', uploadedPreviewUrls), - detail: await generatePreviewsFromAssets(materialId, normalizedAssets.detail, 'detail', uploadedPreviewUrls), + main: await generatePreviewsFromAssets(materialId, normalizedAssets.main, 'main', uploadedPreviewUrls, assembledFiles, tempFiles, uploadIds), + sku: await generatePreviewsFromAssets(materialId, normalizedAssets.sku, 'sku', uploadedPreviewUrls, assembledFiles, tempFiles, uploadIds), + detail: await generatePreviewsFromAssets(materialId, normalizedAssets.detail, 'detail', uploadedPreviewUrls, assembledFiles, tempFiles, uploadIds), } + await reportProgress('previews', 55) const zipInfos: { main?: { filePath: string; fileName: string; fileSize: number } @@ -562,6 +1106,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage authorName, categoryName, zipTimeoutMs, + zipCompressionLevel, ) createdZipPaths.push(zipInfos.main.filePath) zipFilesForAll.push({ path: zipInfos.main.filePath, name: zipInfos.main.fileName }) @@ -577,6 +1122,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage authorName, categoryName, zipTimeoutMs, + zipCompressionLevel, ) createdZipPaths.push(zipInfos.sku.filePath) zipFilesForAll.push({ path: zipInfos.sku.filePath, name: zipInfos.sku.fileName }) @@ -592,6 +1138,7 @@ export async function completeProductPackageUpload(input: CompleteProductPackage authorName, categoryName, zipTimeoutMs, + zipCompressionLevel, ) createdZipPaths.push(zipInfos.detail.filePath) zipFilesForAll.push({ path: zipInfos.detail.filePath, name: zipInfos.detail.fileName }) @@ -603,21 +1150,28 @@ export async function completeProductPackageUpload(input: CompleteProductPackage material.productModel || 'product', '任务-详情', allZipTimeoutMs, + zipCompressionLevel, ) createdZipPaths.push(zipInfos.all.filePath) + await reportProgress('zips', 80) - const psdPermanentPaths = await movePsdFilesToPermanentDir(materialId, normalizedAssets.psd, tempFiles) + const psdPermanentPaths = await movePsdFilesToPermanentDir( + materialId, + normalizedAssets.psd, + tempFiles, + uploadIds, + assembledFiles, + ) createdZipPaths.push(...psdPermanentPaths.map((item) => item.filePath)) + await reportProgress('database', 85) const saveResult = await saveProductPackageAssets(materialId, previewAssets, zipInfos, psdPermanentPaths) + databaseSaveCompleted = true const cardPreviewUrl = previewAssets.main[0]?.fileUrl || material.previewUrl - await assignTaskNoOnComplete( - materialId, - normalizedTaskNo, - normalizedDingtalkApprovalNo, - ) + // 先写入非完成标记的数据,最后由 assignTaskNoOnComplete 写入 + // taskStatus=completed,作为后台任务可恢复的提交标记。 await prisma.material.update({ where: { id: materialId }, data: { @@ -626,13 +1180,23 @@ export async function completeProductPackageUpload(input: CompleteProductPackage }, }) - await cleanupTempFiles(tempFiles, uploadIds) - - await createMaterialLog({ + await assignTaskNoOnComplete( materialId, - userId: operatorUserId, - action: 'UPLOAD', - changes: { + normalizedTaskNo, + normalizedDingtalkApprovalNo, + ) + persistentStateCommitted = true + await reportProgress('database', 93) + + await cleanupTempFiles(tempFiles, uploadIds) + await reportProgress('cleanup', 97) + + try { + await createMaterialLog({ + materialId, + userId: operatorUserId, + action: 'UPLOAD', + changes: { uploadType: { oldValue: null, newValue: '任务-详情', @@ -667,20 +1231,30 @@ export async function completeProductPackageUpload(input: CompleteProductPackage all: zipInfos.all.fileSize, }, }, - }, - }) + }, + }) + } catch (logError) { + // 主数据已经提交,日志失败不能再回滚 ZIP/S3,避免产生“数据库完成但文件被删”的坏素材。 + console.error('[ProductPackage] material log write failed after commit:', logError) + } if (derivedIsFullSet) { - void queueTaskDetailFullSetNotification(materialId) + void queueTaskDetailFullSetNotification(materialId).catch((notificationError) => { + console.error('[ProductPackage] full-set notification failed:', notificationError) + }) } if (saveResult.createdSkuPreviewAssets.some((asset) => asset.barcodes.length > 0)) { void queueTaskDetailBarcodeImagesUpdatedNotification( materialId, saveResult.createdSkuPreviewAssets.map((asset) => asset.id), - ) + ).catch((notificationError) => { + console.error('[ProductPackage] barcode notification failed:', notificationError) + }) } + await reportProgress('completed', 100) + return { materialId, taskNo: normalizedTaskNo, @@ -688,7 +1262,47 @@ export async function completeProductPackageUpload(input: CompleteProductPackage isFullSet: derivedIsFullSet, } } catch (error) { - await cleanupOnError(uploadedPreviewUrls, createdZipPaths, tempFiles, materialId) + if (persistentStateCommitted) { + console.error('[ProductPackage] post-commit error; skip destructive cleanup:', error) + throw error + } + + let databaseCompensated = !databaseSaveCompleted + if (databaseSaveCompleted && canCompensateFreshMaterial && originalMaterialState) { + try { + await prisma.$transaction(async (tx) => { + await tx.materialAsset.deleteMany({ where: { materialId } }) + await tx.material.update({ + where: { id: materialId }, + data: { + previewUrl: originalMaterialState!.previewUrl, + zipFilePath: originalMaterialState!.zipFilePath, + zipFileName: originalMaterialState!.zipFileName, + zipFileSize: originalMaterialState!.zipFileSize, + taskNo: originalMaterialState!.taskNo, + dingtalkApprovalNo: originalMaterialState!.dingtalkApprovalNo, + taskStatus: originalMaterialState!.taskStatus, + isFullSet: originalMaterialState!.isFullSet, + }, + }) + }) + databaseCompensated = true + console.warn(`[ProductPackage] compensated partial database save material=${materialId}`) + } catch (compensationError) { + // 数据库补偿失败时保留已写入的文件,避免数据库仍指向已被删除的 ZIP/S3。 + databaseCompensated = false + console.error('[ProductPackage] database compensation failed; keep committed files:', compensationError) + } + } + + await cleanupOnError( + databaseCompensated ? uploadedPreviewUrls : [], + databaseCompensated ? createdZipPaths : [], + tempFiles, + materialId, + uploadIds, + !options?.preserveMaterialOnError, + ) throw error } } diff --git a/lib/product-package.ts b/lib/product-package.ts index 4a82a37..94f2101 100644 --- a/lib/product-package.ts +++ b/lib/product-package.ts @@ -3,6 +3,7 @@ */ import { prisma } from './prisma'; +import type { Prisma } from '@prisma/client'; import path from 'path'; import { getDingtalkApprovalNoError, @@ -18,6 +19,22 @@ import { const TASK_NO_REGEX = /^TASK\d{12,18}(-\d{2})*$/; const DELETED_TASK_NO_SUFFIX_REGEX = /_DELETED_\d+_\d+$/; +/** + * 按规范化任务号串行化最终占用动作。锁函数返回 void,必须藏在子查询中, + * 只让 Prisma 反序列化普通整数列。 + */ +export async function acquireTaskNoAssignmentLock( + tx: Prisma.TransactionClient, + normalizedTaskNo: string, +) { + await tx.$queryRaw` + SELECT 1 AS locked + FROM ( + SELECT pg_advisory_xact_lock(hashtext(${'task-no:' + normalizedTaskNo})) + ) AS lock_result + `; +} + // 任务状态 export const TASK_STATUS = { UPLOADING: 'uploading', @@ -379,15 +396,38 @@ export async function assignTaskNoOnComplete( const cleanupResult = await purgeDeletedMaterialsByTaskNo(normalizedTaskNo); - await prisma.material.update({ - where: { id: materialId }, - data: { - taskNo: normalizedTaskNo, - ...(hasDingtalkApprovalNo - ? { dingtalkApprovalNo: normalizedDingtalkApprovalNo } - : {}), - taskStatus: TASK_STATUS.COMPLETED, - } as any, + await prisma.$transaction(async (tx) => { + await acquireTaskNoAssignmentLock(tx, normalizedTaskNo); + // purge 可能耗时且涉及文件系统/S3,不能放进长事务。拿锁后必须重新检查, + // 这是并发完成时决定谁能最终占用任务号的权威门禁。 + const concurrentOwner = await tx.material.findFirst({ + where: { + taskNo: normalizedTaskNo, + deletedAt: null, + NOT: { id: materialId }, + } as any, + select: { + id: true, + productModel: true, + materialType: true, + }, + }); + if (concurrentOwner) { + throw new Error( + `任务号 ${normalizedTaskNo} 已存在,对应素材:${concurrentOwner.productModel}(${concurrentOwner.materialType})` + ); + } + + await tx.material.update({ + where: { id: materialId }, + data: { + taskNo: normalizedTaskNo, + ...(hasDingtalkApprovalNo + ? { dingtalkApprovalNo: normalizedDingtalkApprovalNo } + : {}), + taskStatus: TASK_STATUS.COMPLETED, + } as any, + }); }); return cleanupResult; diff --git a/lib/task-detail-image-browser.ts b/lib/task-detail-image-browser.ts new file mode 100644 index 0000000..761a220 --- /dev/null +++ b/lib/task-detail-image-browser.ts @@ -0,0 +1,38 @@ +import { + preparePerformanceSurveyImagesInBrowser, + type PerformanceSurveyBrowserProcessedImage, +} from '@/lib/performance-survey-image-browser' + +export type TaskDetailAssetKind = 'main' | 'sku' | 'detail' + +export type TaskDetailBrowserPreview = PerformanceSurveyBrowserProcessedImage & { + sourceIndex: number +} + +/** + * 在用户浏览器中逐张生成任务-详情预览。 + * + * 这里故意一次只传一张源图,避免把整批原图和 Blob 同时留在内存里。 + * 详情长图只上传切片;普通主图/SKU图和短详情图上传一张预览。 + */ +export async function prepareTaskDetailImagePreview( + file: File, + assetKind: TaskDetailAssetKind, + onProgress?: (label: string) => void, +): Promise { + const processed = await preparePerformanceSurveyImagesInBrowser( + [file], + onProgress, + { profile: assetKind === 'detail' ? 'task_detail_detail' : 'task_detail_preview' }, + ) + + if (assetKind === 'detail' && processed.some((item) => item.sliceCount > 1)) { + return processed.filter((item) => item.kind === 'slice') + } + + const preview = processed.filter((item) => item.kind === 'preview') + if (preview.length > 0) return preview + + // 极旧浏览器/worker 兼容:没有 preview 时至少保留第一张切片,不能因预览缺失阻断原图上传。 + return processed.filter((item) => item.kind === 'slice').slice(0, 1) +} diff --git a/lib/task-detail-upload-jobs.ts b/lib/task-detail-upload-jobs.ts new file mode 100644 index 0000000..a7fdf46 --- /dev/null +++ b/lib/task-detail-upload-jobs.ts @@ -0,0 +1,552 @@ +import type { Prisma } from '@prisma/client' +import { prisma } from '@/lib/prisma' +import { + assertTaskDetailUploadAssetLimits, + completeProductPackageUpload, + type ProductPackageAssetsInput, + type CompleteProductPackageUploadInput, +} from '@/lib/product-package-completion' +import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' + +export const TASK_DETAIL_UPLOAD_JOB_STATUS = { + QUEUED: 'queued', + PROCESSING: 'processing', + COMPLETED: 'completed', + FAILED: 'failed', +} as const + +export const TASK_DETAIL_UPLOAD_JOB_STAGE = { + QUEUED: 'queued', + VALIDATING: 'validating', + ASSEMBLING: 'assembling', + PREVIEWS: 'previews', + ZIPS: 'zips', + DATABASE: 'database', + CLEANUP: 'cleanup', + COMPLETED: 'completed', + FAILED: 'failed', +} as const + +export type TaskDetailUploadJobStatus = + (typeof TASK_DETAIL_UPLOAD_JOB_STATUS)[keyof typeof TASK_DETAIL_UPLOAD_JOB_STATUS] + +export interface CreateTaskDetailUploadJobInput { + materialId: string + taskNo?: string | null + dingtalkApprovalNo?: string | null + operatorUserId: string + assets: ProductPackageAssetsInput + options?: CompleteProductPackageUploadInput['options'] +} + +export interface TaskDetailUploadJobStatusView { + jobId: string + materialId: string + status: TaskDetailUploadJobStatus | string + stage: string + progress: number + error: string | null + createdAt: string + startedAt: string | null + completedAt: string | null +} + +export class TaskDetailUploadQueueCapacityError extends Error { + readonly scope: 'user' | 'global' + readonly statusCode: 429 | 503 + + constructor(scope: 'user' | 'global') { + super(scope === 'user' + ? `您已有 ${TASK_DETAIL_UPLOAD_LIMITS.maxQueuedJobsPerUser} 个任务等待处理,请稍后再提交` + : '当前上传处理队列已满,请稍后再提交') + this.name = 'TaskDetailUploadQueueCapacityError' + this.scope = scope + this.statusCode = scope === 'user' ? 429 : 503 + } +} + +const MAX_CONCURRENT_JOBS = 1 +// 有心跳的正常任务不会触发此门禁;进程被杀后最多等待 30 分钟即可变成 +// failed,而不是把素材永久卡在 processing。超大任务可通过环境变量放宽。 +const DEFAULT_STALE_JOB_MS = 30 * 60 * 1000 +const RECOVERY_BATCH_SIZE = 20 + +let ensureTablePromise: Promise | null = null +const pendingJobIds = new Set() +let activeJobCount = 0 +let pumpRunning = false +let globallyBackpressured = false +let retryTimer: ReturnType | null = null +const GLOBAL_RETRY_DELAY_MS = 5_000 + +/** + * 在当前事务内获取任务-详情上传的 advisory lock。 + * + * pg_advisory_xact_lock() 本身返回 PostgreSQL 的 void 类型。直接用 + * Prisma $queryRaw 读取它会触发 `Failed to deserialize column of type 'void'`, + * 因此把函数放进子查询,只返回一个普通整数列给 Prisma;锁仍会在当前 + * 事务结束时自动释放。 + */ +export async function acquireTaskDetailUploadLock( + tx: Prisma.TransactionClient, + lockName: string, +) { + await tx.$queryRaw` + SELECT 1 AS locked + FROM ( + SELECT pg_advisory_xact_lock(hashtext(${lockName})) + ) AS lock_result + ` +} + +function getStaleJobMs() { + const value = Number(process.env.TASK_DETAIL_UPLOAD_STALE_MS) + return Number.isFinite(value) && value > 0 ? value : DEFAULT_STALE_JOB_MS +} + +function toJsonValue(value: unknown): Prisma.InputJsonValue { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue +} + +function toIso(value: Date | null | undefined) { + return value ? value.toISOString() : null +} + +function toStatusView(job: { + id: string + materialId: string + status: string + stage: string + progress: number + errorMessage: string | null + createdAt: Date + startedAt: Date | null + completedAt: Date | null +}): TaskDetailUploadJobStatusView { + return { + jobId: job.id, + materialId: job.materialId, + status: job.status, + stage: job.stage, + progress: Math.max(0, Math.min(100, job.progress)), + error: job.errorMessage || null, + createdAt: job.createdAt.toISOString(), + startedAt: toIso(job.startedAt), + completedAt: toIso(job.completedAt), + } +} + +/** + * 任务表由 reviewed Prisma migration 创建。请求路径不执行 DDL,避免滚动发布时 + * 多个容器同时抢迁移锁;若迁移未执行,调用方会收到明确的数据库错误并阻止入队。 + */ +export async function ensureProductPackageUploadJobTable() { + if (!ensureTablePromise) { + ensureTablePromise = Promise.resolve() + } + return ensureTablePromise +} + +async function updateJobProgress( + jobId: string, + update: { stage: string; progress: number; message?: string }, +) { + const progress = Math.max(0, Math.min(99, Math.round(update.progress))) + await prisma.productPackageUploadJob.updateMany({ + where: { id: jobId, status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING }, + data: { + stage: update.stage, + progress, + ...(update.message ? { errorMessage: update.message } : {}), + }, + }) +} + +async function claimTaskDetailUploadJob(jobId: string) { + return prisma.$transaction(async (tx) => { + // 用事务级 advisory lock 串行化所有容器的“领取任务”动作,保证全局最多一个 + // 任务进入 Sharp/ZIP 重处理,而不是只在单个 Node 进程内限流。 + await acquireTaskDetailUploadLock(tx, 'product-package-upload-worker') + const activeCount = await tx.productPackageUploadJob.count({ + where: { status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING }, + }) + if (activeCount > 0) return false + + const claimed = await tx.productPackageUploadJob.updateMany({ + where: { + id: jobId, + status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED, + }, + data: { + status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING, + stage: TASK_DETAIL_UPLOAD_JOB_STAGE.VALIDATING, + progress: 5, + startedAt: new Date(), + completedAt: null, + errorMessage: null, + }, + }) + return claimed.count === 1 + }) +} + +async function reconcileCommittedTaskDetailUploadJob(jobId: string) { + const job = await prisma.productPackageUploadJob.findUnique({ + where: { id: jobId }, + select: { + id: true, + status: true, + materialId: true, + material: { select: { zipFilePath: true, taskStatus: true } }, + }, + }) + if (!job) return true + if (job.status !== TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED && job.status !== TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING) { + return true + } + + const assetCount = await prisma.materialAsset.count({ where: { materialId: job.materialId } }) + if (job.material.taskStatus !== 'completed' || !job.material.zipFilePath || assetCount === 0) return false + + // 主资产事务已提交但容器可能在 job.completed 写入前被杀掉:不要拿已清理的 + // upload session 重跑,直接把持久化任务补记为完成。 + await prisma.productPackageUploadJob.updateMany({ + where: { id: jobId, status: { in: [TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED, TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING] } }, + data: { + status: TASK_DETAIL_UPLOAD_JOB_STATUS.COMPLETED, + stage: TASK_DETAIL_UPLOAD_JOB_STAGE.COMPLETED, + progress: 100, + completedAt: new Date(), + errorMessage: null, + }, + }) + return true +} + +type TaskDetailUploadProcessOutcome = 'processed' | 'busy' | 'ignored' + +async function processOneTaskDetailUploadJob(jobId: string): Promise { + if (await reconcileCommittedTaskDetailUploadJob(jobId)) return 'ignored' + const claimed = await claimTaskDetailUploadJob(jobId) + if (!claimed) { + const pendingJob = await prisma.productPackageUploadJob.findUnique({ + where: { id: jobId }, + select: { status: true }, + }) + if (pendingJob?.status === TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED) { + return 'busy' + } + return 'ignored' + } + + const job = await prisma.productPackageUploadJob.findUnique({ where: { id: jobId } }) + if (!job) { + return 'ignored' + } + + const heartbeatTimer = setInterval(() => { + void prisma.productPackageUploadJob.updateMany({ + where: { id: jobId, status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING }, + data: { updatedAt: new Date() }, + }).catch((heartbeatError) => { + console.warn(`[TaskDetailUploadJob] heartbeat failed job=${jobId}:`, heartbeatError) + }) + }, 30_000) + + try { + try { + await completeProductPackageUpload({ + materialId: job.materialId, + taskNo: job.taskNo, + dingtalkApprovalNo: job.dingtalkApprovalNo, + operatorUserId: job.operatorUserId, + assets: job.assets as ProductPackageAssetsInput, + options: { + ...(job.options && typeof job.options === 'object' ? job.options as Record : {}), + preserveMaterialOnError: true, + onProgress: (update) => updateJobProgress(jobId, update), + }, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : '任务-详情上传处理失败' + console.error(`[TaskDetailUploadJob] failed job=${jobId}:`, error) + try { + await prisma.productPackageUploadJob.updateMany({ + where: { id: jobId }, + data: { + status: TASK_DETAIL_UPLOAD_JOB_STATUS.FAILED, + stage: TASK_DETAIL_UPLOAD_JOB_STAGE.FAILED, + errorMessage: errorMessage.slice(0, 2000), + completedAt: new Date(), + }, + }) + } catch (jobFailureRecordError) { + console.error(`[TaskDetailUploadJob] failed record write failed job=${jobId}:`, jobFailureRecordError) + } + await prisma.material.updateMany({ + where: { id: job.materialId, taskStatus: { not: 'completed' } }, + data: { taskStatus: 'failed' }, + }).catch((statusError) => { + console.error(`[TaskDetailUploadJob] failed to mark material status job=${jobId}:`, statusError) + }) + return 'processed' + } + + try { + await prisma.productPackageUploadJob.updateMany({ + where: { id: jobId }, + data: { + status: TASK_DETAIL_UPLOAD_JOB_STATUS.COMPLETED, + stage: TASK_DETAIL_UPLOAD_JOB_STAGE.COMPLETED, + progress: 100, + completedAt: new Date(), + errorMessage: null, + }, + }) + } catch (completionRecordError) { + // 素材处理已经成功时,不能把 job 反写成 failed。保留 processing,交给 + // reconcile/周期恢复在数据库恢复后补记 completed。 + console.error(`[TaskDetailUploadJob] completed record write failed job=${jobId}:`, completionRecordError) + try { + await reconcileCommittedTaskDetailUploadJob(jobId) + } catch (reconcileError) { + console.error(`[TaskDetailUploadJob] completed record reconcile failed job=${jobId}:`, reconcileError) + } + } + } finally { + clearInterval(heartbeatTimer) + } + return 'processed' +} + +function scheduleGlobalQueueRetry() { + if (retryTimer) return + retryTimer = setTimeout(() => { + retryTimer = null + globallyBackpressured = false + void pumpTaskDetailUploadJobs() + }, GLOBAL_RETRY_DELAY_MS) + // 生产服务本身会常驻,但测试/CLI 不应被单个背压 timer 阻止退出。 + ;(retryTimer as ReturnType & { unref?: () => void }).unref?.() +} + +async function pumpTaskDetailUploadJobs() { + if (pumpRunning || globallyBackpressured) { + return + } + + pumpRunning = true + try { + while (activeJobCount < MAX_CONCURRENT_JOBS && pendingJobIds.size > 0) { + const nextJobId = pendingJobIds.values().next().value as string | undefined + if (!nextJobId) { + break + } + pendingJobIds.delete(nextJobId) + activeJobCount += 1 + void processOneTaskDetailUploadJob(nextJobId) + .then((outcome) => { + if (outcome === 'busy') { + pendingJobIds.add(nextJobId) + globallyBackpressured = true + scheduleGlobalQueueRetry() + } + }) + .catch((error) => { + console.error(`[TaskDetailUploadJob] worker crashed job=${nextJobId}:`, error) + pendingJobIds.add(nextJobId) + globallyBackpressured = true + scheduleGlobalQueueRetry() + }) + .finally(() => { + activeJobCount -= 1 + if (!globallyBackpressured) { + void pumpTaskDetailUploadJobs() + } + }) + } + } finally { + pumpRunning = false + } +} + +export function triggerTaskDetailUploadJob(jobId: string) { + pendingJobIds.add(jobId) + void pumpTaskDetailUploadJobs().catch((error) => { + console.error('[TaskDetailUploadJob] queue pump failed:', error) + }) +} + +export async function createTaskDetailUploadJob(input: CreateTaskDetailUploadJobInput) { + assertTaskDetailUploadAssetLimits(input.assets) + await ensureProductPackageUploadJobTable() + const job = await prisma.$transaction(async (tx) => { + await acquireTaskDetailUploadLock(tx, 'product-package-upload-create') + const existing = await tx.productPackageUploadJob.findFirst({ + where: { + materialId: input.materialId, + operatorUserId: input.operatorUserId, + status: { + in: [ + TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED, + TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING, + TASK_DETAIL_UPLOAD_JOB_STATUS.COMPLETED, + ], + }, + }, + orderBy: { createdAt: 'desc' }, + }) + if (existing) return existing + + const queuedForUser = await tx.productPackageUploadJob.count({ + where: { + operatorUserId: input.operatorUserId, + status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED, + }, + }) + if (queuedForUser >= TASK_DETAIL_UPLOAD_LIMITS.maxQueuedJobsPerUser) { + throw new TaskDetailUploadQueueCapacityError('user') + } + + const queuedGlobally = await tx.productPackageUploadJob.count({ + where: { status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED }, + }) + if (queuedGlobally >= TASK_DETAIL_UPLOAD_LIMITS.maxQueuedJobsGlobal) { + throw new TaskDetailUploadQueueCapacityError('global') + } + + const created = await tx.productPackageUploadJob.create({ + data: { + materialId: input.materialId, + operatorUserId: input.operatorUserId, + taskNo: input.taskNo?.trim() || null, + dingtalkApprovalNo: input.dingtalkApprovalNo?.trim() || null, + assets: toJsonValue(input.assets), + options: input.options ? toJsonValue(input.options) : undefined, + status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED, + stage: TASK_DETAIL_UPLOAD_JOB_STAGE.QUEUED, + progress: 0, + errorMessage: null, + }, + }) + + // 素材列表/详情页可以据此识别“正在后台处理”的空素材,用户关闭上传页后 + // 仍然能知道它不是一个普通的空卡片。完成函数会把状态推进到 completed。 + await tx.material.update({ + where: { id: input.materialId }, + data: { taskStatus: 'uploading' }, + }) + + return created + }) + + if (job.status === TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED) { + triggerTaskDetailUploadJob(job.id) + } + return toStatusView(job) +} + +export async function getTaskDetailUploadJobStatus(jobId: string, operatorUserId: string) { + await ensureProductPackageUploadJobTable() + const job = await prisma.productPackageUploadJob.findFirst({ + where: { + id: jobId, + operatorUserId, + }, + }) + + return job ? toStatusView(job) : null +} + +export async function getLatestTaskDetailUploadJobStatus(materialId: string, operatorUserId: string) { + await ensureProductPackageUploadJobTable() + const job = await prisma.productPackageUploadJob.findFirst({ + where: { + materialId, + operatorUserId, + }, + orderBy: { createdAt: 'desc' }, + }) + + return job ? toStatusView(job) : null +} + +/** + * 服务重启后恢复未完成队列。processing 会在心跳停止并超过 stale 窗口后标记失败, + * 不直接重跑可能已经写入部分资产的非幂等处理链路。 + */ +export async function recoverTaskDetailUploadJobs() { + await ensureProductPackageUploadJobTable() + const staleBefore = new Date(Date.now() - getStaleJobMs()) + + const staleJobs = await prisma.productPackageUploadJob.findMany({ + where: { + status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING, + updatedAt: { lt: staleBefore }, + }, + select: { id: true, materialId: true }, + }) + + // 处理链路会写入 ZIP、预览图和资产记录,无法安全地把中断中的任务直接 + // 重跑,否则可能重复资产或覆盖仍被数据库引用的文件。只有确认完成标记已 + // 落库的任务才补记 completed,其余任务标记 failed,交给用户重新提交。 + for (const staleJob of staleJobs) { + if (await reconcileCommittedTaskDetailUploadJob(staleJob.id)) continue + + await prisma.productPackageUploadJob.updateMany({ + where: { id: staleJob.id, status: TASK_DETAIL_UPLOAD_JOB_STATUS.PROCESSING }, + data: { + status: TASK_DETAIL_UPLOAD_JOB_STATUS.FAILED, + stage: TASK_DETAIL_UPLOAD_JOB_STAGE.FAILED, + errorMessage: '后台处理进程中断,请重新提交上传', + completedAt: new Date(), + }, + }) + await prisma.material.updateMany({ + where: { id: staleJob.materialId, taskStatus: { not: 'completed' } }, + data: { taskStatus: 'failed' }, + }).catch((statusError) => { + console.error(`[TaskDetailUploadJob] failed to mark stale material=${staleJob.materialId}:`, statusError) + }) + } + + const queuedJobs = await prisma.productPackageUploadJob.findMany({ + where: { status: TASK_DETAIL_UPLOAD_JOB_STATUS.QUEUED }, + orderBy: { createdAt: 'asc' }, + take: RECOVERY_BATCH_SIZE, + select: { id: true }, + }) + + for (const job of queuedJobs) { + triggerTaskDetailUploadJob(job.id) + } + + if (queuedJobs.length > 0) { + console.log(`[TaskDetailUploadJob] recovered ${queuedJobs.length} queued job(s)`) + } + return queuedJobs.length +} + +/** @internal 仅供队列核心单元测试使用,不由任何 HTTP 路由调用。 */ +export const __taskDetailUploadJobTesting = { + claimTaskDetailUploadJob, + reconcileCommittedTaskDetailUploadJob, + processOneTaskDetailUploadJob, + pumpTaskDetailUploadJobs, + getRuntimeState: () => ({ + pendingJobIds: Array.from(pendingJobIds), + activeJobCount, + pumpRunning, + globallyBackpressured, + retryScheduled: retryTimer !== null, + }), + resetRuntimeState: () => { + if (retryTimer) clearTimeout(retryTimer) + retryTimer = null + globallyBackpressured = false + pendingJobIds.clear() + activeJobCount = 0 + pumpRunning = false + ensureTablePromise = null + }, +} diff --git a/lib/task-detail-upload-policy.ts b/lib/task-detail-upload-policy.ts new file mode 100644 index 0000000..6c656c0 --- /dev/null +++ b/lib/task-detail-upload-policy.ts @@ -0,0 +1,77 @@ +export const TASK_DETAIL_UPLOAD_LIMITS = { + /** 单个图片原图的保护上限,避免浏览器解码/服务器兜底处理一次吞掉过多内存。 */ + maxSingleImageBytes: 100 * 1024 * 1024, + /** 一次任务允许的图片数量上限;详情长图的切片不计入此门禁。 */ + maxImagesPerGroup: 60, + maxTotalImageCount: 120, + /** PSD/PSB/TIFF 等源文件不是图片计数的一部分,单独限制,防止小文件堆积绕过图片上限。 */ + maxPsdFileCount: 20, + /** 原图与源文件的总条目数上限。 */ + maxTotalSourceFileCount: 140, + /** 原图总大小保护上限,给大批量上传留出空间,同时避免误操作打爆客户端和服务器。 */ + maxTotalSourceBytes: 1024 * 1024 * 1024, + maxPreviewCountPerImage: 60, + maxTotalPreviewCount: 1_000, + maxSinglePreviewBytes: 10 * 1024 * 1024, + maxTotalPreviewBytes: 512 * 1024 * 1024, + /** 普通预览为 800x1200,详情切片最大 1600x1500。 */ + maxPreviewWidth: 800, + maxPreviewHeight: 1_200, + maxPreviewPixels: 800 * 1_200, + maxSliceWidth: 1_600, + maxSliceHeight: 1_500, + maxSlicePixels: 1_600 * 1_500, + maxPreviewAnimationFrames: 1, + /** 单个用户最多排队 3 个,防止一个账号长期占满唯一图片 worker。 */ + maxQueuedJobsPerUser: 3, + /** 全站最多等待 20 个任务;processing 任务不计入等待容量。 */ + maxQueuedJobsGlobal: 20, + /** 合法最大提交:140 个源文件 + 1000 个客户端预览。 */ + maxTotalUploadSessionCount: 1_140, +} as const + +export type TaskDetailUploadFileGroups = { + main: File[] + sku: File[] + detail: File[] + psd?: File[] +} + +export function validateTaskDetailUploadFiles(groups: TaskDetailUploadFileGroups): string | null { + const imageGroups: Array<[string, File[]]> = [ + ['主图', groups.main], + ['SKU 图', groups.sku], + ['详情图', groups.detail], + ] + + for (const [label, files] of imageGroups) { + if (files.length > TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup) { + return `${label}最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张,请分批提交` + } + const oversized = files.find((file) => file.size > TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes) + if (oversized) { + return `${label}「${oversized.name}」超过单图 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxSingleImageBytes / 1024 / 1024)}MB 限制,请先压缩或拆分` + } + } + + const imageFiles = imageGroups.flatMap(([, files]) => files) + if (imageFiles.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount) { + return `本次图片最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount} 张,请分批提交` + } + + const psdFiles = groups.psd || [] + if (psdFiles.length > TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount) { + return `PSD 源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount} 个,请分批提交` + } + + const allFiles = [...imageFiles, ...psdFiles] + if (allFiles.length > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount) { + return `本次原图和源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceFileCount} 个,请分批提交` + } + const totalBytes = allFiles.reduce((sum, file) => sum + Math.max(0, file.size), 0) + if (totalBytes > TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes) { + return `本次素材总大小超过 ${Math.round(TASK_DETAIL_UPLOAD_LIMITS.maxTotalSourceBytes / 1024 / 1024 / 1024)}GB,请分批提交` + } + + return null +} diff --git a/lib/task-detail-upload-status.ts b/lib/task-detail-upload-status.ts new file mode 100644 index 0000000..66d041e --- /dev/null +++ b/lib/task-detail-upload-status.ts @@ -0,0 +1,70 @@ +export type TaskDetailUploadJobStatus = { + jobId: string + materialId?: string + status: 'queued' | 'processing' | 'completed' | 'failed' + stage?: string + progress?: number + error?: string + taskNo?: string +} + +export class TaskDetailUploadJobError extends Error { + readonly jobStatus?: TaskDetailUploadJobStatus['status'] + + constructor(message: string, jobStatus?: TaskDetailUploadJobStatus['status']) { + super(message) + this.name = 'TaskDetailUploadJobError' + this.jobStatus = jobStatus + } +} + +type PollOptions = { + signal?: AbortSignal + timeoutMs?: number + onStatus?: (status: TaskDetailUploadJobStatus) => void +} + +function wait(delayMs: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException('上传已取消', 'AbortError')) + return + } + const timer = setTimeout(resolve, delayMs) + signal?.addEventListener('abort', () => { + clearTimeout(timer) + reject(new DOMException('上传已取消', 'AbortError')) + }, { once: true }) + }) +} + +export async function pollTaskDetailUploadJob( + jobId: string, + options: PollOptions = {}, +): Promise { + const deadline = Date.now() + (options.timeoutMs ?? 30 * 60 * 1000) + let delayMs = 800 + + while (Date.now() < deadline) { + const response = await fetch( + `/api/materials/upload/product-package/status?jobId=${encodeURIComponent(jobId)}`, + { signal: options.signal }, + ) + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(payload?.error || '查询后台处理状态失败') + } + + const status = (payload?.data || payload) as TaskDetailUploadJobStatus + options.onStatus?.(status) + if (status.status === 'completed') return status + if (status.status === 'failed') { + throw new TaskDetailUploadJobError(status.error || '后台处理失败', status.status) + } + + await wait(delayMs, options.signal) + delayMs = Math.min(3_000, Math.round(delayMs * 1.25)) + } + + throw new Error('后台处理超时,请稍后到素材详情查看处理结果') +} diff --git a/lib/task-material-processor.ts b/lib/task-material-processor.ts index a42065c..59cbeb1 100644 --- a/lib/task-material-processor.ts +++ b/lib/task-material-processor.ts @@ -120,6 +120,7 @@ export async function createZipFromFiles( authorName?: string, // 作者名(上传人姓名) categoryName?: string, // 店铺名 timeoutMs: number = 30000, + compressionLevel: number = 9, ): Promise<{ filePath: string; fileName: string; fileSize: number }> { if (!files || files.length === 0) { throw new Error('没有文件需要打包'); @@ -193,7 +194,7 @@ export async function createZipFromFiles( // 创建输出流 output = createWriteStream(zipFilePath); archive = archiver('zip', { - zlib: { level: 9 } // 最高压缩级别 + zlib: { level: Math.max(0, Math.min(9, Math.round(compressionLevel))) }, }); // 监听完成事件 @@ -278,6 +279,7 @@ export async function createZipFromZips( productModel: string, materialType: string, timeoutMs: number = 60000, + compressionLevel: number = 9, ): Promise<{ filePath: string; fileName: string; fileSize: number }> { if (zipFiles.length === 0) { throw new Error('没有文件需要打包'); @@ -339,7 +341,7 @@ export async function createZipFromZips( try { output = createWriteStream(zipFilePath); archive = archiver('zip', { - zlib: { level: 9 } // 最高压缩级别 + zlib: { level: Math.max(0, Math.min(9, Math.round(compressionLevel))) }, }); output.on('close', async () => { @@ -557,7 +559,9 @@ export async function cleanupOnError( uploadedPreviewUrls: string[], zipFilePaths: string | string[] | null, tempFiles: string[], - materialId: string | null + materialId: string | null, + uploadIds: string[] = [], + deleteMaterial = true, ): Promise { console.log('[Cleanup] Starting error cleanup...'); @@ -606,8 +610,13 @@ export async function cleanupOnError( } } + // 失败时也要清掉分片目录和 Redis 会话,避免重复上传/失败重试不断堆积。 + if (uploadIds.length > 0) { + await cleanupTempFiles([], uploadIds) + } + // 删除素材记录 - if (materialId) { + if (materialId && deleteMaterial) { try { await prisma.material.delete({ where: { id: materialId }, diff --git a/prisma/migrations/20260731_add_product_package_upload_jobs/migration.sql b/prisma/migrations/20260731_add_product_package_upload_jobs/migration.sql new file mode 100644 index 0000000..18a821a --- /dev/null +++ b/prisma/migrations/20260731_add_product_package_upload_jobs/migration.sql @@ -0,0 +1,36 @@ +CREATE TABLE "product_package_upload_jobs" ( + "id" TEXT NOT NULL, + "material_id" TEXT NOT NULL, + "operator_user_id" TEXT NOT NULL, + "task_no" TEXT, + "dingtalk_approval_no" TEXT, + "assets" JSONB NOT NULL, + "options" JSONB, + "status" TEXT NOT NULL, + "stage" TEXT NOT NULL, + "progress" INTEGER NOT NULL DEFAULT 0, + "error_message" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "started_at" TIMESTAMP(3), + "completed_at" TIMESTAMP(3), + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "product_package_upload_jobs_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "product_package_upload_jobs_material_id_idx" +ON "product_package_upload_jobs"("material_id"); + +CREATE INDEX "product_package_upload_jobs_operator_user_id_created_at_idx" +ON "product_package_upload_jobs"("operator_user_id", "created_at"); + +CREATE INDEX "product_package_upload_jobs_status_created_at_idx" +ON "product_package_upload_jobs"("status", "created_at"); + +ALTER TABLE "product_package_upload_jobs" +ADD CONSTRAINT "product_package_upload_jobs_material_id_fkey" +FOREIGN KEY ("material_id") REFERENCES "materials"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "product_package_upload_jobs" +ADD CONSTRAINT "product_package_upload_jobs_operator_user_id_fkey" +FOREIGN KEY ("operator_user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6124963..de13c4c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,6 +51,7 @@ model User { materialTransfersAsOperator MaterialTransfer[] @relation("MaterialTransferOperator") materials Material[] @relation("MaterialOwner") taskDetailRequestedMaterials Material[] @relation("TaskDetailRequester") + productPackageUploadJobs ProductPackageUploadJob[] @relation("ProductPackageUploadJobOperator") departmentRef Department? @relation("UserDepartment", fields: [departmentId], references: [id]) transferToDept Department? @relation("UserTransferDepartment", fields: [transferToDeptId], references: [id]) @@ -114,6 +115,7 @@ model Material { skus MaterialSku[] transfers MaterialTransfer[] transcodeProgresses VideoTranscodeProgress[] @relation("MaterialTranscodeProgress") + productPackageUploadJobs ProductPackageUploadJob[] user User @relation("MaterialOwner", fields: [userId], references: [id], onDelete: Cascade) taskDetailRequester User? @relation("TaskDetailRequester", fields: [taskDetailRequesterUserId], references: [id], onDelete: SetNull) @@ -140,6 +142,32 @@ model Material { @@map("materials") } +model ProductPackageUploadJob { + id String @id @default(uuid()) + materialId String @map("material_id") + operatorUserId String @map("operator_user_id") + taskNo String? @map("task_no") + dingtalkApprovalNo String? @map("dingtalk_approval_no") + assets Json + options Json? + status String + stage String + progress Int @default(0) + errorMessage String? @map("error_message") + createdAt DateTime @default(now()) @map("created_at") + startedAt DateTime? @map("started_at") + completedAt DateTime? @map("completed_at") + updatedAt DateTime @updatedAt @map("updated_at") + + material Material @relation(fields: [materialId], references: [id], onDelete: Cascade) + operator User @relation("ProductPackageUploadJobOperator", fields: [operatorUserId], references: [id], onDelete: Cascade) + + @@index([materialId]) + @@index([operatorUserId, createdAt]) + @@index([status, createdAt]) + @@map("product_package_upload_jobs") +} + model MaterialLike { id String @id @default(uuid()) materialId String