2026-06-01 11:23:44 +08:00

419 lines
12 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 { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { createMaterialLog } from '@/lib/material-log'
import {
checkMetadataSize,
compressMetadata,
parseMetadata,
prepareMaterialData,
readAllFieldValues,
} from '@/lib/field-data-storage'
import { validateSubmittedTemplateFields } from '@/lib/template-field-validation'
import { canManageMaterial, getMaterialPermissionActor } from '@/lib/material-permissions'
// GET /api/materials/[id] - 获取单个素材详情
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
}
const actor = await getMaterialPermissionActor(session)
if (!actor) {
return NextResponse.json(
{ error: '账号不可用,请重新登录或联系管理员' },
{ status: 403 }
)
}
const material = await prisma.material.findFirst({
where: {
id: params.id,
deletedAt: null,
},
select: {
id: true,
userId: true,
productModel: true,
materialKind: true,
brandName: true,
seriesName: true,
skuCode: true,
materialTexture: true,
categoryName: true,
sceneName: true,
platformName: true,
materialType: true,
renderer: true,
renderTime: true,
imageResolution: true,
colorMode: true,
authorName: true,
previewUrl: true,
videoCoverUrl: true,
videoPreview540Url: true,
videoPreviewClipUrl: true,
videoProcessingStatus: true,
videoDuration: true,
zipFileName: true,
zipFileSize: true,
downloadCount: true,
likeCount: true,
displayImages: true,
metadata: true,
createdAt: true,
templateId: true,
// 音频相关字段
// @ts-ignore
audioDuration: true,
// @ts-ignore
audioCategory: true,
// @ts-ignore
audioScene: true,
// @ts-ignore
audioUrl: true,
user: {
select: {
id: true,
username: true,
avatar: true,
}
}
}
})
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
const parsedMetadata = parseMetadata(material.metadata)
// 返回素材信息(转换BigInt为字符串)
return NextResponse.json({
id: material.id,
userId: material.userId,
productModel: material.productModel,
brandName: material.brandName,
seriesName: material.seriesName,
skuCode: material.skuCode,
materialTexture: material.materialTexture,
categoryName: material.categoryName,
sceneName: material.sceneName,
platformName: material.platformName,
materialType: material.materialType,
renderer: material.renderer,
renderTime: material.renderTime,
imageResolution: material.imageResolution,
colorMode: material.colorMode,
authorName: material.authorName,
previewUrl: material.previewUrl,
materialKind:
(material.materialKind || '').trim().toUpperCase() ||
(material.materialType === '视频' ? 'VIDEO' : material.materialType === '音频' ? 'AUDIO' : 'IMAGE'),
videoCoverUrl: material.videoCoverUrl,
videoPreview540Url: material.videoPreview540Url,
videoPreviewClipUrl: material.videoPreviewClipUrl,
videoProcessingStatus: material.videoProcessingStatus,
videoDuration: material.videoDuration,
zipFileName: material.zipFileName,
zipFileSize: material.zipFileSize.toString(),
downloadCount: material.downloadCount,
likeCount: material.likeCount,
displayImages: material.displayImages,
createdAt: material.createdAt,
templateId: material.templateId,
templateFields: parsedMetadata.templateFields,
// 音频相关字段
audioDuration: (material as any).audioDuration,
audioCategory: (material as any).audioCategory,
audioScene: (material as any).audioScene,
audioUrl: (material as any).audioUrl,
user: {
username: material.user.username,
avatar: material.user.avatar
}
})
} catch (error: any) {
console.error('[Materials] GET [id] error:', error)
return NextResponse.json(
{ error: '获取素材失败' },
{ status: 500 }
)
}
}
// PUT /api/materials/[id] - 更新素材信息
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json(
{ error: '请先登录' },
{ status: 401 }
)
}
const body = await request.json()
const {
productModel,
categoryName,
sceneName,
materialType,
renderer,
authorName,
brandName,
seriesName,
skuCode,
materialTexture,
renderTime,
imageResolution,
colorMode,
templateId,
templateFields = {},
} = body
if (
templateFields === null ||
typeof templateFields !== 'object' ||
Array.isArray(templateFields)
) {
return NextResponse.json(
{ error: '模板字段格式无效', errorCode: 'INVALID_TEMPLATE_FIELDS' },
{ status: 400 }
)
}
// 验证必填字段
if (!productModel || !categoryName || !sceneName || !materialType || !renderer || !authorName) {
return NextResponse.json(
{ error: '缺少必填字段' },
{ status: 400 }
)
}
// 获取素材信息
const material = await prisma.material.findFirst({
where: {
id: params.id,
deletedAt: null,
},
include: {
user: {
select: {
id: true,
department: true,
},
},
},
})
if (!material) {
return NextResponse.json(
{ error: '素材不存在' },
{ status: 404 }
)
}
// 权限检查:本人、最高权限账号,或管理部门范围内的管理员可以编辑
const actor = await getMaterialPermissionActor(session)
if (!(await canManageMaterial(material, actor))) {
return NextResponse.json(
{ error: '您没有权限编辑此素材' },
{ status: 403 }
)
}
// 记录修改前的值,用于日志
const oldValues: Record<string, any> = {
productModel: material.productModel,
categoryName: material.categoryName,
sceneName: material.sceneName,
materialType: material.materialType,
renderer: material.renderer,
authorName: material.authorName,
brandName: material.brandName,
seriesName: material.seriesName,
skuCode: material.skuCode,
materialTexture: material.materialTexture,
renderTime: material.renderTime,
imageResolution: material.imageResolution,
colorMode: material.colorMode,
}
// 更新素材信息
const updateData: any = {
productModel,
categoryName,
sceneName,
materialType,
renderer,
authorName,
updatedAt: new Date()
}
const assignOptional = (key: string, value: any) => {
if (value !== undefined) {
updateData[key] = value === null || value === '' ? null : value
}
}
assignOptional('brandName', brandName)
assignOptional('seriesName', seriesName)
assignOptional('skuCode', skuCode)
assignOptional('materialTexture', materialTexture)
assignOptional('renderTime', renderTime)
assignOptional('imageResolution', imageResolution)
assignOptional('colorMode', colorMode)
const effectiveTemplateId = templateId ?? material.templateId ?? null
if (effectiveTemplateId) {
const mergedTemplateFields = {
...readAllFieldValues(material as Record<string, unknown>),
...updateData,
...templateFields,
}
const templateFieldsValidation = await validateSubmittedTemplateFields({
templateId: effectiveTemplateId,
templateFields: mergedTemplateFields,
allowUnknownKeys: true,
})
if (!templateFieldsValidation.valid) {
return NextResponse.json(
{
error: templateFieldsValidation.error,
errorCode: templateFieldsValidation.errorCode,
details: templateFieldsValidation.details,
},
{ status: 400 }
)
}
const { metadata: rawMetadata } = prepareMaterialData(
templateFieldsValidation.templateFields as Record<string, any>,
effectiveTemplateId,
undefined,
material.metadata
)
const metadata = compressMetadata(rawMetadata)
const metadataSize = checkMetadataSize(metadata)
if (!metadataSize.ok) {
return NextResponse.json(
{
error: `自定义字段数据过大,当前约 ${metadataSize.size} 字节,最大允许 ${metadataSize.maxSize} 字节。请减少字段内容后重试。`,
errorCode: 'METADATA_TOO_LARGE',
},
{ status: 400 }
)
}
updateData.templateId = effectiveTemplateId
updateData.metadata = metadata
}
const updatedMaterial = await prisma.material.update({
where: { id: params.id },
data: updateData
})
// 记录修改详情到日志(用于监控仪表板)
const changes: string[] = []
const fieldNames: Record<string, string> = {
productModel: '产品型号',
categoryName: '一级分类',
sceneName: '二级分类',
materialType: '素材类型',
renderer: '渲染器',
authorName: '作者花名',
brandName: '品牌名称',
seriesName: '系列/产品线',
skuCode: 'SKU编码',
materialTexture: '材质/工艺',
renderTime: '渲染耗时',
imageResolution: '输出分辨率',
colorMode: '颜色模式',
}
Object.keys(updateData).forEach((key) => {
if (key === 'updatedAt') return
const oldVal = oldValues[key] ?? null
const newVal = updateData[key] ?? null
if (String(oldVal) !== String(newVal)) {
const fieldName = fieldNames[key] || key
changes.push(`${fieldName}: "${oldVal ?? '(空)'}" → "${newVal ?? '(空)'}"`)
}
})
if (changes.length > 0) {
console.log(`[MaterialUpdate] Material ${params.id} updated by ${actor!.id}:`, changes.join('; '))
// 记录详细修改日志到数据库
const changesObj: Record<string, { oldValue: any; newValue: any }> = {}
Object.keys(updateData).forEach((key) => {
if (key === 'updatedAt') return
const oldVal = oldValues[key] ?? null
const newVal = updateData[key] ?? null
if (String(oldVal) !== String(newVal)) {
changesObj[key] = {
oldValue: oldVal ?? null,
newValue: newVal ?? null,
}
}
})
if (Object.keys(changesObj).length > 0) {
await createMaterialLog({
materialId: params.id,
userId: actor!.id,
action: 'UPDATE',
changes: changesObj,
})
}
}
return NextResponse.json({
success: true,
message: '素材信息更新成功',
material: {
id: updatedMaterial.id,
productModel: updatedMaterial.productModel,
brandName: updatedMaterial.brandName,
seriesName: updatedMaterial.seriesName,
skuCode: updatedMaterial.skuCode,
materialTexture: updatedMaterial.materialTexture,
categoryName: updatedMaterial.categoryName,
sceneName: updatedMaterial.sceneName,
materialType: updatedMaterial.materialType,
renderer: updatedMaterial.renderer,
renderTime: updatedMaterial.renderTime,
imageResolution: updatedMaterial.imageResolution,
colorMode: updatedMaterial.colorMode,
authorName: updatedMaterial.authorName
}
})
} catch (error: any) {
console.error('[Materials] PUT [id] error:', error)
return NextResponse.json(
{ error: '更新素材失败' },
{ status: 500 }
)
}
}