593 lines
18 KiB
TypeScript
593 lines
18 KiB
TypeScript
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 }
|
||
)
|
||
}
|
||
}
|