2026-06-03 18:41:52 +08:00

593 lines
18 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 { 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 { planMaterialListQueryWithPaidCore } from '@/lib/license'
import { buildMaterialManagementScopeWhere, canManageMaterial, getMaterialPermissionActor } from '@/lib/material-permissions'
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] : []
}
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 planMaterialListQuery(
search: string,
homeFieldFilters: Array<{ key: string; value: string }>
) {
const requestedHomeFieldKeys = Array.from(new Set(homeFieldFilters.map((filter) => filter.key)))
const [searchableFields, enabledHomeFilterFields] = await Promise.all([
prisma.uploadField.findMany({
where: {
isSearchable: true,
},
select: {
key: true,
},
}),
requestedHomeFieldKeys.length > 0
? prisma.fieldDefinition.findMany({
where: {
key: { in: requestedHomeFieldKeys },
isFilterEnabled: true,
},
select: {
key: true,
},
})
: Promise.resolve([]),
])
return planMaterialListQueryWithPaidCore({
search,
searchableFields,
homeFieldFilters,
enabledHomeFilterFields,
})
}
async function findMaterialIdsByCustomFieldSearch(search: string, fieldKeys: string[]): Promise<string[]> {
if (fieldKeys.length === 0) {
return []
}
const rows = await prisma.$queryRaw<Array<{ id: string }>>(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<string[]> {
const rows = await prisma.$queryRaw<Array<{ id: string }>>(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 actor = await getMaterialPermissionActor(session)
if (!actor) {
return NextResponse.json(
{ error: '账号不可用,请重新登录或联系管理员' },
{ status: 403 }
)
}
if (showDeleted === 'true' && actor.role !== 'ADMIN' && !actor.isSuperAdmin) {
return NextResponse.json(
{ error: '无权限查看回收站' },
{ status: 403 }
)
}
const homeFieldFilters: Array<{ key: string; value: string }> = []
searchParams.forEach((value, key) => {
if (key.startsWith('homeField_') && value) {
const fieldKey = key.replace('homeField_', '')
homeFieldFilters.push({ key: fieldKey, value })
}
})
const queryPlan = await planMaterialListQuery(search, homeFieldFilters)
if (!queryPlan.valid) {
return NextResponse.json(
{ error: queryPlan.error || '无法规划素材查询条件' },
{ status: 503 }
)
}
// 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 })
}
if (actor.role === 'ADMIN' || actor.isSuperAdmin) {
const scopeWhere = await buildMaterialManagementScopeWhere(actor)
if (Object.keys(scopeWhere).length > 0) {
andConditions.push(scopeWhere)
}
}
// Add search condition (search in SPU, shop, scene category, author)
if (queryPlan.search) {
const customSearchMaterialIds = await findMaterialIdsByCustomFieldSearch(
queryPlan.search,
queryPlan.customSearchFieldKeys
)
const standardSearchConditions = queryPlan.standardSearchFields.map((field) => ({
[field]: { contains: queryPlan.search, mode: 'insensitive' },
}))
const customSearchConditions = customSearchMaterialIds.length > 0
? [{ id: { in: customSearchMaterialIds } }]
: []
andConditions.push({
OR: [
...standardSearchConditions,
...customSearchConditions,
]
})
}
// 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',
'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 queryPlan.standardHomeFilters) {
andConditions.push({ [key]: value } as Record<string, string>)
}
for (const { key, value } of queryPlan.customHomeFilters) {
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<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为字符串,并修正存储 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 actor = await getMaterialPermissionActor(session)
if (!actor) {
return NextResponse.json(
{ error: '当前用户不存在或已被禁用' },
{ status: 403 }
)
}
const isSuperAdmin = actor.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 = actor.id
// 权限验证:最高权限账号可以删除任意素材;普通管理员只能软删除自己或管理范围内的素材。
if (!isSuperAdmin && !(await canManageMaterial(material, actor))) {
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 }
)
}
}