- facets API 增加内存缓存(TTL 5 分钟,键=角色+部门+筛选参数,容量 200 条) - loadMonths 从 findMany 全表扫描改为 groupBy 按月聚合(数据库侧完成) - 首页移除下方白卡区块(与编号索引重复)+ 恢复 05-07 规划中板块行
277 lines
8.5 KiB
TypeScript
277 lines
8.5 KiB
TypeScript
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<FacetField, MaterialFilterDimension> = {
|
||
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<string, number>()
|
||
|
||
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<string, number>()
|
||
|
||
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<string, { data: any; expiresAt: number }>()
|
||
|
||
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<string, number>()
|
||
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 },
|
||
)
|
||
}
|
||
}
|