import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { Prisma } from '@prisma/client' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite' import { createMaterialLog } from '@/lib/material-log' import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments' import { isStandardField } from '@/lib/field-data-storage' import { collectMaterialStorageUrls, deleteMaterialStorageFiles, } from '@/lib/material-storage-cleanup' 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', ]) function buildMaterialSnapshot(material: { productModel?: string | null categoryName?: string | null sceneName?: string | null materialType?: string | null authorName?: string | null }) { return { productModel: material.productModel || null, categoryName: material.categoryName || null, sceneName: material.sceneName || null, materialType: material.materialType || null, authorName: material.authorName || null, } } function metadataTemplateFieldsJson() { return Prisma.sql`COALESCE( ( CASE WHEN "metadata" IS NOT NULL AND "metadata" ~ '^\\s*\\{' THEN "metadata"::jsonb ELSE '{}'::jsonb END ) -> 'templateFields', '{}'::jsonb )` } async function getSearchableCustomFieldKeys(): Promise { const fields = await prisma.uploadField.findMany({ where: { isSearchable: true, }, select: { key: true, }, }) return Array.from( new Set(fields.map((field) => field.key).filter((key) => !isStandardField(key))) ) } async function getEnabledCustomHomeFilterKeys(fieldKeys: string[]): Promise> { const customKeys = fieldKeys.filter((key) => !ALLOWED_DYNAMIC_FILTER_FIELDS.has(key)) if (customKeys.length === 0) { return new Set() } const enabledFields = await prisma.fieldDefinition.findMany({ where: { key: { in: customKeys }, isFilterEnabled: true, }, select: { key: true, }, }) return new Set(enabledFields.map((field) => field.key)) } async function findMaterialIdsByCustomFieldSearch(search: string): Promise { const fieldKeys = await getSearchableCustomFieldKeys() if (fieldKeys.length === 0) { return [] } const rows = await prisma.$queryRaw>(Prisma.sql` SELECT "id" FROM "materials" WHERE EXISTS ( SELECT 1 FROM jsonb_each_text(${metadataTemplateFieldsJson()}) AS field("key", "value") WHERE field."key" IN (${Prisma.join(fieldKeys)}) AND field."value" ILIKE ${`%${search}%`} ) `) return rows.map((row) => row.id) } async function findMaterialIdsByCustomFieldValue(fieldKey: string, value: string): Promise { const rows = await prisma.$queryRaw>(Prisma.sql` SELECT "id" FROM "materials" WHERE ${metadataTemplateFieldsJson()} ->> ${fieldKey} = ${value} `) return rows.map((row) => row.id) } // Get all materials with search and filter support export async function GET(req: NextRequest) { try { const session = await getServerSession(authOptions) if (!session?.user) { return NextResponse.json( { error: '请先登录' }, { status: 401 } ) } 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 }> = [] const requestedDynamicFieldKeys: string[] = [] searchParams.forEach((value, key) => { if (key.startsWith('homeField_') && value) { const fieldKey = key.replace('homeField_', '') requestedDynamicFieldKeys.push(fieldKey) if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(fieldKey)) { dynamicFieldFilters.push({ key: fieldKey, value }) } } }) const enabledCustomHomeFilterKeys = await getEnabledCustomHomeFilterKeys(requestedDynamicFieldKeys) searchParams.forEach((value, key) => { if (key.startsWith('homeField_') && value) { const fieldKey = key.replace('homeField_', '') if (enabledCustomHomeFilterKeys.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) { const customSearchMaterialIds = await findMaterialIdsByCustomFieldSearch(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' } }, // 作者 ...(customSearchMaterialIds.length > 0 ? [{ id: { in: customSearchMaterialIds } }] : []), ] }) } // 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 = { '3dmax-material': '3DMAX', 'psd-material': 'PSD', 'audio-material': '音频', '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 }) } } for (const { key, value } of dynamicFieldFilters) { if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(key)) { andConditions.push({ [key]: value } as Record) } else { const materialIds = await findMaterialIdsByCustomFieldValue(key, value) andConditions.push({ id: { in: materialIds } }) } } // 合并所有条件 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, rawMaterials] = 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, // @ts-ignore - 音频字段已在数据库中添加 audioDuration: true, // @ts-ignore audioCategory: true, // @ts-ignore audioScene: true, // @ts-ignore audioUrl: 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, templateId: true, template: { select: { name: true, }, }, user: { select: { username: true, avatar: true, }, }, }, orderBy, skip: (page - 1) * maxPageSize, take: maxPageSize, }) ]) const totalPages = Math.ceil(totalCount / maxPageSize) // 先统一处理原始 materials,避免在多个地方重复转换 const materials = rawMaterials.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(), } }) // 如果用户已登录,查询点赞状态(优化:只在有数据时查询) let userLikes: Set = new Set() let userFavorites: Set = 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为字符串,并修正存储 URL const materialsWithLikeStatus = materials.map((material) => { const baseMaterial = { ...material, isLiked: userLikes.has(material.id), isFavorited: userFavorites.has(material.id), } // 统一修正 previewUrl / videoCoverUrl / videoPreview* / displayImages 等中的 localhost/127.0.0.1 return rewriteMaterialStorageUrlsForClient(baseMaterial) }) 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) - 软删除 // 权限规则: // - 超级管理员(isSuperAdmin=true)可以删除任意素材 // - 普通管理员(role=ADMIN)可以删除自己上传的素材,以及自己管理的部门成员上传的素材 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 } ) } // 获取当前操作者信息 const operatorId = (session.user as any).id // 获取操作者的部门管理信息 const operator = await prisma.user.findUnique({ where: { id: operatorId }, select: { id: true, department: true, managedDepartment: true, isSuperAdmin: true, }, }) const isSuperAdmin = Boolean(operator?.isSuperAdmin) // Get material to delete files, including uploader's department info const material = await prisma.material.findUnique({ where: { id }, include: { user: { select: { id: true, department: true, }, }, }, }) if (!material) { return NextResponse.json( { error: '素材不存在' }, { status: 404 } ) } if (permanent && !isSuperAdmin) { return NextResponse.json( { error: '仅最高权限账号可永久删除素材' }, { status: 403 } ) } // 获取用户ID用于日志记录 const userId = operatorId // 权限验证:最高权限账号可以删除任意素材;普通管理员只能软删除自己或管理范围内的素材。 if (!isSuperAdmin) { // 检查是否是自己上传的素材 const isOwnMaterial = material.userId === operatorId if (!isOwnMaterial) { // 检查是否有权限管理素材上传者所在的部门 const operatorScope = (operator?.managedDepartment || '').trim() const targetDepartment = (material.user?.department || '').trim() if (!operatorScope) { return NextResponse.json( { error: '您只能删除自己上传的素材' }, { status: 403 } ) } // 检查目标素材的上传者是否在管理范围内 let hasAccess = targetDepartment === operatorScope || targetDepartment.startsWith(`${operatorScope}-`) if (!hasAccess) { // 使用部门层级关系检查 const departments = await prisma.department.findMany({ select: { name: true } }) const descendantMap = buildDepartmentDescendants(departments) hasAccess = isDepartmentWithinScope(operatorScope, targetDepartment, descendantMap) } if (!hasAccess) { return NextResponse.json( { error: '您只能删除自己或所管理部门成员上传的素材' }, { status: 403 } ) } } } if (permanent) { const cleanupResult = await deleteMaterialStorageFiles(collectMaterialStorageUrls(material), { logPrefix: `[Delete] Material ${id}`, }) console.log( `[Delete] Material ${id} files cleanup: ${cleanupResult.deletedFiles} deleted, ${cleanupResult.skippedFiles} skipped, ${cleanupResult.failedFiles} failed` ) if (cleanupResult.failedFiles > 0) { return NextResponse.json( { error: '文件删除失败,素材记录已保留,请稍后重试', deletedFiles: cleanupResult.deletedFiles, skippedFiles: cleanupResult.skippedFiles, failedFiles: cleanupResult.failedFiles, failures: cleanupResult.failures.slice(0, 10), }, { status: 500 } ) } // 文件确认删除后再记录硬删除日志,避免失败时误报“已删除”。 await createMaterialLog({ materialId: id, userId, action: 'DELETE', changes: { permanent: { oldValue: false, newValue: true, }, }, materialSnapshot: buildMaterialSnapshot(material), }) // 永久删除数据库记录 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, }, }, materialSnapshot: buildMaterialSnapshot(material), }) } return NextResponse.json({ message: '删除成功' }) } catch (error) { console.error('[Materials] DELETE error:', error) return NextResponse.json( { error: '删除失败' }, { status: 500 } ) } }