import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { isSystemAdmin } from '@/lib/department-permission' import { safeJsonParse } from '@/lib/safe-json' const VALID_ACTIONS = new Set(['ALL', 'UPLOAD', 'UPDATE', 'DELETE', 'DOWNLOAD', 'RESTORE']) function toDateStart(dateString: string): Date { return new Date(`${dateString}T00:00:00`) } function toDateEnd(dateString: string): Date { return new Date(`${dateString}T23:59:59.999`) } function normalizeKeyword(keyword: string | null): string { return (keyword || '').trim() } function getSnapshotMaterialInfo(changes: Record | null): { productModel?: string materialType?: string } { if (!changes) return {} const snapshot = changes.materialSnapshot if (!snapshot || typeof snapshot !== 'object') return {} const info = snapshot as Record return { productModel: typeof info.productModel === 'string' ? info.productModel : undefined, materialType: typeof info.materialType === 'string' ? info.materialType : undefined, } } export async function GET(request: NextRequest) { try { const session = await getServerSession(authOptions) if (!session?.user || !isSystemAdmin((session.user as any).role)) { return NextResponse.json({ error: '无权限' }, { status: 403 }) } const { searchParams } = new URL(request.url) const requestedAction = (searchParams.get('action') || 'ALL').toUpperCase() const action = VALID_ACTIONS.has(requestedAction) ? requestedAction : 'ALL' const page = Math.max(Number(searchParams.get('page') || '1'), 1) const pageSize = Math.min(Math.max(Number(searchParams.get('pageSize') || '20'), 1), 100) const startDate = searchParams.get('startDate') const endDate = searchParams.get('endDate') const keyword = normalizeKeyword(searchParams.get('keyword')) const materialLogWhere: any = {} const downloadLogWhere: any = {} if (startDate || endDate) { const timestampFilter: { gte?: Date; lte?: Date } = {} if (startDate) { timestampFilter.gte = toDateStart(startDate) } if (endDate) { timestampFilter.lte = toDateEnd(endDate) } materialLogWhere.timestamp = timestampFilter downloadLogWhere.downloadedAt = timestampFilter } if (action === 'ALL') { materialLogWhere.action = { not: 'DOWNLOAD' } } else if (action !== 'DOWNLOAD') { materialLogWhere.action = action } const includeMaterialLogs = action !== 'DOWNLOAD' const includeDownloadLogs = action === 'ALL' || action === 'DOWNLOAD' // 关键词过滤下推到 DB 端:把原"take:5000 内存过滤"改为精确 where,避免 total 被截断。 // 匹配范围与原内存过滤完全一致: // user: username / realName / department / departmentRef.name // material: id / productModel / materialType // snapshot 兜底: changes.materialSnapshot.productModel / materialType(JSON 字符串,无法 DB 端匹配,单独补齐) if (keyword) { const [matchedUsers, matchedMaterials, snapshotMaterialLogs] = await Promise.all([ prisma.user.findMany({ where: { OR: [ { username: { contains: keyword, mode: 'insensitive' } }, { realName: { contains: keyword, mode: 'insensitive' } }, { department: { contains: keyword, mode: 'insensitive' } }, { departmentRef: { name: { contains: keyword, mode: 'insensitive' } } }, ], }, select: { id: true }, take: 2000, }), prisma.material.findMany({ where: { OR: [ { id: { contains: keyword, mode: 'insensitive' } }, { productModel: { contains: keyword, mode: 'insensitive' } }, { materialType: { contains: keyword, mode: 'insensitive' } }, ], }, select: { id: true }, take: 2000, }), // snapshot 兜底:changes 是 JSON 字符串,DB 端无法精确匹配嵌套字段, // 用 contains 匹配 changes 字符串本身,命中后再取 materialId 补进结果集。 // (为避免全表扫描,限定只扫 material_logs,download_logs 无 changes 字段) prisma.materialLog.findMany({ where: { changes: { contains: keyword, mode: 'insensitive' }, }, select: { materialId: true }, take: 2000, }), ]) const matchedUserIds = matchedUsers.map((u) => u.id) const matchedMaterialIds = new Set(matchedMaterials.map((m) => m.id)) snapshotMaterialLogs.forEach((log) => matchedMaterialIds.add(log.materialId)) const allMatchedMaterialIds = Array.from(matchedMaterialIds) // 既不命中用户也不命中素材 → 直接返回空结果,避免全表扫描 if (matchedUserIds.length === 0 && allMatchedMaterialIds.length === 0) { return NextResponse.json({ success: true, data: { logs: [], page, pageSize, total: 0, totalPages: 0 }, }) } // OR 关系:日志命中用户 或 命中素材都算匹配 const keywordFilter = { OR: [ ...(matchedUserIds.length > 0 ? [{ userId: { in: matchedUserIds } }] : []), ...(allMatchedMaterialIds.length > 0 ? [{ materialId: { in: allMatchedMaterialIds } }] : []), ], } materialLogWhere.OR = keywordFilter.OR downloadLogWhere.OR = keywordFilter.OR } // 统一使用真实分页的 take(keyword 已下推到 where,不再需要内存过滤的 5000 上限) const fetchLimit = page * pageSize const [materialLogTotal, downloadLogTotal, materialLogs, downloadLogs] = await Promise.all([ includeMaterialLogs ? prisma.materialLog.count({ where: materialLogWhere }) : Promise.resolve(0), includeDownloadLogs ? prisma.materialDownloadLog.count({ where: downloadLogWhere }) : Promise.resolve(0), includeMaterialLogs ? prisma.materialLog.findMany({ where: materialLogWhere, orderBy: { timestamp: 'desc' }, take: fetchLimit, include: { user: { select: { id: true, username: true, realName: true, department: true, departmentId: true, departmentRef: { select: { name: true, }, }, }, }, }, }) : Promise.resolve([]), includeDownloadLogs ? prisma.materialDownloadLog.findMany({ where: downloadLogWhere, orderBy: { downloadedAt: 'desc' }, take: fetchLimit, include: { user: { select: { id: true, username: true, realName: true, department: true, departmentId: true, departmentRef: { select: { name: true, }, }, }, }, }, }) : Promise.resolve([]), ]) const materialIds = Array.from( new Set([ ...materialLogs.map((log) => log.materialId), ...downloadLogs.map((log) => log.materialId), ]) ) const materials = materialIds.length ? await prisma.material.findMany({ where: { id: { in: materialIds } }, select: { id: true, productModel: true, materialType: true, }, }) : [] const materialMap = new Map(materials.map((material) => [material.id, material])) const formattedMaterialLogs = materialLogs.map((log) => { const parsedChanges = log.changes ? safeJsonParse>(log.changes, {}, false) : null const relatedMaterial = materialMap.get(log.materialId) const snapshotInfo = getSnapshotMaterialInfo(parsedChanges) return { id: `material-${log.id}`, source: 'material_log', action: log.action, timestamp: log.timestamp.toISOString(), user: { id: log.user.id, username: log.user.username, realName: log.user.realName, departmentName: log.user.departmentRef?.name || log.user.department || '未分配部门', }, material: { id: log.materialId, productModel: relatedMaterial?.productModel || snapshotInfo.productModel || '已删除素材', materialType: relatedMaterial?.materialType || snapshotInfo.materialType || '未知类型', }, changes: parsedChanges, } }) const formattedDownloadLogs = downloadLogs.map((log) => ({ id: `download-${log.id}`, source: 'download_log', action: 'DOWNLOAD', timestamp: log.downloadedAt.toISOString(), user: { id: log.user.id, username: log.user.username, realName: log.user.realName, departmentName: log.user.departmentRef?.name || log.user.department || '未分配部门', }, material: { id: log.materialId, productModel: materialMap.get(log.materialId)?.productModel || '已删除素材', materialType: materialMap.get(log.materialId)?.materialType || '未知类型', }, changes: null, })) const mergedLogs = [...formattedMaterialLogs, ...formattedDownloadLogs].sort( (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() ) // keyword 已下推到 DB where,mergedLogs 即为命中结果,无需内存过滤 const offset = (page - 1) * pageSize const paginatedLogs = mergedLogs.slice(offset, offset + pageSize) // total 来自 DB 真实 count(keyword 已注入 where,不再截断) const total = materialLogTotal + downloadLogTotal return NextResponse.json({ success: true, data: { logs: paginatedLogs, page, pageSize, total, totalPages: Math.ceil(total / pageSize), }, }) } catch (error) { console.error('[Admin Material Operation Logs] GET error:', error) return NextResponse.json({ error: '获取素材操作日志失败' }, { status: 500 }) } }