fix: 稳定任务详情上传与素材查询
This commit is contained in:
parent
04fd25b60e
commit
0897c81471
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()) {
|
||||
// 验证任务号格式
|
||||
|
||||
43
app/api/materials/upload/product-package/status/route.ts
Normal file
43
app/api/materials/upload/product-package/status/route.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,27 +488,47 @@ export default function ProductPackageUploader({
|
||||
}
|
||||
};
|
||||
|
||||
// 使用分片上传到本地服务器(和任务-3D原图一样)
|
||||
// 使用分片上传到本地服务器(和任务-3D原图一样)。
|
||||
// 图片预处理和网络上传是两条不同的资源链路:预处理保持单张,网络分片
|
||||
// 允许两个并发,避免 300M 公网链路被单个请求的往返等待限制吞吐。
|
||||
const uploadFileWithChunks = async (
|
||||
file: File,
|
||||
onProgress?: (progress: number) => void
|
||||
onProgress?: (progress: number) => void,
|
||||
isCancelled?: () => boolean,
|
||||
requestKey?: string,
|
||||
): Promise<string> => {
|
||||
const { chunkUpload } = await import('@/lib/chunk-upload');
|
||||
|
||||
|
||||
const result = await chunkUpload(file, {
|
||||
materialType: '任务-详情',
|
||||
fileName: file.name,
|
||||
}, {
|
||||
// chunkSize使用默认配置:内网20MB,公网10MB
|
||||
skipComplete: true, // 跳过complete,由后续统一处理
|
||||
onProgress: (progress) => {
|
||||
onProgress?.(progress);
|
||||
skipComplete: true,
|
||||
chunkConcurrency: 2,
|
||||
isCancelled,
|
||||
onProgress: (progress) => onProgress?.(progress),
|
||||
onActiveXhrChange: (xhrs) => {
|
||||
if (requestKey) {
|
||||
activeXhrSetsRef.current.set(requestKey, xhrs);
|
||||
const merged = new Set<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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' }),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`,也尚未部署生产。
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
168
lib/__tests__/department-permission-batch.test.ts
Normal file
168
lib/__tests__/department-permission-batch.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
122
lib/__tests__/image-processor-slice.test.ts
Normal file
122
lib/__tests__/image-processor-slice.test.ts
Normal 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('图片尺寸过大')
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
128
lib/__tests__/materials-facets-route.test.ts
Normal file
128
lib/__tests__/materials-facets-route.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
35
lib/__tests__/performance-survey-image-error.test.ts
Normal file
35
lib/__tests__/performance-survey-image-error.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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-`)
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
57
lib/__tests__/product-package-status-route.test.ts
Normal file
57
lib/__tests__/product-package-status-route.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
82
lib/__tests__/product-package-task-no-lock.test.ts
Normal file
82
lib/__tests__/product-package-task-no-lock.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
307
lib/__tests__/task-detail-upload-jobs.test.ts
Normal file
307
lib/__tests__/task-detail-upload-jobs.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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[] = []
|
||||
|
||||
@ -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 },
|
||||
});
|
||||
|
||||
return user?.isSuperAdmin ?? false;
|
||||
}).then((user) => 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)) {
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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 张图片')
|
||||
|
||||
41
lib/performance-survey-image-error.ts
Normal file
41
lib/performance-survey-image-error.ts
Normal 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)
|
||||
}
|
||||
@ -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} 张`,
|
||||
)
|
||||
}
|
||||
|
||||
@ -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 : '浏览器图片处理失败',
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
@ -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;
|
||||
|
||||
38
lib/task-detail-image-browser.ts
Normal file
38
lib/task-detail-image-browser.ts
Normal 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)
|
||||
}
|
||||
552
lib/task-detail-upload-jobs.ts
Normal file
552
lib/task-detail-upload-jobs.ts
Normal 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
|
||||
},
|
||||
}
|
||||
77
lib/task-detail-upload-policy.ts
Normal file
77
lib/task-detail-upload-policy.ts
Normal 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
|
||||
}
|
||||
70
lib/task-detail-upload-status.ts
Normal file
70
lib/task-detail-upload-status.ts
Normal 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('后台处理超时,请稍后到素材详情查看处理结果')
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -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;
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user