gongxiangp2/lib/material-update.ts

537 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { CERTIFICATE_REPORT_KIND, isCertificateReportMaterialType, normalizeReportTitle, validateCertificateReportMetadata } from '@/lib/certificate-report'
import { isLiveClipType, parseLiveDate, validateLiveClipFields } from '@/lib/live-clip'
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
reportKind?: string | null
certificateTestedOn?: string | null
certificateNumber?: string | null
certificateExpiresOn?: string | null
reportUsageRules?: 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
liveDate?: string | null
liveHost?: string | null
liveHostUserId?: string | null
livePlatform?: 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<string, any>) {
const changes: Record<string, { oldValue: any; newValue: any }> = {}
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 hasReportKindField = Object.prototype.hasOwnProperty.call(input, 'reportKind')
const hasCertificateTestedOnField = Object.prototype.hasOwnProperty.call(input, 'certificateTestedOn')
const hasCertificateNumberField = Object.prototype.hasOwnProperty.call(input, 'certificateNumber')
const hasCertificateExpiresOnField = Object.prototype.hasOwnProperty.call(input, 'certificateExpiresOn')
const hasReportUsageRulesField = Object.prototype.hasOwnProperty.call(input, 'reportUsageRules')
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 }
}
let certificateReportMetadata: ReturnType<typeof validateCertificateReportMetadata> | null = null
if (isCertificateReportMaterialType(materialType)) {
if (!productCategoryName) {
return { error: '请选择品类', status: 400 as const }
}
certificateReportMetadata = validateCertificateReportMetadata({
reportKind: hasReportKindField ? input.reportKind : material.reportKind,
productModel,
reportTitle: effectiveReportTitle,
certificateTestedOn: hasCertificateTestedOnField
? input.certificateTestedOn
: material.certificateTestedOn?.toISOString().slice(0, 10),
certificateNumber: hasCertificateNumberField ? input.certificateNumber : material.certificateNumber,
certificateExpiresOn: hasCertificateExpiresOnField
? input.certificateExpiresOn
: material.certificateExpiresOn?.toISOString().slice(0, 10),
reportUsageRules: hasReportUsageRulesField ? input.reportUsageRules : material.reportUsageRules,
platformName,
}, isCertificateReportMaterialType(material.materialType) ? material.platformName : '')
if ('error' in certificateReportMetadata) {
return { error: certificateReportMetadata.error, 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)
if (material.taskDetailPrivate && materialType !== '任务-详情') {
return { error: '私密优化素材不能改为其他素材类型', status: 400 as const }
}
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<string, any> = {
previewUrl,
productModel: isCertificateReportMaterialType(materialType)
&& certificateReportMetadata
&& 'value' in certificateReportMetadata
&& certificateReportMetadata.value.reportKind === CERTIFICATE_REPORT_KIND.RAW
? ''
: productModel,
reportTitle: isCertificateReportMaterialType(materialType) ? effectiveReportTitle : null,
reportKind: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.reportKind : null,
certificateTestedOn: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.certificateTestedOn : null,
certificateNumber: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.certificateNumber : null,
certificateExpiresOn: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.certificateExpiresOn : null,
reportUsageRules: certificateReportMetadata && 'value' in certificateReportMetadata ? certificateReportMetadata.value.reportUsageRules : 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,
}
if (isLiveClipType(materialType)) {
const liveClipErrors = validateLiveClipFields({
title: productModel,
liveDate: input.liveDate,
livePlatform: input.livePlatform,
})
if (liveClipErrors.length > 0) {
return { error: liveClipErrors.map((e) => e.message).join(';'), status: 400 as const }
}
updateData.liveDate = parseLiveDate(input.liveDate) || material.liveDate || null
updateData.liveHost = normalizeOptionalString(input.liveHost)
updateData.liveHostUserId = normalizeOptionalString(input.liveHostUserId)
updateData.livePlatform = normalizeOptionalString(input.livePlatform)
}
// 更换归属人按正式转移处理:在素材上落下最初上传者与本次转移信息,
// 让"改作者"这个动作不再是一次无据可查的静默覆盖。
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()
}
}