465 lines
14 KiB
TypeScript
465 lines
14 KiB
TypeScript
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 { deleteFromS3, isS3Url } from '@/lib/s3'
|
||
|
||
function parseDisplayImages(value: string | null): string[] {
|
||
if (!value) return []
|
||
try {
|
||
const parsed = JSON.parse(value)
|
||
if (Array.isArray(parsed)) {
|
||
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||
}
|
||
} catch (error) {
|
||
console.warn('[materials] Failed to parse displayImages JSON, fallback to single value:', error)
|
||
}
|
||
return value ? [value] : []
|
||
}
|
||
|
||
const ALLOWED_DYNAMIC_FILTER_FIELDS = new Set([
|
||
'renderer',
|
||
'brandName',
|
||
'seriesName',
|
||
'skuCode',
|
||
'materialTexture',
|
||
'renderTime',
|
||
'imageResolution',
|
||
'colorMode',
|
||
'productModel',
|
||
'platformName',
|
||
'categoryName',
|
||
'sceneName',
|
||
'authorName',
|
||
])
|
||
|
||
// Get all materials with search and filter support
|
||
export async function GET(req: NextRequest) {
|
||
try {
|
||
const session = await getServerSession(authOptions)
|
||
const { searchParams } = new URL(req.url)
|
||
const search = searchParams.get('search') || ''
|
||
const categoryName = searchParams.get('category') || ''
|
||
const sceneName = searchParams.get('scene') || ''
|
||
const materialType = searchParams.get('materialType') || ''
|
||
const platformName = searchParams.get('platform') || ''
|
||
const month = searchParams.get('month') || ''
|
||
const templateId = searchParams.get('templateId') || ''
|
||
const sortBy = searchParams.get('sortBy') || 'createdAt' // createdAt | downloadCount | likeCount
|
||
const page = parseInt(searchParams.get('page') || '1')
|
||
const pageSize = parseInt(searchParams.get('pageSize') || '20')
|
||
const showDeleted = searchParams.get('showDeleted') || ''
|
||
const year = searchParams.get('year') || ''
|
||
const dynamicFieldFilters: Array<{ key: string; value: string }> = []
|
||
searchParams.forEach((value, key) => {
|
||
if (key.startsWith('homeField_') && value) {
|
||
const fieldKey = key.replace('homeField_', '')
|
||
if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(fieldKey)) {
|
||
dynamicFieldFilters.push({ key: fieldKey, value })
|
||
}
|
||
}
|
||
})
|
||
|
||
// Build where clause
|
||
const where: any = {}
|
||
const andConditions: any[] = []
|
||
|
||
// 根据showDeleted参数决定是否显示已删除的素材
|
||
// 注意:如果 deletedAt 字段不存在(迁移未完成),此查询可能会失败
|
||
// 需要先运行数据库迁移:docker exec flnxi-material npx prisma migrate deploy
|
||
if (showDeleted === 'true') {
|
||
// 只显示已删除的素材
|
||
andConditions.push({ deletedAt: { not: null } })
|
||
} else {
|
||
// 默认只查询未删除的素材
|
||
andConditions.push({ deletedAt: null })
|
||
}
|
||
|
||
// Add search condition (search in SPU, shop, scene category, author)
|
||
if (search) {
|
||
andConditions.push({
|
||
OR: [
|
||
{ productModel: { contains: search, mode: 'insensitive' } }, // SPU
|
||
{ brandName: { contains: search, mode: 'insensitive' } }, // 品牌
|
||
{ seriesName: { contains: search, mode: 'insensitive' } }, // 系列
|
||
{ skuCode: { contains: search, mode: 'insensitive' } }, // SKU
|
||
{ materialTexture: { contains: search, mode: 'insensitive' } }, // 材质
|
||
{ categoryName: { contains: search, mode: 'insensitive' } }, // 使用店铺
|
||
{ sceneName: { contains: search, mode: 'insensitive' } }, // 场景类别
|
||
{ authorName: { contains: search, mode: 'insensitive' } }, // 作者
|
||
]
|
||
})
|
||
}
|
||
|
||
// Add shop filter (使用店铺)
|
||
if (categoryName) {
|
||
andConditions.push({ categoryName })
|
||
}
|
||
|
||
// Add scene category filter (场景类别)
|
||
if (sceneName) {
|
||
andConditions.push({ sceneName })
|
||
}
|
||
|
||
// Add material type filter (素材类型)
|
||
if (materialType) {
|
||
andConditions.push({ materialType })
|
||
}
|
||
|
||
// Add platform filter (平台分类)
|
||
if (platformName) {
|
||
andConditions.push({ platformName })
|
||
}
|
||
|
||
// Add month filter (上传月份)
|
||
if (month) {
|
||
const [year, monthNum] = month.split('-')
|
||
const startDate = new Date(parseInt(year), parseInt(monthNum) - 1, 1)
|
||
const endDate = new Date(parseInt(year), parseInt(monthNum), 1)
|
||
andConditions.push({
|
||
createdAt: {
|
||
gte: startDate,
|
||
lt: endDate
|
||
}
|
||
})
|
||
} else if (year) {
|
||
const parsedYear = parseInt(year, 10)
|
||
if (!Number.isNaN(parsedYear)) {
|
||
andConditions.push({
|
||
createdAt: {
|
||
gte: new Date(parsedYear, 0, 1),
|
||
lt: new Date(parsedYear + 1, 0, 1),
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
// Add template filter (模板筛选)
|
||
if (templateId) {
|
||
// 如果是预制模板,需要根据模板名称匹配
|
||
if (templateId.startsWith('builtin-')) {
|
||
const templateKey = templateId.replace('builtin-', '')
|
||
// 预制模板通过materialType匹配(因为预制模板的materialType是固定的)
|
||
const materialTypeMap: Record<string, string> = {
|
||
'3dmax-material': '3DMAX',
|
||
'psd-material': 'PSD',
|
||
'ai-material': 'AI 工作流',
|
||
'video-material': '视频',
|
||
'model-material': '模特',
|
||
'other-material': '其他',
|
||
}
|
||
const mappedMaterialType = materialTypeMap[templateKey]
|
||
if (mappedMaterialType) {
|
||
// 筛选使用该materialType的素材,且templateId为null或builtin-xxx
|
||
andConditions.push({
|
||
AND: [
|
||
{ materialType: mappedMaterialType },
|
||
{
|
||
OR: [
|
||
{ templateId: null },
|
||
{ templateId: templateId },
|
||
]
|
||
}
|
||
]
|
||
})
|
||
}
|
||
} else {
|
||
// 自定义模板,直接使用templateId
|
||
andConditions.push({ templateId })
|
||
}
|
||
}
|
||
|
||
dynamicFieldFilters.forEach(({ key, value }) => {
|
||
andConditions.push({ [key]: value } as Record<string, string>)
|
||
})
|
||
|
||
// 合并所有条件
|
||
if (andConditions.length > 0) {
|
||
where.AND = andConditions
|
||
}
|
||
|
||
// 构建排序规则
|
||
let orderBy: any = { createdAt: 'desc' }
|
||
if (sortBy === 'downloadCount') {
|
||
orderBy = { downloadCount: 'desc' }
|
||
} else if (sortBy === 'likeCount') {
|
||
orderBy = { likeCount: 'desc' }
|
||
}
|
||
|
||
// 计算总数和分页数据(并行查询,优化性能)
|
||
const maxPageSize = Math.min(pageSize, 100) // 限制最大分页大小为 100
|
||
const [totalCount, materials] = await Promise.all([
|
||
prisma.material.count({ where }),
|
||
prisma.material.findMany({
|
||
where,
|
||
select: {
|
||
id: 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,
|
||
createdAt: true,
|
||
userId: true,
|
||
isPremium: true,
|
||
user: {
|
||
select: {
|
||
username: true,
|
||
avatar: true,
|
||
},
|
||
},
|
||
},
|
||
orderBy,
|
||
skip: (page - 1) * maxPageSize,
|
||
take: maxPageSize,
|
||
})
|
||
])
|
||
const totalPages = Math.ceil(totalCount / maxPageSize)
|
||
|
||
// 如果用户已登录,查询点赞状态(优化:只在有数据时查询)
|
||
let userLikes: Set<string> = new Set()
|
||
let userFavorites: Set<string> = new Set()
|
||
if (session && materials.length > 0) {
|
||
const userId = (session.user as any).id
|
||
const materialIds = materials.map(m => m.id)
|
||
const [likes, favorites] = await Promise.all([
|
||
prisma.materialLike.findMany({
|
||
where: {
|
||
userId,
|
||
materialId: { in: materialIds }
|
||
},
|
||
select: {
|
||
materialId: true
|
||
}
|
||
}),
|
||
prisma.materialFavorite.findMany({
|
||
where: {
|
||
userId,
|
||
materialId: { in: materialIds }
|
||
},
|
||
select: {
|
||
materialId: true
|
||
}
|
||
})
|
||
])
|
||
userLikes = new Set(likes.map(l => l.materialId))
|
||
userFavorites = new Set(favorites.map(f => f.materialId))
|
||
}
|
||
|
||
// 添加点赞状态到每个素材,并转换BigInt为字符串
|
||
const materialsWithLikeStatus = materials.map((material) => {
|
||
const rawKind = (material.materialKind || '').trim().toUpperCase()
|
||
const normalizedKind = rawKind || (material.materialType === '视频' ? 'VIDEO' : 'IMAGE')
|
||
|
||
return {
|
||
...material,
|
||
materialKind: normalizedKind,
|
||
displayImages: parseDisplayImages(material.displayImages || null),
|
||
zipFileSize: material.zipFileSize.toString(),
|
||
isLiked: userLikes.has(material.id),
|
||
isFavorited: userFavorites.has(material.id),
|
||
}
|
||
})
|
||
|
||
return NextResponse.json({
|
||
materials: materialsWithLikeStatus,
|
||
totalPages,
|
||
totalCount,
|
||
currentPage: page,
|
||
})
|
||
} catch (error) {
|
||
console.error('[Materials] GET error:', error)
|
||
return NextResponse.json(
|
||
{ error: '获取素材列表失败' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|
||
|
||
// Delete material (admin only) - 软删除
|
||
export async function DELETE(req: NextRequest) {
|
||
try {
|
||
const session = await getServerSession(authOptions)
|
||
|
||
if (!session || (session.user as any).role !== 'ADMIN') {
|
||
return NextResponse.json(
|
||
{ error: '无权限' },
|
||
{ status: 403 }
|
||
)
|
||
}
|
||
|
||
const { searchParams } = new URL(req.url)
|
||
const id = searchParams.get('id')
|
||
const permanent = searchParams.get('permanent') === 'true' // 永久删除
|
||
|
||
if (!id) {
|
||
return NextResponse.json(
|
||
{ error: '缺少素材ID' },
|
||
{ status: 400 }
|
||
)
|
||
}
|
||
|
||
// Get material to delete files
|
||
const material = await prisma.material.findUnique({
|
||
where: { id },
|
||
})
|
||
|
||
if (!material) {
|
||
return NextResponse.json(
|
||
{ error: '素材不存在' },
|
||
{ status: 404 }
|
||
)
|
||
}
|
||
|
||
// 获取用户ID用于日志记录
|
||
const userId = (session.user as any).id
|
||
|
||
if (permanent) {
|
||
// 永久删除前记录日志
|
||
await createMaterialLog({
|
||
materialId: id,
|
||
userId,
|
||
action: 'DELETE',
|
||
changes: {
|
||
permanent: {
|
||
oldValue: false,
|
||
newValue: true,
|
||
},
|
||
},
|
||
})
|
||
|
||
// 收集所有需要删除的文件URL
|
||
const filesToDelete: string[] = []
|
||
|
||
// 源文件(zipFilePath 或 videoSourceUrl)
|
||
if (material.zipFilePath) filesToDelete.push(material.zipFilePath)
|
||
if ((material as any).videoSourceUrl && (material as any).videoSourceUrl !== material.zipFilePath) {
|
||
filesToDelete.push((material as any).videoSourceUrl)
|
||
}
|
||
|
||
// 预览图
|
||
if (material.previewUrl) filesToDelete.push(material.previewUrl)
|
||
|
||
// 多预览图(JSON数组)
|
||
if (material.previewUrls) {
|
||
try {
|
||
const previewUrls = JSON.parse(material.previewUrls)
|
||
if (Array.isArray(previewUrls)) {
|
||
filesToDelete.push(...previewUrls.filter((url: string) => url && url.trim()))
|
||
}
|
||
} catch {
|
||
// 忽略解析错误
|
||
}
|
||
}
|
||
|
||
// 视频相关文件
|
||
if ((material as any).videoPreview540Url) filesToDelete.push((material as any).videoPreview540Url)
|
||
if ((material as any).videoPreviewClipUrl) filesToDelete.push((material as any).videoPreviewClipUrl)
|
||
if ((material as any).videoCoverUrl) filesToDelete.push((material as any).videoCoverUrl)
|
||
|
||
// 展示图片(JSON数组)
|
||
if (material.displayImages) {
|
||
try {
|
||
const displayImages = JSON.parse(material.displayImages)
|
||
if (Array.isArray(displayImages)) {
|
||
filesToDelete.push(...displayImages.filter((url: string) => url && url.trim()))
|
||
}
|
||
} catch {
|
||
// 忽略解析错误
|
||
}
|
||
}
|
||
|
||
// 去重并过滤空值
|
||
const uniqueFiles = Array.from(new Set(filesToDelete.filter((url) => url && url.trim())))
|
||
|
||
// 删除所有文件(S3或本地)
|
||
let deletedFiles = 0
|
||
let failedFiles = 0
|
||
|
||
for (const fileUrl of uniqueFiles) {
|
||
try {
|
||
if (isS3Url(fileUrl)) {
|
||
await deleteFromS3(fileUrl)
|
||
deletedFiles++
|
||
console.log(`[Delete] Deleted S3 file: ${fileUrl}`)
|
||
} else {
|
||
// 本地文件(如果存在)
|
||
const { unlink } = await import('fs/promises')
|
||
try {
|
||
await unlink(fileUrl)
|
||
deletedFiles++
|
||
console.log(`[Delete] Deleted local file: ${fileUrl}`)
|
||
} catch (fileError: any) {
|
||
if (fileError.code !== 'ENOENT') {
|
||
console.error(`[Delete] Failed to delete local file: ${fileUrl}`, fileError)
|
||
failedFiles++
|
||
}
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`[Delete] Failed to delete file: ${fileUrl}`, error)
|
||
failedFiles++
|
||
}
|
||
}
|
||
|
||
console.log(`[Delete] Material ${id} files cleanup: ${deletedFiles} deleted, ${failedFiles} failed`)
|
||
|
||
// 永久删除数据库记录
|
||
await prisma.material.delete({
|
||
where: { id },
|
||
})
|
||
} else {
|
||
// 软删除:设置 deletedAt
|
||
await prisma.material.update({
|
||
where: { id },
|
||
data: { deletedAt: new Date() },
|
||
})
|
||
|
||
// 记录软删除日志
|
||
await createMaterialLog({
|
||
materialId: id,
|
||
userId,
|
||
action: 'DELETE',
|
||
changes: {
|
||
permanent: {
|
||
oldValue: false,
|
||
newValue: false,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
return NextResponse.json({ message: '删除成功' })
|
||
} catch (error) {
|
||
console.error('[Materials] DELETE error:', error)
|
||
return NextResponse.json(
|
||
{ error: '删除失败' },
|
||
{ status: 500 }
|
||
)
|
||
}
|
||
}
|
||
|
||
|