chenpei 02df0b7ef4 perf: facets 服务端缓存 + loadMonths 查询优化 + 首页去重复卡片
- facets API 增加内存缓存(TTL 5 分钟,键=角色+部门+筛选参数,容量 200 条)
- loadMonths 从 findMany 全表扫描改为 groupBy 按月聚合(数据库侧完成)
- 首页移除下方白卡区块(与编号索引重复)+ 恢复 05-07 规划中板块行
2026-08-17 09:09:51 +08:00

277 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 },
)
}
}