476 lines
16 KiB
TypeScript
476 lines
16 KiB
TypeScript
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<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 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<string, any> = {
|
||
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()
|
||
}
|
||
}
|