WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

465 lines
14 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 { 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 }
)
}
}