gongxiangp2/lib/material-update.ts
2026-05-07 16:37:22 +08:00

321 lines
9.8 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 { 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<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 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<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,
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,
}
}