import { prisma } from '@/lib/prisma' import { createMaterialLog } from '@/lib/material-log' import { deleteFromS3 } from '@/lib/s3' import { compressImage, sanitizeFileName } from '@/lib/image-processor' import { buildMaterialPublicDisplayPath, uploadPublicDisplayToS3, } from '@/lib/public-display' import { getDefaultRenderer, requiresCategoryAndScene, requiresProductCategoryName, requiresRenderer, shouldAutoFillGeneral, } from '@/lib/material-validation' import { validateTaskNo } from '@/lib/product-package' import { canManuallyReplacePreview, supportsIsFullSet, } from '@/lib/material-type-options' import { isSuperAdmin, isSystemAdmin } from '@/lib/department-permission' import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set' import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing' import { normalizeTaskDetailSourceType } from '@/lib/task-detail-source-type' import { isCertificateReportMaterialType, normalizeReportTitle } from '@/lib/certificate-report' export interface MaterialUpdateInput { productModel: string reportTitle?: string | null categoryName?: string | null sceneName?: string | null platformName?: string | null productCategoryName?: string | null materialType: string renderer?: string | null authorName: string userId?: string | null taskNo?: string | null taskDetailSourceType?: string | null // 兼容旧调用方传参,实际保存时会按资产自动计算 isFullSet?: boolean | string | null } interface UpdateMaterialOptions { materialId: string operatorUserId: string operatorRole?: string | null input: MaterialUpdateInput previewImage?: File | null } function normalizeString(value: string | null | undefined): string { return typeof value === 'string' ? value.trim() : '' } function normalizeOptionalString(value: string | null | undefined): string | null { const normalized = normalizeString(value) return normalized || null } function normalizeTaskNo(value: string | null | undefined): string | null { const normalized = normalizeOptionalString(value) return normalized ? normalized.toUpperCase() : null } function buildMaterialChanges(oldMaterial: any, newData: Record) { const changes: Record = {} const normalize = (value: any) => { if (value instanceof Date) { return value.toISOString() } if (value === undefined) { return null } return value } for (const [key, value] of Object.entries(newData)) { if (key === 'updatedAt') continue const oldValue = normalize(oldMaterial[key]) const newValue = normalize(value) if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) { changes[key] = { oldValue, newValue } } } return changes } async function ensureEditableMaterial( materialId: string, operatorUserId: string, operatorRole?: string | null, ) { const material = await prisma.material.findFirst({ where: { id: materialId, deletedAt: null }, include: { assets: { select: { assetType: true, }, }, }, }) if (!material) { return { error: '素材不存在或已被删除', status: 404 as const } } const hasSuperAdminPermission = await isSuperAdmin(operatorUserId) const hasSystemAdminRole = isSystemAdmin(operatorRole) if (material.userId !== operatorUserId && !hasSystemAdminRole && !hasSuperAdminPermission) { return { error: '您没有权限编辑此素材', status: 403 as const } } return { material } } async function resolvePreviewUrl( previewImage: File | null | undefined, materialType: string, currentPreviewUrl: string, ) { if (!previewImage || previewImage.size <= 0) { return currentPreviewUrl } if (!canManuallyReplacePreview(materialType)) { throw new Error('当前素材类型不支持手工替换预览图') } const imageBuffer = Buffer.from(await previewImage.arrayBuffer()) const compressedBuffer = await compressImage(imageBuffer, { maxSizeKB: 300, maxWidth: 600, format: 'webp', }) const sanitizedName = sanitizeFileName(previewImage.name) const fileName = `preview-${Date.now()}-${sanitizedName}` const nextPreviewUrl = await uploadPublicDisplayToS3( compressedBuffer, buildMaterialPublicDisplayPath(fileName), 'image/webp', ) if (currentPreviewUrl) { try { await deleteFromS3(currentPreviewUrl) } catch (error) { console.error('[material-update] 删除旧预览图失败:', error) } } return nextPreviewUrl } export async function updateMaterialWithSharedRules(options: UpdateMaterialOptions) { const { materialId, operatorUserId, operatorRole, input, previewImage } = options const editable = await ensureEditableMaterial(materialId, operatorUserId, operatorRole) if ('error' in editable) { return editable } const material = editable.material as any const hasTaskNoField = Object.prototype.hasOwnProperty.call(input, 'taskNo') const hasTaskDetailSourceTypeField = Object.prototype.hasOwnProperty.call(input, 'taskDetailSourceType') const productModel = normalizeString(input.productModel) const reportTitle = normalizeReportTitle(input.reportTitle) const materialType = normalizeString(input.materialType) const authorName = normalizeString(input.authorName) const shouldUseGeneralCategoryScene = shouldAutoFillGeneral(materialType) const categoryName = normalizeOptionalString(input.categoryName) const sceneName = normalizeOptionalString(input.sceneName) const platformName = normalizeOptionalString(input.platformName) const productCategoryName = normalizeOptionalString(input.productCategoryName) const taskNo = normalizeTaskNo(input.taskNo) const rawTaskDetailSourceType = input.taskDetailSourceType const taskDetailSourceType = normalizeTaskDetailSourceType(rawTaskDetailSourceType) if (!materialType || !authorName) { return { error: '缺少必填字段', status: 400 as const } } if (isCertificateReportMaterialType(materialType)) { if (!productModel && !reportTitle) { return { error: 'SPU 和 报告名称/检测对象 至少填写一个', status: 400 as const } } } else if (!productModel) { return { error: '缺少必填字段', status: 400 as const } } if (requiresCategoryAndScene(materialType) && (!categoryName || !sceneName)) { return { error: '请填写所有必填字段(店铺、场景)', status: 400 as const } } if (requiresProductCategoryName(materialType) && !productCategoryName) { return { error: '标准模型类型必须选择品类', status: 400 as const } } if (requiresRenderer(materialType) && !normalizeOptionalString(input.renderer)) { return { error: '3D场景类型必须指定渲染器', status: 400 as const } } if (taskNo && !validateTaskNo(taskNo)) { return { error: '任务号格式错误,应为 TASK + 12-18位数字,可选的多个子任务号(-2位数字,如 TASK202601141713525624-01-01)', status: 400 as const, } } if ( hasTaskDetailSourceTypeField && rawTaskDetailSourceType != null && normalizeString(rawTaskDetailSourceType) && !taskDetailSourceType ) { return { error: '出图方式只能是 实拍、渲染、AI 或留空', status: 400 as const, } } if (taskNo) { const existingTaskNo = await prisma.material.findFirst({ where: { taskNo, deletedAt: null, NOT: { id: materialId }, }, select: { id: true, }, }) if (existingTaskNo) { return { error: '任务号已存在', status: 409 as const } } } const nextUserId = normalizeOptionalString(input.userId) if (nextUserId && nextUserId !== material.userId) { const nextUser = await prisma.user.findUnique({ where: { id: nextUserId }, select: { id: true }, }) if (!nextUser) { return { error: '指定的用户不存在', status: 400 as const } } } let previewUrl = material.previewUrl try { previewUrl = await resolvePreviewUrl(previewImage, materialType, material.previewUrl) } catch (error) { return { error: error instanceof Error ? error.message : '预览图更新失败', status: 400 as const, } } const updateData: Record = { previewUrl, productModel, reportTitle: isCertificateReportMaterialType(materialType) ? reportTitle : null, categoryName: shouldUseGeneralCategoryScene ? '通用' : (categoryName ?? material.categoryName), sceneName: shouldUseGeneralCategoryScene ? '通用' : (sceneName ?? material.sceneName), platformName: platformName ?? '', productCategoryName: productCategoryName ?? '', materialType, authorName, renderer: requiresRenderer(materialType) ? normalizeOptionalString(input.renderer) || material.renderer || getDefaultRenderer(materialType) : material.renderer, taskNo: hasTaskNoField ? taskNo : material.taskNo, taskDetailSourceType: materialType === '任务-详情' ? (hasTaskDetailSourceTypeField ? taskDetailSourceType : material.taskDetailSourceType ?? null) : null, isFullSet: supportsIsFullSet(materialType) ? deriveTaskDetailIsFullSet(materialType, material.assets, Boolean(material.isFullSet)) : false, } if (nextUserId && nextUserId !== material.userId) { updateData.userId = nextUserId } const updatedMaterial = await prisma.material.update({ where: { id: materialId }, data: updateData as any, }) const changes = buildMaterialChanges(material, updateData) if (Object.keys(changes).length > 0) { await createMaterialLog({ materialId, userId: operatorUserId, action: 'UPDATE', changes, }) } if (!Boolean(material.isFullSet) && Boolean((updatedMaterial as any).isFullSet)) { void queueTaskDetailFullSetNotification(materialId) } return { material: updatedMaterial, changes, } }