397 lines
13 KiB
TypeScript
397 lines
13 KiB
TypeScript
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'
|
||
import {
|
||
getDingtalkApprovalNoError,
|
||
normalizeDingtalkApprovalNo,
|
||
} from '@/lib/dingtalk-approval-no'
|
||
|
||
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
|
||
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 }
|
||
}
|
||
|
||
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 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 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 && !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 (dingtalkApprovalNo && !materialType.startsWith('任务-')) {
|
||
return {
|
||
error: '钉钉审批编号仅适用于“任务-”素材',
|
||
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) ? 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,
|
||
dingtalkApprovalNo: materialType.startsWith('任务-')
|
||
? (hasDingtalkApprovalNoField ? dingtalkApprovalNo : material.dingtalkApprovalNo)
|
||
: null,
|
||
taskDetailSourceType:
|
||
materialType === '任务-详情'
|
||
? (hasTaskDetailSourceTypeField ? taskDetailSourceType : material.taskDetailSourceType ?? null)
|
||
: null,
|
||
taskDetailRequesterUserId: nextTaskDetailRequesterUserId,
|
||
taskDetailRequesterName: nextTaskDetailRequesterName,
|
||
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,
|
||
}
|
||
}
|