fix: 降低数据库读压并优化去重查询
This commit is contained in:
@@ -4,11 +4,14 @@ import { getProjectBySlug, getProjects } from '@/hooks/useProjects'
|
|||||||
import { ProjectDetail } from '@/components/project/ProjectDetail'
|
import { ProjectDetail } from '@/components/project/ProjectDetail'
|
||||||
import { ProjectSidebar } from '@/components/project/ProjectSidebar'
|
import { ProjectSidebar } from '@/components/project/ProjectSidebar'
|
||||||
import { RelatedProjects } from '@/components/project/RelatedProjects'
|
import { RelatedProjects } from '@/components/project/RelatedProjects'
|
||||||
|
import { cache } from 'react'
|
||||||
|
|
||||||
interface ProjectDetailPageProps {
|
interface ProjectDetailPageProps {
|
||||||
params: Promise<{ locale: string; id: string }>
|
params: Promise<{ locale: string; id: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCachedProjectBySlug = cache((slug: string) => getProjectBySlug(slug))
|
||||||
|
|
||||||
export default async function ProjectDetailPage({
|
export default async function ProjectDetailPage({
|
||||||
params,
|
params,
|
||||||
}: ProjectDetailPageProps) {
|
}: ProjectDetailPageProps) {
|
||||||
@@ -18,7 +21,7 @@ export default async function ProjectDetailPage({
|
|||||||
|
|
||||||
// 并行获取项目和相关项目数据
|
// 并行获取项目和相关项目数据
|
||||||
const [project, relatedProjectsResult] = await Promise.all([
|
const [project, relatedProjectsResult] = await Promise.all([
|
||||||
getProjectBySlug(id),
|
getCachedProjectBySlug(id),
|
||||||
getProjects({ limit: 3 }),
|
getProjects({ limit: 3 }),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -72,7 +75,7 @@ export async function generateMetadata({ params }: ProjectDetailPageProps) {
|
|||||||
const { id, locale } = resolvedParams
|
const { id, locale } = resolvedParams
|
||||||
const tProject = await getTranslations('project')
|
const tProject = await getTranslations('project')
|
||||||
|
|
||||||
const project = await getProjectBySlug(id)
|
const project = await getCachedProjectBySlug(id)
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { unstable_cache } from "next/cache";
|
||||||
|
|
||||||
export async function GET() {
|
const TAGS_CACHE_REVALIDATE_SECONDS = 300;
|
||||||
try {
|
|
||||||
const tags = await prisma.tag.findMany({
|
const getCachedTags = unstable_cache(
|
||||||
|
async () =>
|
||||||
|
prisma.tag.findMany({
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
select: { projects: true },
|
select: { projects: true },
|
||||||
@@ -12,7 +15,17 @@ export async function GET() {
|
|||||||
orderBy: {
|
orderBy: {
|
||||||
name: "asc",
|
name: "asc",
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
["api-tags:v1"],
|
||||||
|
{
|
||||||
|
revalidate: TAGS_CACHE_REVALIDATE_SECONDS,
|
||||||
|
tags: ["api-tags"],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const tags = await getCachedTags();
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const CheckDuplicatesSchema = z.object({
|
|||||||
websiteUrl: z.string().url().optional(),
|
websiteUrl: z.string().url().optional(),
|
||||||
slug: z.string().optional(),
|
slug: z.string().optional(),
|
||||||
})
|
})
|
||||||
),
|
).min(1).max(100),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,99 +42,215 @@ interface CheckResult {
|
|||||||
projectName?: string
|
projectName?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
type CheckProjectInput = {
|
||||||
* 多级去重策略:检查项目是否已存在
|
|
||||||
*
|
|
||||||
* 优先级:
|
|
||||||
* 1. GitHub URL 完全匹配(最准确)
|
|
||||||
* 2. Hugging Face URL 完全匹配
|
|
||||||
* 3. Website URL 完全匹配
|
|
||||||
* 4. slug 匹配(兜底)
|
|
||||||
*
|
|
||||||
* @param project - 项目待检查信息
|
|
||||||
* @returns 检查结果
|
|
||||||
*/
|
|
||||||
async function checkProjectExists(project: {
|
|
||||||
githubUrl?: string
|
githubUrl?: string
|
||||||
huggingfaceUrl?: string
|
huggingfaceUrl?: string
|
||||||
websiteUrl?: string
|
websiteUrl?: string
|
||||||
slug?: string
|
slug?: string
|
||||||
}): Promise<CheckResult> {
|
}
|
||||||
// 优先级1: GitHub URL 匹配
|
|
||||||
if (project.githubUrl) {
|
|
||||||
const existingByGithub = await prisma.externalLink.findFirst({
|
|
||||||
where: {
|
|
||||||
type: 'GITHUB',
|
|
||||||
url: project.githubUrl,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
project: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
|
type ProjectSummary = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DuplicateCheckMaps = {
|
||||||
|
githubMap: Map<string, ProjectSummary>
|
||||||
|
huggingfaceMap: Map<string, ProjectSummary>
|
||||||
|
websiteMap: Map<string, ProjectSummary>
|
||||||
|
slugMap: Map<string, ProjectSummary>
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLookupKey(project: CheckProjectInput): string {
|
||||||
|
return [
|
||||||
|
project.githubUrl || '',
|
||||||
|
project.huggingfaceUrl || '',
|
||||||
|
project.websiteUrl || '',
|
||||||
|
project.slug || '',
|
||||||
|
].join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toExternalLinkMap(
|
||||||
|
rows: Array<{ url: string; project: ProjectSummary }>
|
||||||
|
): Map<string, ProjectSummary> {
|
||||||
|
const map = new Map<string, ProjectSummary>()
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!map.has(row.url)) {
|
||||||
|
map.set(row.url, row.project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSlugMap(
|
||||||
|
rows: Array<{ slug: string; id: string; name: string }>
|
||||||
|
): Map<string, ProjectSummary> {
|
||||||
|
const map = new Map<string, ProjectSummary>()
|
||||||
|
for (const row of rows) {
|
||||||
|
map.set(row.slug, { id: row.id, name: row.name })
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildDuplicateCheckMaps(
|
||||||
|
projects: CheckProjectInput[]
|
||||||
|
): Promise<DuplicateCheckMaps> {
|
||||||
|
const githubUrls = Array.from(
|
||||||
|
new Set(
|
||||||
|
projects
|
||||||
|
.map((project) => project.githubUrl?.trim())
|
||||||
|
.filter((url): url is string => Boolean(url))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const huggingfaceUrls = Array.from(
|
||||||
|
new Set(
|
||||||
|
projects
|
||||||
|
.map((project) => project.huggingfaceUrl?.trim())
|
||||||
|
.filter((url): url is string => Boolean(url))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const websiteUrls = Array.from(
|
||||||
|
new Set(
|
||||||
|
projects
|
||||||
|
.map((project) => project.websiteUrl?.trim())
|
||||||
|
.filter((url): url is string => Boolean(url))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const slugs = Array.from(
|
||||||
|
new Set(
|
||||||
|
projects
|
||||||
|
.map((project) => project.slug?.trim())
|
||||||
|
.filter((slug): slug is string => Boolean(slug))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const [githubRows, huggingfaceRows, websiteRows, slugRows] = await Promise.all([
|
||||||
|
githubUrls.length > 0
|
||||||
|
? prisma.externalLink.findMany({
|
||||||
|
where: {
|
||||||
|
type: 'GITHUB',
|
||||||
|
url: {
|
||||||
|
in: githubUrls,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
url: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
huggingfaceUrls.length > 0
|
||||||
|
? prisma.externalLink.findMany({
|
||||||
|
where: {
|
||||||
|
type: 'HUGGINGFACE',
|
||||||
|
url: {
|
||||||
|
in: huggingfaceUrls,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
url: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
websiteUrls.length > 0
|
||||||
|
? prisma.externalLink.findMany({
|
||||||
|
where: {
|
||||||
|
type: 'WEBSITE',
|
||||||
|
url: {
|
||||||
|
in: websiteUrls,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
url: true,
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
slugs.length > 0
|
||||||
|
? prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
slug: {
|
||||||
|
in: slugs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
githubMap: toExternalLinkMap(githubRows),
|
||||||
|
huggingfaceMap: toExternalLinkMap(huggingfaceRows),
|
||||||
|
websiteMap: toExternalLinkMap(websiteRows),
|
||||||
|
slugMap: toSlugMap(slugRows),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkProjectExists(
|
||||||
|
project: CheckProjectInput,
|
||||||
|
duplicateMaps: DuplicateCheckMaps
|
||||||
|
): CheckResult {
|
||||||
|
if (project.githubUrl) {
|
||||||
|
const existingByGithub = duplicateMaps.githubMap.get(project.githubUrl.trim())
|
||||||
if (existingByGithub) {
|
if (existingByGithub) {
|
||||||
return {
|
return {
|
||||||
githubUrl: project.githubUrl,
|
githubUrl: project.githubUrl,
|
||||||
exists: true,
|
exists: true,
|
||||||
matchType: 'GITHUB_URL',
|
matchType: 'GITHUB_URL',
|
||||||
projectId: existingByGithub.project.id,
|
projectId: existingByGithub.id,
|
||||||
projectName: existingByGithub.project.name,
|
projectName: existingByGithub.name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先级2: Hugging Face URL 匹配
|
|
||||||
if (project.huggingfaceUrl) {
|
if (project.huggingfaceUrl) {
|
||||||
const existingByHuggingFace = await prisma.externalLink.findFirst({
|
const existingByHuggingFace = duplicateMaps.huggingfaceMap.get(project.huggingfaceUrl.trim())
|
||||||
where: {
|
|
||||||
type: 'HUGGINGFACE',
|
|
||||||
url: project.huggingfaceUrl,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
project: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (existingByHuggingFace) {
|
if (existingByHuggingFace) {
|
||||||
return {
|
return {
|
||||||
huggingfaceUrl: project.huggingfaceUrl,
|
huggingfaceUrl: project.huggingfaceUrl,
|
||||||
exists: true,
|
exists: true,
|
||||||
matchType: 'HUGGINGFACE_URL',
|
matchType: 'HUGGINGFACE_URL',
|
||||||
projectId: existingByHuggingFace.project.id,
|
projectId: existingByHuggingFace.id,
|
||||||
projectName: existingByHuggingFace.project.name,
|
projectName: existingByHuggingFace.name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先级3: Website URL 匹配
|
|
||||||
if (project.websiteUrl) {
|
if (project.websiteUrl) {
|
||||||
const existingByWebsite = await prisma.externalLink.findFirst({
|
const existingByWebsite = duplicateMaps.websiteMap.get(project.websiteUrl.trim())
|
||||||
where: {
|
|
||||||
type: 'WEBSITE',
|
|
||||||
url: project.websiteUrl,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
project: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (existingByWebsite) {
|
if (existingByWebsite) {
|
||||||
return {
|
return {
|
||||||
websiteUrl: project.websiteUrl,
|
websiteUrl: project.websiteUrl,
|
||||||
exists: true,
|
exists: true,
|
||||||
matchType: 'WEBSITE_URL',
|
matchType: 'WEBSITE_URL',
|
||||||
projectId: existingByWebsite.project.id,
|
projectId: existingByWebsite.id,
|
||||||
projectName: existingByWebsite.project.name,
|
projectName: existingByWebsite.name,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优先级4: Slug 匹配(兜底)
|
|
||||||
if (project.slug) {
|
if (project.slug) {
|
||||||
const existingBySlug = await prisma.project.findUnique({
|
const existingBySlug = duplicateMaps.slugMap.get(project.slug.trim())
|
||||||
where: { slug: project.slug },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (existingBySlug) {
|
if (existingBySlug) {
|
||||||
return {
|
return {
|
||||||
slug: project.slug,
|
slug: project.slug,
|
||||||
@@ -146,7 +262,6 @@ async function checkProjectExists(project: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未找到匹配项
|
|
||||||
return {
|
return {
|
||||||
githubUrl: project.githubUrl,
|
githubUrl: project.githubUrl,
|
||||||
huggingfaceUrl: project.huggingfaceUrl,
|
huggingfaceUrl: project.huggingfaceUrl,
|
||||||
@@ -191,10 +306,20 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 并行检查所有项目
|
const duplicateMaps = await buildDuplicateCheckMaps(payload.projects)
|
||||||
const results = await Promise.all(
|
const cachedResults = new Map<string, CheckResult>()
|
||||||
payload.projects.map((project) => checkProjectExists(project))
|
|
||||||
)
|
const results = payload.projects.map((project) => {
|
||||||
|
const lookupKey = buildLookupKey(project)
|
||||||
|
const cached = cachedResults.get(lookupKey)
|
||||||
|
if (cached) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = checkProjectExists(project, duplicateMaps)
|
||||||
|
cachedResults.set(lookupKey, result)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
// 统计信息
|
// 统计信息
|
||||||
const stats = {
|
const stats = {
|
||||||
|
|||||||
+42
-9
@@ -1,11 +1,13 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { getTopTags } from '@/hooks/useProjects'
|
import { getTopTags } from '@/hooks/useProjects'
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from '@prisma/client'
|
||||||
|
import { unstable_cache } from 'next/cache'
|
||||||
|
|
||||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||||
const DEFAULT_RANKING_LIMIT = 6
|
const DEFAULT_RANKING_LIMIT = 6
|
||||||
const DEFAULT_TIMELINE_LIMIT = 8
|
const DEFAULT_TIMELINE_LIMIT = 8
|
||||||
const DEFAULT_TOP_TAG_LIMIT = 12
|
const DEFAULT_TOP_TAG_LIMIT = 12
|
||||||
|
const HOME_PAGE_REVALIDATE_SECONDS = 300
|
||||||
|
|
||||||
export type HomeProjectSummary = {
|
export type HomeProjectSummary = {
|
||||||
id: string
|
id: string
|
||||||
@@ -137,22 +139,30 @@ async function getTopStarsProjects(limit: number): Promise<HomeProjectSummary[]>
|
|||||||
return projects.map(mapProjectSummary)
|
return projects.map(mapProjectSummary)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHomePageData(): Promise<HomePageData> {
|
function getLatestProjectsByWindow(
|
||||||
|
projects: HomeProjectSummary[],
|
||||||
|
createdAfter: Date,
|
||||||
|
limit: number
|
||||||
|
): HomeProjectSummary[] {
|
||||||
|
return projects
|
||||||
|
.filter((project) => new Date(project.createdAt) >= createdAfter)
|
||||||
|
.slice(0, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildHomePageData(): Promise<HomePageData> {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const last24Hours = new Date(now - ONE_DAY_MS)
|
const last24Hours = new Date(now - ONE_DAY_MS)
|
||||||
const last7Days = new Date(now - ONE_DAY_MS * 7)
|
const last7Days = new Date(now - ONE_DAY_MS * 7)
|
||||||
const last30Days = new Date(now - ONE_DAY_MS * 30)
|
const last30Days = new Date(now - ONE_DAY_MS * 30)
|
||||||
|
const latestProjectsLimit = Math.max(DEFAULT_RANKING_LIMIT, DEFAULT_TIMELINE_LIMIT)
|
||||||
|
|
||||||
const [
|
const [
|
||||||
totalProjects,
|
totalProjects,
|
||||||
newProjects30d,
|
newProjects30d,
|
||||||
newProjects7d,
|
newProjects7d,
|
||||||
newProjects24h,
|
newProjects24h,
|
||||||
latest24h,
|
latestProjects,
|
||||||
latest7d,
|
|
||||||
latest30d,
|
|
||||||
topStars,
|
topStars,
|
||||||
timeline,
|
|
||||||
topTags,
|
topTags,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
safeQuery('countTotalProjects', 0, () => prisma.project.count()),
|
safeQuery('countTotalProjects', 0, () => prisma.project.count()),
|
||||||
@@ -186,14 +196,28 @@ export async function getHomePageData(): Promise<HomePageData> {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
getLatestProjects(DEFAULT_RANKING_LIMIT, last24Hours),
|
getLatestProjects(latestProjectsLimit),
|
||||||
getLatestProjects(DEFAULT_RANKING_LIMIT, last7Days),
|
|
||||||
getLatestProjects(DEFAULT_RANKING_LIMIT, last30Days),
|
|
||||||
getTopStarsProjects(DEFAULT_RANKING_LIMIT),
|
getTopStarsProjects(DEFAULT_RANKING_LIMIT),
|
||||||
getLatestProjects(DEFAULT_TIMELINE_LIMIT),
|
|
||||||
getTopTags(DEFAULT_TOP_TAG_LIMIT),
|
getTopTags(DEFAULT_TOP_TAG_LIMIT),
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const latest24h = getLatestProjectsByWindow(
|
||||||
|
latestProjects,
|
||||||
|
last24Hours,
|
||||||
|
DEFAULT_RANKING_LIMIT
|
||||||
|
)
|
||||||
|
const latest7d = getLatestProjectsByWindow(
|
||||||
|
latestProjects,
|
||||||
|
last7Days,
|
||||||
|
DEFAULT_RANKING_LIMIT
|
||||||
|
)
|
||||||
|
const latest30d = getLatestProjectsByWindow(
|
||||||
|
latestProjects,
|
||||||
|
last30Days,
|
||||||
|
DEFAULT_RANKING_LIMIT
|
||||||
|
)
|
||||||
|
const timeline = latestProjects.slice(0, DEFAULT_TIMELINE_LIMIT)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
overview: {
|
overview: {
|
||||||
totalProjects,
|
totalProjects,
|
||||||
@@ -221,3 +245,12 @@ export async function getHomePageData(): Promise<HomePageData> {
|
|||||||
timeline,
|
timeline,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCachedHomePageData = unstable_cache(buildHomePageData, ['home-page-data:v1'], {
|
||||||
|
revalidate: HOME_PAGE_REVALIDATE_SECONDS,
|
||||||
|
tags: ['home-page-data'],
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function getHomePageData(): Promise<HomePageData> {
|
||||||
|
return getCachedHomePageData()
|
||||||
|
}
|
||||||
|
|||||||
+78
-16
@@ -1,5 +1,6 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { Prisma, type TagCategory } from '@prisma/client'
|
import { Prisma, type TagCategory } from '@prisma/client'
|
||||||
|
import { unstable_cache } from 'next/cache'
|
||||||
import {
|
import {
|
||||||
FIXED_PROJECT_TYPE_TAGS,
|
FIXED_PROJECT_TYPE_TAGS,
|
||||||
TAG_CATEGORY_META,
|
TAG_CATEGORY_META,
|
||||||
@@ -95,6 +96,7 @@ export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number]
|
|||||||
const DEFAULT_PAGE = 1
|
const DEFAULT_PAGE = 1
|
||||||
const DEFAULT_LIMIT = 10
|
const DEFAULT_LIMIT = 10
|
||||||
const MAX_LIMIT = 100
|
const MAX_LIMIT = 100
|
||||||
|
const DEFAULT_CACHE_REVALIDATE_SECONDS = 300
|
||||||
|
|
||||||
export function normalizeProjectSort(value?: string): ProjectSortOption {
|
export function normalizeProjectSort(value?: string): ProjectSortOption {
|
||||||
const candidate = String(value || '').trim()
|
const candidate = String(value || '').trim()
|
||||||
@@ -365,8 +367,8 @@ export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]>
|
|||||||
return tags.filter(tag => tag._count.projects > 0)
|
return tags.filter(tag => tag._count.projects > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTopTags(limit: number = 10): Promise<TagWithProjectCount[]> {
|
async function getTopTagsFromDb(limit: number): Promise<TagWithProjectCount[]> {
|
||||||
const tags = await withDbRetry('getTopTags', () =>
|
return withDbRetry('getTopTags', () =>
|
||||||
prisma.tag.findMany({
|
prisma.tag.findMany({
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
@@ -378,25 +380,41 @@ export async function getTopTags(limit: number = 10): Promise<TagWithProjectCoun
|
|||||||
not: 'FIXED_PROJECT_TYPE',
|
not: 'FIXED_PROJECT_TYPE',
|
||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
some: {}, // 只返回有项目的标签
|
some: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
orderBy: [{ projects: { _count: 'desc' } }, { name: 'asc' }],
|
||||||
|
take: limit,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// 按项目数量降序、名称升序排序
|
|
||||||
return tags
|
|
||||||
.filter(tag => tag._count.projects > 0)
|
|
||||||
.sort((a, b) => {
|
|
||||||
const countDiff = b._count.projects - a._count.projects
|
|
||||||
if (countDiff !== 0) return countDiff
|
|
||||||
return a.name.localeCompare(b.name, 'zh')
|
|
||||||
})
|
|
||||||
.slice(0, limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFixedProjectTypeFilters(
|
const topTagsCache = new Map<number, () => Promise<TagWithProjectCount[]>>()
|
||||||
status: 'ACTIVE' | 'ARCHIVED' = 'ACTIVE'
|
|
||||||
|
function getTopTagsCachedFetcher(limit: number): () => Promise<TagWithProjectCount[]> {
|
||||||
|
const existing = topTagsCache.get(limit)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = unstable_cache(
|
||||||
|
async () => getTopTagsFromDb(limit),
|
||||||
|
[`top-tags:${limit}`],
|
||||||
|
{
|
||||||
|
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||||
|
tags: ['top-tags'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
topTagsCache.set(limit, fetcher)
|
||||||
|
return fetcher
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTopTags(limit: number = 10): Promise<TagWithProjectCount[]> {
|
||||||
|
return getTopTagsCachedFetcher(limit)()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFixedProjectTypeFiltersFromDb(
|
||||||
|
status: 'ACTIVE' | 'ARCHIVED'
|
||||||
): Promise<FixedProjectTypeFilter[]> {
|
): Promise<FixedProjectTypeFilter[]> {
|
||||||
let counts: number[] = []
|
let counts: number[] = []
|
||||||
|
|
||||||
@@ -434,7 +452,38 @@ export async function getFixedProjectTypeFilters(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTagCategoryGroups(): Promise<FilterTagCategoryGroup[]> {
|
const fixedProjectTypeFilterCache = new Map<
|
||||||
|
'ACTIVE' | 'ARCHIVED',
|
||||||
|
() => Promise<FixedProjectTypeFilter[]>
|
||||||
|
>()
|
||||||
|
|
||||||
|
function getFixedProjectTypeFilterCachedFetcher(
|
||||||
|
status: 'ACTIVE' | 'ARCHIVED'
|
||||||
|
): () => Promise<FixedProjectTypeFilter[]> {
|
||||||
|
const existing = fixedProjectTypeFilterCache.get(status)
|
||||||
|
if (existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetcher = unstable_cache(
|
||||||
|
async () => getFixedProjectTypeFiltersFromDb(status),
|
||||||
|
[`fixed-project-type-filters:${status}`],
|
||||||
|
{
|
||||||
|
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||||
|
tags: ['fixed-project-type-filters'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
fixedProjectTypeFilterCache.set(status, fetcher)
|
||||||
|
return fetcher
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFixedProjectTypeFilters(
|
||||||
|
status: 'ACTIVE' | 'ARCHIVED' = 'ACTIVE'
|
||||||
|
): Promise<FixedProjectTypeFilter[]> {
|
||||||
|
return getFixedProjectTypeFilterCachedFetcher(status)()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
|
||||||
let tags: TagWithProjectCount[] = []
|
let tags: TagWithProjectCount[] = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -501,6 +550,19 @@ export async function getTagCategoryGroups(): Promise<FilterTagCategoryGroup[]>
|
|||||||
.filter((group) => group.tags.length > 0)
|
.filter((group) => group.tags.length > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCachedTagCategoryGroups = unstable_cache(
|
||||||
|
getTagCategoryGroupsFromDb,
|
||||||
|
['tag-category-groups:v1'],
|
||||||
|
{
|
||||||
|
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||||
|
tags: ['tag-category-groups'],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export async function getTagCategoryGroups(): Promise<FilterTagCategoryGroup[]> {
|
||||||
|
return getCachedTagCategoryGroups()
|
||||||
|
}
|
||||||
|
|
||||||
// 定义 AI 搜索结果类型
|
// 定义 AI 搜索结果类型
|
||||||
export type AISearchResultItem = ProjectWithFlatTags & {
|
export type AISearchResultItem = ProjectWithFlatTags & {
|
||||||
similarity: number
|
similarity: number
|
||||||
|
|||||||
Reference in New Issue
Block a user