292 lines
11 KiB
TypeScript
292 lines
11 KiB
TypeScript
import { canAccessOperationsGMaterials, getOperationsGMaterialIds } from '@/lib/material-organization-access'
|
||
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<string, unknown> | null): {
|
||
productModel?: string
|
||
materialType?: string
|
||
} {
|
||
if (!changes) return {}
|
||
const snapshot = changes.materialSnapshot
|
||
if (!snapshot || typeof snapshot !== 'object') return {}
|
||
|
||
const info = snapshot as Record<string, unknown>
|
||
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 excludedIds = await canAccessOperationsGMaterials((session.user as any).id) ? [] : await getOperationsGMaterialIds()
|
||
const materialLogWhere: any = { ...(excludedIds.length ? { materialId: { notIn: excludedIds } } : {}) }
|
||
const downloadLogWhere: any = { ...(excludedIds.length ? { materialId: { notIn: excludedIds } } : {}) }
|
||
|
||
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<string>(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<Record<string, unknown>>(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 })
|
||
}
|
||
}
|