import { randomUUID } from 'crypto' import { prisma } from '@/lib/prisma' import { createMaterialLog } from '@/lib/material-log' import { compressImage, sanitizeFileName } from '@/lib/image-processor' import { deleteFileIfUnreferenced } from '@/lib/file-cleanup' import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-process-lock' 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 { canManageMaterial } from '@/lib/material-authorization' 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' import { getDingtalkApprovalNoError, normalizeDingtalkApprovalNo, } from '@/lib/dingtalk-approval-no' import { TRANSFER_REASON, buildMaterialTransferFields, recordMaterialTransfer, } from '@/lib/material-transfer' 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 /** 更换归属人时的转移理由,留空则记为默认的编辑转移 */ transferNote?: string | null taskNo?: string | null dingtalkApprovalNo?: string | null taskDetailSourceType?: string | null taskDetailRequesterUserId?: 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 } } // 统一走 canManageMaterial,与资产、SKU、白底图等写操作保持同一套判定, // 托管给部门的素材由此自动对该部门成员开放维护权限。 const canManage = await canManageMaterial( { id: operatorUserId, role: operatorRole }, material, ) if (!canManage) { 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()}-${randomUUID()}-${sanitizedName}` const nextPreviewUrl = await uploadPublicDisplayToS3( compressedBuffer, buildMaterialPublicDisplayPath(fileName), 'image/webp', ) return nextPreviewUrl } async function updateMaterialWithSharedRulesLocked(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 hasReportTitleField = Object.prototype.hasOwnProperty.call(input, 'reportTitle') const hasTaskNoField = Object.prototype.hasOwnProperty.call(input, 'taskNo') const hasDingtalkApprovalNoField = Object.prototype.hasOwnProperty.call(input, 'dingtalkApprovalNo') const hasTaskDetailSourceTypeField = Object.prototype.hasOwnProperty.call(input, 'taskDetailSourceType') const hasTaskDetailRequesterField = Object.prototype.hasOwnProperty.call(input, 'taskDetailRequesterUserId') const productModel = normalizeString(input.productModel) const reportTitle = normalizeReportTitle(input.reportTitle) const effectiveReportTitle = hasReportTitleField ? reportTitle : normalizeReportTitle(material.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 dingtalkApprovalNo = normalizeDingtalkApprovalNo(input.dingtalkApprovalNo) const rawTaskDetailSourceType = input.taskDetailSourceType const taskDetailSourceType = normalizeTaskDetailSourceType(rawTaskDetailSourceType) const requestedTaskDetailRequesterUserId = normalizeOptionalString(input.taskDetailRequesterUserId) if (!materialType || !authorName) { return { error: '缺少必填字段', status: 400 as const } } if (isCertificateReportMaterialType(materialType)) { if (!productModel && !effectiveReportTitle) { 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, } } const dingtalkApprovalNoError = getDingtalkApprovalNoError(dingtalkApprovalNo) if (dingtalkApprovalNoError) { return { error: dingtalkApprovalNoError, 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 } } } const currentTaskDetailRequesterUserId = normalizeOptionalString(material.taskDetailRequesterUserId) const currentTaskDetailRequesterName = normalizeOptionalString(material.taskDetailRequesterName) let nextTaskDetailRequesterUserId = currentTaskDetailRequesterUserId let nextTaskDetailRequesterName = currentTaskDetailRequesterName if (materialType !== '任务-详情') { nextTaskDetailRequesterUserId = null nextTaskDetailRequesterName = null } else if (!hasTaskDetailRequesterField) { if (material.materialType !== '任务-详情') { return { error: '请选择需求运营人员', status: 400 as const } } } else if (!requestedTaskDetailRequesterUserId) { if (material.materialType !== '任务-详情') { return { error: '请选择需求运营人员', status: 400 as const } } if (currentTaskDetailRequesterUserId) { return { error: '需求运营人员不能清空,只能更换', status: 400 as const } } // 历史任务详情没有运营字段时允许继续留空;若原人员已删除,则保留姓名快照。 nextTaskDetailRequesterUserId = null nextTaskDetailRequesterName = currentTaskDetailRequesterName } else if (requestedTaskDetailRequesterUserId !== currentTaskDetailRequesterUserId) { const requester = await prisma.user.findFirst({ where: { id: requestedTaskDetailRequesterUserId, isActive: true, status: 'active', }, select: { id: true, username: true, realName: true, }, }) if (!requester) { return { error: '所选需求运营人员不存在或已停用', status: 400 as const } } nextTaskDetailRequesterUserId = requester.id nextTaskDetailRequesterName = requester.realName || requester.username } 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) ? effectiveReportTitle : 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, dingtalkApprovalNo: hasDingtalkApprovalNoField ? dingtalkApprovalNo : material.dingtalkApprovalNo, taskDetailSourceType: materialType === '任务-详情' ? (hasTaskDetailSourceTypeField ? taskDetailSourceType : material.taskDetailSourceType ?? null) : null, taskDetailRequesterUserId: nextTaskDetailRequesterUserId, taskDetailRequesterName: nextTaskDetailRequesterName, isFullSet: supportsIsFullSet(materialType) ? deriveTaskDetailIsFullSet(materialType, material.assets, Boolean(material.isFullSet)) : false, } // 更换归属人按正式转移处理:在素材上落下最初上传者与本次转移信息, // 让"改作者"这个动作不再是一次无据可查的静默覆盖。 const isOwnershipTransfer = Boolean(nextUserId && nextUserId !== material.userId) const transferNote = normalizeOptionalString(input.transferNote) || TRANSFER_REASON.EDIT if (isOwnershipTransfer && nextUserId) { updateData.userId = nextUserId Object.assign( updateData, buildMaterialTransferFields({ currentOwnerId: material.userId, existingOriginalOwnerId: material.originalOwnerId, operatorUserId, note: transferNote, }), ) } const uploadedPreviewUrl = previewImage && previewImage.size > 0 && previewUrl !== material.previewUrl ? previewUrl : null let updatedMaterial: any try { updatedMaterial = await prisma.material.update({ where: { id: materialId }, data: updateData as any, }) } catch (error) { if (!uploadedPreviewUrl) throw error // 数据库可能已经提交,只是连接在返回确认前中断。只有明确读到旧值时才清理 // 本次新对象;读取失败时偏向保留,绝不删除数据库可能已经引用的图片。 let observed: any = null try { observed = await prisma.material.findUnique({ where: { id: materialId } }) } catch (verificationError) { console.error('[material-update] 更新结果无法确认,保留新预览图等待对账:', verificationError) throw error } if (observed?.previewUrl === uploadedPreviewUrl) { updatedMaterial = observed } else { await deleteFileIfUnreferenced(uploadedPreviewUrl, { excludeMaterialIds: [materialId], }).catch((cleanupError) => { console.error('[material-update] 清理未提交的新预览图失败:', cleanupError) }) throw error } } if (uploadedPreviewUrl && material.previewUrl && material.previewUrl !== uploadedPreviewUrl) { await deleteFileIfUnreferenced(material.previewUrl, { excludeMaterialIds: [materialId], }).catch((error) => { console.error('[material-update] 数据库提交后清理旧预览图失败,已保留数据库新值:', error) }) } if (isOwnershipTransfer && nextUserId) { // 归属变更已随素材更新一并落库。流水写入失败不回滚归属本身——素材上的 // originalOwnerId / transferredAt / transferredBy / transferNote 已完整记下了 // 这次转移的事实,缺的只是流水表里的一行,可事后补录。 await recordMaterialTransfer(prisma, { materialId, fromUserId: material.userId, toUserId: nextUserId, operatorUserId, note: transferNote, }).catch((error) => { console.error('[material-update] 素材转移流水写入失败,归属变更已生效:', error) }) } 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, } } export async function updateMaterialWithSharedRules(options: UpdateMaterialOptions) { const release = await acquireTaskMaterialCompletionLock(options.materialId) try { release.assertHeld() const result = await updateMaterialWithSharedRulesLocked(options) release.assertHeld() return result } finally { await release() } }