fix: 稳定任务详情上传与素材查询

This commit is contained in:
chenpei 2026-08-01 15:57:02 +08:00
parent 04fd25b60e
commit 0897c81471
45 changed files with 5477 additions and 477 deletions

View File

@ -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<string, number>()
@ -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) {

View File

@ -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<T, R>(
values: T[],
concurrency: number,
mapper: (value: T, index: number) => Promise<R>,
): Promise<R[]> {
const results = new Array<R>(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,

View File

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

View File

@ -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()) {
// 验证任务号格式

View File

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

View File

@ -179,7 +179,7 @@ describe('PerformanceSurveyAdminConfig', () => {
test('概览将岗位设置与发布状态合并展示', async () => {
const request = createRequest()
const onNavigate = jest.fn()
render(<PerformanceSurveyAdminConfig mode="overview" request={request} onNavigate={onNavigate} />)
render(<PerformanceSurveyAdminConfig mode="overview" request={request} onNavigate={onNavigate} month="2026-07" />)
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(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
const view = render(<PerformanceSurveyAdminConfig mode="templates" request={request} month="2026-07" />)
await waitFor(() => expect(request).toHaveBeenCalledWith('/admin/config?month=2026-07&scope=templates'))
view.rerender(<PerformanceSurveyAdminConfig mode="people" request={request} />)
view.rerender(<PerformanceSurveyAdminConfig mode="people" request={request} month="2026-07" />)
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(<PerformanceSurveyAdminConfig mode="people" request={request} />)
render(<PerformanceSurveyAdminConfig mode="people" request={request} month="2026-07" />)
await waitFor(() => expect(screen.getAllByText('张三').length).toBeGreaterThan(0))
fireEvent.click(screen.getByLabelText('选择 张三'))
@ -892,7 +892,7 @@ describe('PerformanceSurveyAdminConfig', () => {
: group),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
render(<PerformanceSurveyAdminConfig mode="positions" request={request} month="2026-07" />)
await waitFor(() => expect(screen.getByRole('heading', { name: '岗位考核项' })).toBeInTheDocument())
const publishButton = screen.getByRole('button', { name: '发布岗位设置' })
expect(publishButton).toBeEnabled()

View File

@ -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: () => <div data-testid="upload-form" />,
default: ({
onProductModelChange,
onCategoryChange,
}: {
onProductModelChange: (value: string) => void
onCategoryChange: (value: string) => void
}) => (
<div data-testid="upload-form">
<button type="button" onClick={() => onProductModelChange('SPU-TEST')}>设置测试 SPU</button>
<button type="button" onClick={() => onCategoryChange('测试店铺')}>设置测试店铺</button>
</div>
),
}))
jest.mock('@/components/upload/category-handlers', () => ({
@ -29,11 +61,72 @@ const defaultProps = {
onRefreshScenes: jest.fn().mockResolvedValue(undefined),
}
function createDeferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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<typeof userEvent.setup>,
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<unknown>) {
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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<XMLHttpRequest>) => void
}) => {
const isA = file.name === 'a.jpg'
options.onActiveXhrChange?.(new Set([isA ? xhrA : xhrB]))
return isA ? uploadA.promise : uploadB.promise
})
const fetchMock = installUploadFlowFetch()
render(<ProductPackageUploader {...defaultProps} />)
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<XMLHttpRequest>) => 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(<ProductPackageUploader {...defaultProps} />)
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<unknown>()
const fetchMock = installUploadFlowFetch(completeResponse.promise)
render(<ProductPackageUploader {...defaultProps} />)
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<unknown>()
const fetchMock = installUploadFlowFetch(completeResponse.promise)
const { unmount } = render(<ProductPackageUploader {...defaultProps} />)
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(<ProductPackageUploader {...defaultProps} />)
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
}
})
})

View File

@ -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<AssetFile[]>) {
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<HTMLInputElement>(null);
const detailInputRef = useRef<HTMLInputElement>(null);
const psdInputRef = useRef<HTMLInputElement>(null);
const mountedRef = useRef(true);
const cancelledRef = useRef(false);
const uploadStopRef = useRef(false);
const activeXhrsRef = useRef<Set<XMLHttpRequest>>(new Set());
const activeXhrSetsRef = useRef<Map<string, Set<XMLHttpRequest>>>(new Map());
const jobPollAbortRef = useRef<AbortController | null>(null);
const completionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const progressRef = useRef(0);
const jobAcceptedRef = useRef(false);
const assetGroupsRef = useRef<AssetFile[][]>([]);
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,10 +488,14 @@ 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<string> => {
const { chunkUpload } = await import('@/lib/chunk-upload');
@ -393,15 +504,31 @@ export default function ProductPackageUploader({
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<XMLHttpRequest>();
activeXhrSetsRef.current.forEach((set) => set.forEach((xhr) => merged.add(xhr)));
activeXhrsRef.current = merged;
}
},
});
if (requestKey) {
activeXhrSetsRef.current.delete(requestKey);
const merged = new Set<XMLHttpRequest>();
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 <TInput, TOutput>(
@ -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<string, number>();
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<Array<{ fileName: string; fileSize: number; uploadId: string; sortOrder: number }>> => {
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 <T,>(worker: () => Promise<T>): Promise<T> => {
if (browserPreviewBusy) {
await new Promise<void>((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<UploadedTaskResult> => {
if (cancelledRef.current || uploadStopRef.current) throw new Error('上传已取消');
const { group, asset } = task;
const requestPrefix = `${taskIndex}-${asset.id}`;
let previewAssets: NonNullable<UploadedAsset['previewAssets']> = [];
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<Array<{ fileName: string; fileSize: number; uploadId: string }>> => {
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 (
<div className="space-y-6">
<TaskReferenceFields
@ -754,16 +1038,19 @@ export default function ProductPackageUploader({
<div>
<label htmlFor="task-detail-source-type" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
出图方式 <span className="text-gray-500 dark:text-gray-400 text-xs">(可选)</span>
出图方式 <span className="text-red-500">*</span>
</label>
<select
id="task-detail-source-type"
value={taskDetailSourceType}
onChange={(e) => setTaskDetailSourceType(e.target.value as TaskDetailSourceType | '')}
disabled={uploading}
required
aria-required="true"
aria-invalid={Boolean(error && !taskDetailSourceType)}
className="w-full px-4 py-2 rounded-apple border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white focus:border-gray-900 dark:focus:border-gray-400 focus:ring-2 focus:ring-gray-200 dark:focus:ring-gray-600 outline-none"
>
<option value="">未设置</option>
<option value="">请选择出图方式</option>
{TASK_DETAIL_SOURCE_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
@ -783,7 +1070,10 @@ export default function ProductPackageUploader({
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择主图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -795,26 +1085,28 @@ export default function ProductPackageUploader({
onDragLeave={(e) => handleDragLeave(e, setIsMainDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, setMainImages, setIsMainDragging, 'image')}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => mainInputRef.current?.click())}
>
<input
id="main-images-input"
ref={mainInputRef}
type="file"
accept="image/*"
accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT}
multiple
onChange={(e) => handleImageSelect(e.target.files, setMainImages)}
className="hidden"
disabled={uploading}
/>
<div className="grid grid-cols-5 gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{mainImages.map((img) => (
<div key={img.id} className="relative group">
<div className="relative w-full h-24 rounded-apple overflow-hidden border-2 border-gray-200 dark:border-gray-600">
<img src={img.preview} alt="" className="w-full h-full object-cover" />
<img src={img.preview} alt="" className="h-full w-full bg-gray-50 object-contain dark:bg-gray-800" />
</div>
<button
onClick={() => handleRemoveImage(img.id, setMainImages)}
className="absolute -top-1.5 -right-1.5 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`删除主图 ${img.file.name}`}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
disabled={uploading}
type="button"
>
@ -856,7 +1148,10 @@ export default function ProductPackageUploader({
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择 SKU 图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -868,26 +1163,28 @@ export default function ProductPackageUploader({
onDragLeave={(e) => handleDragLeave(e, setIsSkuDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, setSkuImages, setIsSkuDragging, 'image')}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => skuInputRef.current?.click())}
>
<input
id="sku-images-input"
ref={skuInputRef}
type="file"
accept="image/*"
accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT}
multiple
onChange={(e) => handleImageSelect(e.target.files, setSkuImages)}
className="hidden"
disabled={uploading}
/>
<div className="grid grid-cols-5 gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{skuImages.map((img) => (
<div key={img.id} className="relative group space-y-1.5 rounded-apple border border-gray-200 bg-white p-2 dark:border-gray-700 dark:bg-gray-800/80">
<div className="relative w-full h-20 rounded-apple overflow-hidden border border-gray-200 dark:border-gray-600">
<img src={img.preview} alt="" className="w-full h-full object-cover" />
<img src={img.preview} alt="" className="h-full w-full bg-gray-50 object-contain dark:bg-gray-800" />
</div>
<button
onClick={() => handleRemoveImage(img.id, setSkuImages)}
className="absolute -top-1.5 -right-1.5 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
aria-label={`删除 SKU 图 ${img.file.name}`}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs text-white opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100"
disabled={uploading}
type="button"
>
@ -935,7 +1232,10 @@ export default function ProductPackageUploader({
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择详情图文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -947,12 +1247,13 @@ export default function ProductPackageUploader({
onDragLeave={(e) => handleDragLeave(e, setIsDetailDragging)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, setDetailImages, setIsDetailDragging, 'image')}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => detailInputRef.current?.click())}
>
<input
id="detail-images-input"
ref={detailInputRef}
type="file"
accept="image/*"
accept={TASK_DETAIL_BROWSER_IMAGE_ACCEPT}
multiple
onChange={(e) => handleImageSelect(e.target.files, setDetailImages)}
className="hidden"
@ -968,7 +1269,7 @@ export default function ProductPackageUploader({
>
{detailImages.length > 0 ? '继续添加' : '选择详情图'}
</button>
<span className="text-xs text-gray-400">JPG / PNG / WebP,支持拖拽</span>
<span className="text-xs text-gray-400">JPG / PNG / GIF / WebP,支持拖拽</span>
</div>
{/* 详情图排序组件 */}
@ -990,7 +1291,10 @@ export default function ProductPackageUploader({
</label>
{/* biome-ignore lint/a11y/noStaticElementInteractions: 拖拽上传区域需要使用 drag/drop 事件 */}
<div
role="group"
tabIndex={uploading ? -1 : 0}
aria-disabled={uploading}
aria-label="选择 PSD 源文件"
className={`border-2 border-dashed rounded-apple p-4 transition-colors ${
uploading ? 'cursor-not-allowed opacity-60' : ''
} ${
@ -1004,6 +1308,7 @@ export default function ProductPackageUploader({
onDrop={(e) => {
handleDrop(e, setDetailImages, setIsPsdDragging, 'psd');
}}
onKeyDown={(event) => handleDropZoneKeyDown(event, () => psdInputRef.current?.click())}
>
<input
id="psd-files-input"
@ -1033,6 +1338,7 @@ export default function ProductPackageUploader({
{psdFile.file.name}
<button
onClick={() => 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 && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-apple text-red-600 dark:text-red-400 text-sm">
<div role="alert" aria-live="assertive" className="break-words p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-apple text-red-600 dark:text-red-400 text-sm">
{error}
</div>
)}
@ -1057,30 +1363,37 @@ export default function ProductPackageUploader({
{/* 上传进度 */}
{uploading && (
<div className="space-y-2">
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
role="progressbar"
aria-label="素材上传进度"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={uploadProgress}
className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2"
>
<div
className="bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-700 dark:text-gray-300">{uploadProgress}%</span>
<div className="flex items-start justify-between gap-3 text-sm">
<span className="shrink-0 text-gray-700 dark:text-gray-300">{uploadProgress}%</span>
{processingStep && (
<span className="text-gray-500 dark:text-gray-400">{processingStep}</span>
<span aria-live="polite" className="min-w-0 break-all text-right text-gray-500 dark:text-gray-400">{processingStep}</span>
)}
</div>
</div>
)}
{/* 操作按钮 */}
<div className="flex justify-end space-x-3">
<div className="sticky bottom-0 -mx-4 -mb-4 border-t border-gray-200 bg-white/95 px-4 py-3 backdrop-blur supports-[backdrop-filter]:bg-white/80 dark:border-gray-700 dark:bg-gray-800/95 dark:supports-[backdrop-filter]:bg-gray-800/80">
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
onClick={uploading ? handleCancelUpload : onCancel}
className="btn-secondary"
disabled={uploading}
type="button"
>
取消
{uploading ? '取消上传' : '取消'}
</button>
<button
onClick={handleSubmit}
@ -1090,7 +1403,8 @@ export default function ProductPackageUploader({
>
{uploading ? '上传中...' : '提交'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@ -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<Element>, disabled = fals
event.dataTransfer.dropEffect = disabled ? 'none' : 'copy'
}
/**
* 让拖拽上传区域也能通过键盘打开文件选择器,避免只能用鼠标点击。
*/
export function handleDropZoneKeyDown(
event: React.KeyboardEvent<HTMLElement>,
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)

View File

@ -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`,也尚未部署生产。

View File

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

View File

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

View File

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

View File

@ -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('图片尺寸过大')
})
})

View File

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

View File

@ -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 <T,>(result: T): Promise<T> => {
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,
})
})
})

View File

@ -0,0 +1,35 @@
import {
isPerformanceSurveyBrowserCapabilityError,
PerformanceSurveyBrowserImageError,
type PerformanceSurveyBrowserImageErrorCode,
} from '@/lib/performance-survey-image-error'
describe('performance-survey-image-error', () => {
test.each<PerformanceSurveyBrowserImageErrorCode>([
'BROWSER_CAPABILITY_UNAVAILABLE',
'BROWSER_DECODE_UNAVAILABLE',
'WORKER_UNAVAILABLE',
'CANVAS_UNAVAILABLE',
'CANVAS_EXPORT_UNAVAILABLE',
])('%s 可进入浏览器能力不足的服务器兜底', (code) => {
expect(isPerformanceSurveyBrowserCapabilityError(
new PerformanceSurveyBrowserImageError(code, '浏览器能力不足'),
)).toBe(true)
})
test.each<PerformanceSurveyBrowserImageErrorCode>([
'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)
})
})

View File

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

View File

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

View File

@ -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<void>((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-`)

View File

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

View File

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

View File

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

View File

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

View File

@ -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<void>((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)
})
})

View File

@ -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<string, unknown> = {}) {
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<void>((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)
})
})

View File

@ -20,6 +20,7 @@ interface ChunkUploadOptions {
isCancelled?: () => boolean // 取消检查回调,返回 true 时停止上传
onActiveXhrChange?: (xhrs: Set<XMLHttpRequest>) => void // 暴露所有活跃的XHR请求集合(用于批量取消)
requestHeaders?: Record<string, string> // 供钉钉等独立登录态透传认证头
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[] = []

View File

@ -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<string, Promise<boolean>>();
const viewableMaterialTypesInFlight = new Map<string, Promise<string[]>>();
/**
* 判断是否是超级管理员(从数据库读取)
*/
@ -10,12 +13,24 @@ export async function isSuperAdmin(userId: string | null | undefined): Promise<b
return false;
}
const user = await prisma.user.findUnique({
const existingCheck = superAdminChecksInFlight.get(userId);
if (existingCheck) {
return existingCheck;
}
const check = prisma.user.findUnique({
where: { id: userId },
select: { isSuperAdmin: true },
});
}).then((user) => user?.isSuperAdmin ?? false);
return 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<string, DepartmentPermissionRecord>,
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<string, DepartmentPermissionRecord>,
departmentByName: Map<string, DepartmentRecord>,
departmentById: Map<string, DepartmentRecord>,
visitedDepartments: Set<string> = 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<string, { id: string; name: string; parentId?: string | null }>();
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<string, DepartmentPermissionRecord>(
permissionRecords.map((permission) => [
getDepartmentPermissionKey(permission.department, permission.materialType),
permission,
]),
);
const departmentByName = new Map<string, DepartmentRecord>();
const departmentById = new Map<string, DepartmentRecord>();
for (const departmentRecord of departments || []) {
departmentByName.set(departmentRecord.name, departmentRecord);
departmentById.set(departmentRecord.id, departmentRecord);
}
const result: Record<string, DepartmentPermission> = {};
// 为每种素材类型获取权限(包括继承的权限)
// 权限记录一次性批量读取后,在内存中解析直接配置、关联部门和父部门继承。
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<string[]> {
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<string[]> {
// 超级管理员可以看到所有素材类型
if (userId && await isSuperAdmin(userId)) {

View File

@ -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<Buffer[]> {
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 })

View File

@ -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<MaterialQueryContext> {
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<MaterialWhereResult> {
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 }

View File

@ -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<WorkerProcessedImage[]> {
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<PerformanceSurveyBrowserProcessedImage[]> {
if (files.length === 0) throw new Error('请至少选择 1 张图片')

View File

@ -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<PerformanceSurveyBrowserImageErrorCode>([
'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)
}

View File

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

View File

@ -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<WorkerProcessedImage[]> {
async function processSingleOriginal(file: File, includePreview = true): Promise<WorkerProcessedImage[]> {
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<WorkerProcessedImage[]
})
}
if (!includePreview) return slices
const preview = await renderPreview(slices[0].blob, slices[0].width, slices[0].height)
return [{
...preview,
@ -162,7 +233,9 @@ async function processSingleOriginal(file: File): Promise<WorkerProcessedImage[]
sliceCount,
}, ...slices]
} catch (error) {
throw new Error(
if (error instanceof PerformanceSurveyBrowserImageError) throw error
throw new PerformanceSurveyBrowserImageError(
'PROCESSING_FAILED',
error instanceof Error && error.message
? error.message
: `图片「${file.name}」无法在浏览器中处理`,
@ -172,9 +245,45 @@ async function processSingleOriginal(file: File): Promise<WorkerProcessedImage[]
}
}
async function renderPreviewFromBitmap(bitmap: ImageBitmap) {
const scale = Math.min(previewWidth / bitmap.width, previewHeight / 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 }
}
async function processSinglePreview(file: File): Promise<WorkerProcessedImage[]> {
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<WorkerProcessedImage[]> {
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<WorkerProcessedImage
for (const file of files) {
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} 个切片`,
)
}
totalSliceCount += sliceCount
plans.push({ outputWidth, outputHeight, scale, sliceCount })
@ -202,7 +316,10 @@ async function processOrderedSlices(files: File[]): Promise<WorkerProcessedImage
}
}
if (totalSliceCount > 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<WorkerProcessedImage
workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
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 : '浏览器图片处理失败',
})
}

View File

@ -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<string, unknown>
: {}
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: {

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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<TaskDetailBrowserPreview[]> {
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)
}

View File

@ -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<void> | null = null
const pendingJobIds = new Set<string>()
let activeJobCount = 0
let pumpRunning = false
let globallyBackpressured = false
let retryTimer: ReturnType<typeof setTimeout> | 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<TaskDetailUploadProcessOutcome> {
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<string, unknown> : {}),
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<typeof setTimeout> & { 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
},
}

View File

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

View File

@ -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<void>((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<TaskDetailUploadJobStatus> {
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('后台处理超时,请稍后到素材详情查看处理结果')
}

View File

@ -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<void> {
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 },

View File

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

View File

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