import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { authOptions } from '@/lib/auth' import { prisma } from '@/lib/prisma' import { buildMaterialWhereFromSearchParams, createMaterialQueryContext, MaterialFilterDimension, MaterialQueryError, normalizeMaterialTypeForUi, normalizeSelectableMaterialTypes, } from '@/lib/material-query' type FacetField = 'categoryName' | 'sceneName' | 'platformName' | 'productCategoryName' const FIELD_TO_EXCLUDE: Record = { categoryName: 'category', sceneName: 'scene', platformName: 'platform', productCategoryName: 'productCategory', } function toFacetItems(rows: any[], field: FacetField) { return rows .map((row) => { const name = String(row[field] || '').trim() return { id: name, name, count: row._count?._all || 0, } }) .filter((item) => item.name && item.count > 0) .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')) } function countGroupedRows(rows: any[]) { return rows.reduce((total, row) => total + Number(row._count?._all || 0), 0) } function toMonthItems(rows: Array<{ createdAt: Date }>) { const monthMap = new Map() for (const row of rows) { const date = new Date(row.createdAt) const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` monthMap.set(value, (monthMap.get(value) || 0) + 1) } return Array.from(monthMap.entries()) .map(([value, count]) => { const [, month] = value.split('-') return { value, label: `${value.slice(0, 4)}年${Number.parseInt(month, 10)}月`, count, } }) .sort((a, b) => b.value.localeCompare(a.value)) } function toMaterialTypeItems(rows: any[]) { const countMap = new Map() for (const row of rows) { const normalizedType = normalizeMaterialTypeForUi(row.materialType) if (!normalizedType) { continue } countMap.set(normalizedType, (countMap.get(normalizedType) || 0) + (row._count?._all || 0)) } const orderedTypes = normalizeSelectableMaterialTypes(Array.from(countMap.keys())) return orderedTypes.map((type) => ({ id: type, name: type, count: countMap.get(type) || 0, })) } // ===== 服务端内存缓存 ===== // facets 的 7 条 SQL 在大库上可耗时 20 秒以上(尤其 loadMonths 的全表扫描)。 // 相同权限上下文 + 相同筛选参数的结果在 TTL 内直接返回缓存。 const FACETS_CACHE_TTL_MS = 5 * 60 * 1000 // 5 分钟 const facetsCache = new Map() function buildFacetsCacheKey(searchParams: URLSearchParams, user: any): string { // 以角色+部门+筛选参数为缓存键:同权限的人共享同一份结果 const role = String(user?.role || 'USER') const dept = String(user?.department || '') const managed = String(user?.managedDepartment || '') const filterKey = searchParams.toString() return `${role}|${dept}|${managed}|${filterKey}` } function getFacetsFromCache(key: string): any | null { const entry = facetsCache.get(key) if (!entry) return null if (Date.now() > entry.expiresAt) { facetsCache.delete(key) return null } return entry.data } function setFacetsCache(key: string, data: any) { // 简单容量上限:最多保留 200 个不同筛选组合的缓存 if (facetsCache.size > 200) { const oldest = facetsCache.keys().next().value if (oldest) facetsCache.delete(oldest) } facetsCache.set(key, { data, expiresAt: Date.now() + FACETS_CACHE_TTL_MS }) } export async function GET(request: NextRequest) { try { const session = await getServerSession(authOptions) if (!session?.user || !(session.user as any).id) { return NextResponse.json( { error: '未登录', code: 'UNAUTHORIZED' }, { status: 401 }, ) } const searchParams = request.nextUrl.searchParams const user = session.user as any // 缓存命中:直接返回,跳过全部 7 条 SQL const cacheKey = buildFacetsCacheKey(searchParams, user) const cached = getFacetsFromCache(cacheKey) if (cached) { return NextResponse.json(cached) } const context = await createMaterialQueryContext(searchParams, user) const buildWhere = async (exclude?: MaterialFilterDimension) => buildMaterialWhereFromSearchParams(searchParams, user, { context, ...(exclude ? { exclude } : {}), }) const countWithFilters = async (exclude?: MaterialFilterDimension) => { const result = await buildWhere(exclude) if (result.empty) { return 0 } return prisma.material.count({ where: result.where }) } const groupByField = async (field: FacetField) => { const result = await buildWhere(FIELD_TO_EXCLUDE[field]) if (result.empty) { return { items: [], total: 0 } } const rows = await (prisma.material.groupBy as any)({ by: [field], where: result.where, _count: { _all: true }, }) return { items: toFacetItems(rows, field), total: countGroupedRows(rows), } } const loadMaterialTypes = async () => { const result = await buildWhere('materialType') if (result.empty) { return { items: [], total: 0 } } const rows = await (prisma.material.groupBy as any)({ by: ['materialType'], where: result.where, _count: { _all: true }, }) return { items: toMaterialTypeItems(rows), total: countGroupedRows(rows), } } const loadMonths = async () => { const result = await buildWhere('month') if (result.empty) { return { items: [], total: 0 } } // 用 groupBy 替代 findMany 全表扫描:只取按月聚合的计数,不拉全量 createdAt 行 const rows = await (prisma.material.groupBy as any)({ by: ['createdAt'], where: result.where, _count: { _all: true }, }) // groupBy 返回去重后的 createdAt 及其计数,汇总为月度计数 const monthMap = new Map() let total = 0 for (const row of rows) { const date = new Date(row.createdAt) const value = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}` const count = Number(row._count?._all || 0) monthMap.set(value, (monthMap.get(value) || 0) + count) total += count } return { items: Array.from(monthMap.entries()) .map(([value, count]) => { const [, month] = value.split('-') return { value, label: `${value.slice(0, 4)}年${Number.parseInt(month, 10)}月`, count, } }) .sort((a, b) => b.value.localeCompare(a.value)), total, } } // 复用 groupBy 的计数汇总,避免为每个筛选维度再执行一次 count: // 整个 facets 请求由 13 条 SQL 降为 7 条,同时每批最多并发 3 条。 const [total, categoryResult, sceneResult] = await Promise.all([ countWithFilters(), groupByField('categoryName'), groupByField('sceneName'), ]) const [platformResult, productCategoryResult, monthResult] = await Promise.all([ groupByField('platformName'), groupByField('productCategoryName'), loadMonths(), ]) const materialTypeResult = await loadMaterialTypes() const responseData = { total, categories: categoryResult.items, scenes: sceneResult.items, platforms: platformResult.items, productCategories: productCategoryResult.items, months: monthResult.items, materialTypes: materialTypeResult.items, totals: { materialType: materialTypeResult.total, category: categoryResult.total, scene: sceneResult.total, platform: platformResult.total, productCategory: productCategoryResult.total, month: monthResult.total, }, } // 写入缓存,供同权限用户在 TTL 内复用 setFacetsCache(cacheKey, responseData) return NextResponse.json(responseData) } catch (error) { if (error instanceof MaterialQueryError) { return NextResponse.json( { error: error.message, code: error.code }, { status: error.status }, ) } console.error('[Materials Facets] Error:', error) return NextResponse.json( { error: '获取首页筛选统计失败', code: 'SERVER_ERROR' }, { status: 500 }, ) } }