From 4b62f09d65eb1024f0a7ad719e1220f56b9e1d31 Mon Sep 17 00:00:00 2001 From: mzaxd Date: Sat, 18 Apr 2026 18:58:45 +0800 Subject: [PATCH] fix: add cache fallbacks for tag data --- src/app/api/tags/reset-projects/route.ts | 189 ++++++----- src/app/api/tags/route.ts | 36 +-- src/hooks/useHome.ts | 202 ++++++------ src/hooks/useProjects.ts | 388 +++++++++++------------ src/lib/cache.ts | 22 ++ 5 files changed, 418 insertions(+), 419 deletions(-) create mode 100644 src/lib/cache.ts diff --git a/src/app/api/tags/reset-projects/route.ts b/src/app/api/tags/reset-projects/route.ts index 8861e5e..426c89b 100644 --- a/src/app/api/tags/reset-projects/route.ts +++ b/src/app/api/tags/reset-projects/route.ts @@ -1,105 +1,93 @@ -import { NextRequest, NextResponse } from 'next/server' -import crypto from 'crypto' -import { revalidatePath } from 'next/cache' -import type { TagCategory } from '@prisma/client' -import { prisma } from '@/lib/prisma' +import { NextRequest, NextResponse } from "next/server"; +import { revalidatePath } from "next/cache"; +import type { TagCategory } from "@prisma/client"; +import { prisma } from "@/lib/prisma"; +import { isValidApiKey } from "@/lib/auth"; import { ProjectTagResetRequestSchema, type ProjectTagResetItem, type ResettableTagCategory, -} from '@/lib/validations' +} from "@/lib/validations"; type ResetResultItem = { - projectSlug: string - status: 'updated' | 'dry-run' | 'failed' - selectedTagCount: number - addedCount: number - removedCount: number - details: string[] -} + projectSlug: string; + status: "updated" | "dry-run" | "failed"; + selectedTagCount: number; + addedCount: number; + removedCount: number; + details: string[]; +}; const DEFAULT_RESET_CATEGORIES: ResettableTagCategory[] = [ - 'FIXED_PROJECT_TYPE', - 'TECH_STACK', - 'AI_PARADIGM', - 'PRODUCT_FORM', - 'DOMAIN_SCENARIO', -] - -function isApiKeyValid(providedApiKey: string, expectedApiKey?: string): boolean { - if (!expectedApiKey) { - return false - } - const providedBuf = Buffer.from(providedApiKey) - const expectedBuf = Buffer.from(expectedApiKey) - return ( - providedBuf.length === expectedBuf.length && - crypto.timingSafeEqual(providedBuf, expectedBuf) - ) -} + "FIXED_PROJECT_TYPE", + "TECH_STACK", + "AI_PARADIGM", + "PRODUCT_FORM", + "DOMAIN_SCENARIO", +]; function normalizeSlug(slug: string): string { - return slug.trim().toLowerCase() + return slug.trim().toLowerCase(); } function collectSelectedTagSlugs( item: ProjectTagResetItem, categories: ResettableTagCategory[] ): string[] { - const selectedTagSlugSet = new Set() + const selectedTagSlugSet = new Set(); for (const category of categories) { - const categorySlugs = item.selectedTagSlugsByCategory[category] || [] + const categorySlugs = item.selectedTagSlugsByCategory[category] || []; for (const slug of categorySlugs) { - selectedTagSlugSet.add(normalizeSlug(slug)) + selectedTagSlugSet.add(normalizeSlug(slug)); } } - return [...selectedTagSlugSet] + return [...selectedTagSlugSet]; } function collectValidationErrorsForProjectItem(params: { - item: ProjectTagResetItem - categories: ResettableTagCategory[] - existingTagBySlug: Map + item: ProjectTagResetItem; + categories: ResettableTagCategory[]; + existingTagBySlug: Map; }): string[] { - const { item, categories, existingTagBySlug } = params - const errors: string[] = [] + const { item, categories, existingTagBySlug } = params; + const errors: string[] = []; for (const category of categories) { for (const rawSlug of item.selectedTagSlugsByCategory[category] || []) { - const normalizedSlug = normalizeSlug(rawSlug) - const existingTag = existingTagBySlug.get(normalizedSlug) + const normalizedSlug = normalizeSlug(rawSlug); + const existingTag = existingTagBySlug.get(normalizedSlug); if (!existingTag) { - errors.push(`Unknown tag slug "${rawSlug}" in category ${category}`) - continue + errors.push(`Unknown tag slug "${rawSlug}" in category ${category}`); + continue; } if (existingTag.category !== category) { errors.push( `Tag slug "${rawSlug}" belongs to ${existingTag.category}, expected ${category}` - ) + ); } } } - return errors + return errors; } export async function POST(request: NextRequest) { try { - const body = await request.json() - const validation = ProjectTagResetRequestSchema.safeParse(body) + const body = await request.json(); + const validation = ProjectTagResetRequestSchema.safeParse(body); if (!validation.success) { return NextResponse.json( { success: false, - error: 'Validation error', + error: "Validation error", details: validation.error.errors.map((issue) => issue.message), }, { status: 400 } - ) + ); } const { @@ -108,27 +96,26 @@ export async function POST(request: NextRequest) { replaceAllCategories, projects, categories: requestedCategories, - } = validation.data + } = validation.data; - if (!isApiKeyValid(apiKey, process.env.WEBHOOK_API_KEY)) { + if (!isValidApiKey(apiKey)) { return NextResponse.json( { success: false, - error: 'Unauthorized', - details: ['Invalid or missing API Key'], + error: "Unauthorized", + details: ["Invalid or missing API Key"], }, { status: 401 } - ) + ); } - const categories = requestedCategories.length > 0 - ? requestedCategories - : DEFAULT_RESET_CATEGORIES + const categories = + requestedCategories.length > 0 ? requestedCategories : DEFAULT_RESET_CATEGORIES; - const normalizedProjectSlugs = projects.map((item) => normalizeSlug(item.projectSlug)) + const normalizedProjectSlugs = projects.map((item) => normalizeSlug(item.projectSlug)); const normalizedSelectedTagSlugs = [ ...new Set(projects.flatMap((item) => collectSelectedTagSlugs(item, categories))), - ] + ]; const [existingProjects, existingTags] = await Promise.all([ prisma.project.findMany({ @@ -157,63 +144,67 @@ export async function POST(request: NextRequest) { category: true, }, }), - ]) + ]); - const projectBySlug = new Map(existingProjects.map((project) => [project.slug, project])) - const existingTagBySlug = new Map(existingTags.map((tag) => [tag.slug, tag])) + const projectBySlug = new Map(existingProjects.map((project) => [project.slug, project])); + const existingTagBySlug = new Map(existingTags.map((tag) => [tag.slug, tag])); - const results: ResetResultItem[] = [] - const updatedProjectSlugs: string[] = [] + const results: ResetResultItem[] = []; + const updatedProjectSlugs: string[] = []; for (const item of projects) { - const projectSlug = normalizeSlug(item.projectSlug) - const project = projectBySlug.get(projectSlug) + const projectSlug = normalizeSlug(item.projectSlug); + const project = projectBySlug.get(projectSlug); if (!project) { results.push({ projectSlug, - status: 'failed', + status: "failed", selectedTagCount: 0, addedCount: 0, removedCount: 0, details: [`Project with slug "${projectSlug}" not found`], - }) - continue + }); + continue; } const validationErrors = collectValidationErrorsForProjectItem({ item, categories, existingTagBySlug, - }) + }); if (validationErrors.length > 0) { results.push({ projectSlug, - status: 'failed', + status: "failed", selectedTagCount: 0, addedCount: 0, removedCount: 0, details: validationErrors, - }) - continue + }); + continue; } const selectedTagIds = collectSelectedTagSlugs(item, categories) .map((slug) => existingTagBySlug.get(slug)?.id) - .filter((id): id is string => Boolean(id)) + .filter((id): id is string => Boolean(id)); const previousCategoryTagIds = new Set( project.tags - .filter((projectTag) => categories.includes(projectTag.tag.category as ResettableTagCategory)) + .filter((projectTag) => + categories.includes(projectTag.tag.category as ResettableTagCategory) + ) .map((projectTag) => projectTag.tag.id) - ) + ); - const nextTagIdSet = new Set(selectedTagIds) + const nextTagIdSet = new Set(selectedTagIds); const removedCount = replaceAllCategories ? [...previousCategoryTagIds].filter((tagId) => !nextTagIdSet.has(tagId)).length - : 0 - const addedCount = [...nextTagIdSet].filter((tagId) => !previousCategoryTagIds.has(tagId)).length + : 0; + const addedCount = [...nextTagIdSet].filter( + (tagId) => !previousCategoryTagIds.has(tagId) + ).length; if (!dryRun) { await prisma.$transaction(async (tx) => { @@ -227,7 +218,7 @@ export async function POST(request: NextRequest) { }, }, }, - }) + }); } if (selectedTagIds.length > 0) { @@ -237,32 +228,32 @@ export async function POST(request: NextRequest) { tagId, })), skipDuplicates: true, - }) + }); } - }) - updatedProjectSlugs.push(projectSlug) + }); + updatedProjectSlugs.push(projectSlug); } results.push({ projectSlug, - status: dryRun ? 'dry-run' : 'updated', + status: dryRun ? "dry-run" : "updated", selectedTagCount: selectedTagIds.length, addedCount, removedCount, details: [], - }) + }); } - const updatedCount = results.filter((item) => item.status === 'updated').length - const dryRunCount = results.filter((item) => item.status === 'dry-run').length - const failedCount = results.filter((item) => item.status === 'failed').length + const updatedCount = results.filter((item) => item.status === "updated").length; + const dryRunCount = results.filter((item) => item.status === "dry-run").length; + const failedCount = results.filter((item) => item.status === "failed").length; if (!dryRun && updatedProjectSlugs.length > 0) { - revalidatePath('/zh/projects', 'page') - revalidatePath('/en/projects', 'page') + revalidatePath("/zh/projects", "page"); + revalidatePath("/en/projects", "page"); for (const projectSlug of updatedProjectSlugs) { - revalidatePath(`/zh/projects/${projectSlug}`, 'page') - revalidatePath(`/en/projects/${projectSlug}`, 'page') + revalidatePath(`/zh/projects/${projectSlug}`, "page"); + revalidatePath(`/en/projects/${projectSlug}`, "page"); } } @@ -278,16 +269,16 @@ export async function POST(request: NextRequest) { failedCount, results, }, - }) + }); } catch (error) { - console.error('[POST /api/tags/reset-projects] Error:', error) + console.error("[POST /api/tags/reset-projects] Error:", error); return NextResponse.json( { success: false, - error: 'Internal server error', - details: [error instanceof Error ? error.message : 'Unknown error'], + error: "Internal server error", + details: [error instanceof Error ? error.message : "Unknown error"], }, { status: 500 } - ) + ); } } diff --git a/src/app/api/tags/route.ts b/src/app/api/tags/route.ts index e1400b4..4b3bc43 100644 --- a/src/app/api/tags/route.ts +++ b/src/app/api/tags/route.ts @@ -1,31 +1,31 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { unstable_cache } from "next/cache"; +import { runWithCacheFallback } from "@/lib/cache"; const TAGS_CACHE_REVALIDATE_SECONDS = 300; -const getCachedTags = unstable_cache( - async () => - prisma.tag.findMany({ - include: { - _count: { - select: { projects: true }, - }, +async function getTagsFromDb() { + return prisma.tag.findMany({ + include: { + _count: { + select: { projects: true }, }, - orderBy: { - name: "asc", - }, - }), - ["api-tags:v1"], - { - revalidate: TAGS_CACHE_REVALIDATE_SECONDS, - tags: ["api-tags"], - } -); + }, + orderBy: { + name: "asc", + }, + }); +} + +const getCachedTags = unstable_cache(getTagsFromDb, ["api-tags:v1"], { + revalidate: TAGS_CACHE_REVALIDATE_SECONDS, + tags: ["api-tags"], +}); export async function GET() { try { - const tags = await getCachedTags(); + const tags = await runWithCacheFallback(getCachedTags, getTagsFromDb); return NextResponse.json({ success: true, diff --git a/src/hooks/useHome.ts b/src/hooks/useHome.ts index 4b19d00..0ac1d7b 100644 --- a/src/hooks/useHome.ts +++ b/src/hooks/useHome.ts @@ -1,77 +1,82 @@ -import { prisma } from '@/lib/prisma' -import { getTopTags } from '@/hooks/useProjects' -import { Prisma } from '@prisma/client' -import { unstable_cache } from 'next/cache' +import { prisma } from "@/lib/prisma"; +import { getTopTags } from "@/hooks/useProjects"; +import { Prisma } from "@prisma/client"; +import { unstable_cache } from "next/cache"; +import { runWithCacheFallback } from "@/lib/cache"; -const ONE_DAY_MS = 24 * 60 * 60 * 1000 -const DEFAULT_RANKING_LIMIT = 6 -const DEFAULT_TIMELINE_LIMIT = 8 -const DEFAULT_TOP_TAG_LIMIT = 12 -const HOME_PAGE_REVALIDATE_SECONDS = 300 +const ONE_DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_RANKING_LIMIT = 6; +const DEFAULT_TIMELINE_LIMIT = 8; +const DEFAULT_TOP_TAG_LIMIT = 12; +const HOME_PAGE_REVALIDATE_SECONDS = 300; export type HomeProjectSummary = { - id: string - slug: string - name: string - nameEn: string | null - description: string - descriptionEn: string | null - githubStars: number - createdAt: string + id: string; + slug: string; + name: string; + nameEn: string | null; + description: string; + descriptionEn: string | null; + githubStars: number; + createdAt: string; tags: Array<{ - id: string - name: string - nameEn: string | null - slug: string - }> -} + id: string; + name: string; + nameEn: string | null; + slug: string; + }>; +}; export type HomePageData = { overview: { - totalProjects: number - newProjects30d: number - newProjects7d: number - newProjects24h: number - } + totalProjects: number; + newProjects30d: number; + newProjects7d: number; + newProjects24h: number; + }; rankings: { latestByWindow: { - '24h': HomeProjectSummary[] - '7d': HomeProjectSummary[] - '30d': HomeProjectSummary[] - } - topStars: HomeProjectSummary[] - } + "24h": HomeProjectSummary[]; + "7d": HomeProjectSummary[]; + "30d": HomeProjectSummary[]; + }; + topStars: HomeProjectSummary[]; + }; tagInsights: { topTags: Array<{ - id: string - name: string - nameEn: string | null - slug: string - projectCount: number - }> - } - timeline: HomeProjectSummary[] -} + id: string; + name: string; + nameEn: string | null; + slug: string; + projectCount: number; + }>; + }; + timeline: HomeProjectSummary[]; +}; type ProjectWithRelations = Prisma.ProjectGetPayload<{ include: { tags: { include: { - tag: true - } - } - } -}> + tag: true; + }; + }; + }; +}>; -async function safeQuery(operationName: string, fallback: T, task: () => Promise): Promise { +async function safeQuery( + operationName: string, + fallback: T, + task: () => Promise +): Promise { try { - return await task() + return await task(); } catch (error) { console.error( `[db] ${operationName} degraded to fallback:`, error instanceof Error ? error.message : String(error) - ) - return fallback + ); + return fallback; } } @@ -91,14 +96,17 @@ function mapProjectSummary(project: ProjectWithRelations): HomeProjectSummary { nameEn: projectTag.tag.nameEn, slug: projectTag.tag.slug, })), - } + }; } -async function getLatestProjects(limit: number, createdAfter?: Date): Promise { - const projects = await safeQuery('getLatestProjects', [] as ProjectWithRelations[], () => +async function getLatestProjects( + limit: number, + createdAfter?: Date +): Promise { + const projects = await safeQuery("getLatestProjects", [] as ProjectWithRelations[], () => prisma.project.findMany({ where: { - status: 'ACTIVE', + status: "ACTIVE", ...(createdAfter ? { createdAt: { gte: createdAfter } } : {}), }, include: { @@ -109,20 +117,20 @@ async function getLatestProjects(limit: number, createdAfter?: Date): Promise { - const projects = await safeQuery('getTopStarsProjects', [] as ProjectWithRelations[], () => + const projects = await safeQuery("getTopStarsProjects", [] as ProjectWithRelations[], () => prisma.project.findMany({ where: { - status: 'ACTIVE', + status: "ACTIVE", }, include: { tags: { @@ -131,12 +139,12 @@ async function getTopStarsProjects(limit: number): Promise }, }, }, - orderBy: [{ githubStars: 'desc' }, { createdAt: 'desc' }], + orderBy: [{ githubStars: "desc" }, { createdAt: "desc" }], take: limit, }) - ) + ); - return projects.map(mapProjectSummary) + return projects.map(mapProjectSummary); } function getLatestProjectsByWindow( @@ -144,17 +152,15 @@ function getLatestProjectsByWindow( createdAfter: Date, limit: number ): HomeProjectSummary[] { - return projects - .filter((project) => new Date(project.createdAt) >= createdAfter) - .slice(0, limit) + return projects.filter((project) => new Date(project.createdAt) >= createdAfter).slice(0, limit); } async function buildHomePageData(): Promise { - const now = Date.now() - const last24Hours = new Date(now - ONE_DAY_MS) - const last7Days = new Date(now - ONE_DAY_MS * 7) - const last30Days = new Date(now - ONE_DAY_MS * 30) - const latestProjectsLimit = Math.max(DEFAULT_RANKING_LIMIT, DEFAULT_TIMELINE_LIMIT) + const now = Date.now(); + const last24Hours = new Date(now - ONE_DAY_MS); + const last7Days = new Date(now - ONE_DAY_MS * 7); + const last30Days = new Date(now - ONE_DAY_MS * 30); + const latestProjectsLimit = Math.max(DEFAULT_RANKING_LIMIT, DEFAULT_TIMELINE_LIMIT); const [ totalProjects, @@ -165,31 +171,31 @@ async function buildHomePageData(): Promise { topStars, topTags, ] = await Promise.all([ - safeQuery('countTotalProjects', 0, () => prisma.project.count()), - safeQuery('countNewProjects30d', 0, () => + safeQuery("countTotalProjects", 0, () => prisma.project.count()), + safeQuery("countNewProjects30d", 0, () => prisma.project.count({ where: { - status: 'ACTIVE', + status: "ACTIVE", createdAt: { gte: last30Days, }, }, }) ), - safeQuery('countNewProjects7d', 0, () => + safeQuery("countNewProjects7d", 0, () => prisma.project.count({ where: { - status: 'ACTIVE', + status: "ACTIVE", createdAt: { gte: last7Days, }, }, }) ), - safeQuery('countNewProjects24h', 0, () => + safeQuery("countNewProjects24h", 0, () => prisma.project.count({ where: { - status: 'ACTIVE', + status: "ACTIVE", createdAt: { gte: last24Hours, }, @@ -199,24 +205,12 @@ async function buildHomePageData(): Promise { getLatestProjects(latestProjectsLimit), getTopStarsProjects(DEFAULT_RANKING_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) + 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 { overview: { @@ -227,9 +221,9 @@ async function buildHomePageData(): Promise { }, rankings: { latestByWindow: { - '24h': latest24h, - '7d': latest7d, - '30d': latest30d, + "24h": latest24h, + "7d": latest7d, + "30d": latest30d, }, topStars, }, @@ -243,14 +237,14 @@ async function buildHomePageData(): Promise { })), }, timeline, - } + }; } -const getCachedHomePageData = unstable_cache(buildHomePageData, ['home-page-data:v1'], { +const getCachedHomePageData = unstable_cache(buildHomePageData, ["home-page-data:v1"], { revalidate: HOME_PAGE_REVALIDATE_SECONDS, - tags: ['home-page-data'], -}) + tags: ["home-page-data"], +}); export async function getHomePageData(): Promise { - return getCachedHomePageData() + return runWithCacheFallback(getCachedHomePageData, buildHomePageData); } diff --git a/src/hooks/useProjects.ts b/src/hooks/useProjects.ts index 6d321e8..b773517 100644 --- a/src/hooks/useProjects.ts +++ b/src/hooks/useProjects.ts @@ -1,52 +1,54 @@ -import { prisma } from '@/lib/prisma' -import { Prisma, type TagCategory } from '@prisma/client' -import { unstable_cache } from 'next/cache' +import { prisma } from "@/lib/prisma"; +import { Prisma, type TagCategory } from "@prisma/client"; +import { unstable_cache } from "next/cache"; +import { runWithCacheFallback } from "@/lib/cache"; import { FIXED_PROJECT_TYPE_TAGS, TAG_CATEGORY_META, getTagCategoryOrder, isFixedProjectTypeSlug, type FixedProjectTypeSlug, -} from '@/lib/tag-taxonomy' +} from "@/lib/tag-taxonomy"; -const DB_RETRY_DELAYS_MS = [300, 900] as const +const DB_RETRY_DELAYS_MS = [300, 900] as const; function sleep(ms: number): Promise { return new Promise((resolve) => { - setTimeout(resolve, ms) - }) + setTimeout(resolve, ms); + }); } function isTransientDbError(error: unknown): boolean { if (error instanceof Prisma.PrismaClientInitializationError) { - return true + return true; } if (error instanceof Prisma.PrismaClientRustPanicError) { - return true + return true; } - const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase() + const message = + error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); return ( message.includes("can't reach database server") || - message.includes('p1001') || - message.includes('connection terminated') || - message.includes('timeout') || - message.includes('econnreset') - ) + message.includes("p1001") || + message.includes("connection terminated") || + message.includes("timeout") || + message.includes("econnreset") + ); } async function withDbRetry(operationName: string, task: () => Promise): Promise { - let lastError: unknown + let lastError: unknown; for (let attempt = 0; attempt <= DB_RETRY_DELAYS_MS.length; attempt += 1) { try { - return await task() + return await task(); } catch (error) { - lastError = error + lastError = error; if (!isTransientDbError(error) || attempt === DB_RETRY_DELAYS_MS.length) { - break + break; } - await sleep(DB_RETRY_DELAYS_MS[attempt] ?? 0) + await sleep(DB_RETRY_DELAYS_MS[attempt] ?? 0); } } @@ -54,56 +56,56 @@ async function withDbRetry(operationName: string, task: () => Promise): Pr `[db] ${operationName} failed: ${ lastError instanceof Error ? lastError.message : String(lastError) }` - ) + ); } // 定义带有标签和链接的项目类型 export type ProjectWithTagsAndLinks = Prisma.ProjectGetPayload<{ include: { - tags: { include: { tag: true } } - links: true - } -}> + tags: { include: { tag: true } }; + links: true; + }; +}>; // 定义扁平化标签的项目类型 -export type ProjectWithFlatTags = Omit & { - tags: Prisma.TagGetPayload<{}>[] -} +export type ProjectWithFlatTags = Omit & { + tags: Prisma.TagGetPayload<{}>[]; +}; // 定义标签计数类型 export type TagWithProjectCount = Prisma.TagGetPayload<{ include: { - _count: { select: { projects: true } } - } -}> + _count: { select: { projects: true } }; + }; +}>; export type FilterTagCategoryGroup = { - category: Exclude - name: string - nameEn: string - tags: TagWithProjectCount[] -} + category: Exclude; + name: string; + nameEn: string; + tags: TagWithProjectCount[]; +}; export type FixedProjectTypeFilter = { - slug: FixedProjectTypeSlug - name: string - nameEn: string - projectCount: number -} + slug: FixedProjectTypeSlug; + name: string; + nameEn: string; + projectCount: number; +}; -export const PROJECT_SORT_OPTIONS = ['latest', 'stars_desc', 'stars_asc'] as const -export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number] -const DEFAULT_PAGE = 1 -const DEFAULT_LIMIT = 10 -const MAX_LIMIT = 100 -const DEFAULT_CACHE_REVALIDATE_SECONDS = 300 +export const PROJECT_SORT_OPTIONS = ["latest", "stars_desc", "stars_asc"] as const; +export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number]; +const DEFAULT_PAGE = 1; +const DEFAULT_LIMIT = 10; +const MAX_LIMIT = 100; +const DEFAULT_CACHE_REVALIDATE_SECONDS = 300; export function normalizeProjectSort(value?: string): ProjectSortOption { - const candidate = String(value || '').trim() + const candidate = String(value || "").trim(); if (PROJECT_SORT_OPTIONS.includes(candidate as ProjectSortOption)) { - return candidate as ProjectSortOption + return candidate as ProjectSortOption; } - return 'latest' + return "latest"; } function normalizePositiveInteger( @@ -111,30 +113,30 @@ function normalizePositiveInteger( fallback: number, max?: number ): number { - const normalized = Number.isFinite(value) ? Math.trunc(value as number) : fallback - const bounded = normalized > 0 ? normalized : fallback - return typeof max === 'number' ? Math.min(bounded, max) : bounded + const normalized = Number.isFinite(value) ? Math.trunc(value as number) : fallback; + const bounded = normalized > 0 ? normalized : fallback; + return typeof max === "number" ? Math.min(bounded, max) : bounded; } export async function getProjects(options?: { - search?: string - tag?: string - tags?: string[] - domains?: string[] - productForms?: string[] - projectType?: string - sort?: ProjectSortOption - status?: 'ACTIVE' | 'ARCHIVED' - page?: number - limit?: number + search?: string; + tag?: string; + tags?: string[]; + domains?: string[]; + productForms?: string[]; + projectType?: string; + sort?: ProjectSortOption; + status?: "ACTIVE" | "ARCHIVED"; + page?: number; + limit?: number; }): Promise<{ - projects: ProjectWithFlatTags[] + projects: ProjectWithFlatTags[]; pagination: { - page: number - limit: number - total: number - totalPages: number - } + page: number; + limit: number; + total: number; + totalPages: number; + }; }> { const { search, @@ -143,65 +145,57 @@ export async function getProjects(options?: { domains = [], productForms = [], projectType, - sort = 'latest', - status = 'ACTIVE', + sort = "latest", + status = "ACTIVE", page = DEFAULT_PAGE, limit = DEFAULT_LIMIT, - } = options || {} + } = options || {}; - const safePage = normalizePositiveInteger(page, DEFAULT_PAGE) - const safeLimit = normalizePositiveInteger(limit, DEFAULT_LIMIT, MAX_LIMIT) + const safePage = normalizePositiveInteger(page, DEFAULT_PAGE); + const safeLimit = normalizePositiveInteger(limit, DEFAULT_LIMIT, MAX_LIMIT); const where: Prisma.ProjectWhereInput = { status, - } + }; // 添加搜索字符串长度验证 if (search && search.length >= 2 && search.length <= 100) { where.OR = [ - { name: { contains: search, mode: 'insensitive' } }, - { nameEn: { contains: search, mode: 'insensitive' } }, - { description: { contains: search, mode: 'insensitive' } }, - { descriptionEn: { contains: search, mode: 'insensitive' } }, - ] + { name: { contains: search, mode: "insensitive" } }, + { nameEn: { contains: search, mode: "insensitive" } }, + { description: { contains: search, mode: "insensitive" } }, + { descriptionEn: { contains: search, mode: "insensitive" } }, + ]; } - const andFilters: Prisma.ProjectWhereInput[] = [] + const andFilters: Prisma.ProjectWhereInput[] = []; const normalizedDomainSlugs = Array.from( - new Set( - domains - .map((value) => (value || '').trim()) - .filter((value) => value.length > 0) - ) - ) + new Set(domains.map((value) => (value || "").trim()).filter((value) => value.length > 0)) + ); const normalizedProductFormSlugs = Array.from( - new Set( - productForms - .map((value) => (value || '').trim()) - .filter((value) => value.length > 0) - ) - ) + new Set(productForms.map((value) => (value || "").trim()).filter((value) => value.length > 0)) + ); const normalizedTagSlugs = Array.from( new Set( [tag, ...tags] - .map((value) => (value || '').trim()) + .map((value) => (value || "").trim()) .filter((value) => value.length > 0) .filter((value) => !normalizedDomainSlugs.includes(value)) .filter((value) => !normalizedProductFormSlugs.includes(value)) ) - ) + ); for (const domainSlug of normalizedDomainSlugs) { andFilters.push({ tags: { some: { tag: { - category: 'DOMAIN_SCENARIO', + category: "DOMAIN_SCENARIO", slug: domainSlug, }, }, }, - }) + }); } for (const tagSlug of normalizedTagSlugs) { @@ -213,7 +207,7 @@ export async function getProjects(options?: { }, }, }, - }) + }); } for (const productFormSlug of normalizedProductFormSlugs) { @@ -221,12 +215,12 @@ export async function getProjects(options?: { tags: { some: { tag: { - category: 'PRODUCT_FORM', + category: "PRODUCT_FORM", slug: productFormSlug, }, }, }, - }) + }); } if (projectType && isFixedProjectTypeSlug(projectType)) { @@ -238,25 +232,25 @@ export async function getProjects(options?: { }, }, }, - }) + }); } if (andFilters.length > 0) { - where.AND = andFilters + where.AND = andFilters; } const orderBy: Prisma.ProjectOrderByWithRelationInput[] = - sort === 'stars_desc' - ? [{ githubStars: 'desc' }, { createdAt: 'desc' }] - : sort === 'stars_asc' - ? [{ githubStars: 'asc' }, { createdAt: 'desc' }] - : [{ createdAt: 'desc' }] + sort === "stars_desc" + ? [{ githubStars: "desc" }, { createdAt: "desc" }] + : sort === "stars_asc" + ? [{ githubStars: "asc" }, { createdAt: "desc" }] + : [{ createdAt: "desc" }]; - let projects: ProjectWithTagsAndLinks[] = [] - let total = 0 + let projects: ProjectWithTagsAndLinks[] = []; + let total = 0; try { - ;[projects, total] = await withDbRetry('getProjects', () => + [projects, total] = await withDbRetry("getProjects", () => Promise.all([ prisma.project.findMany({ where, @@ -274,19 +268,19 @@ export async function getProjects(options?: { }), prisma.project.count({ where }), ]) - ) + ); } catch (error) { console.error( - '[db] getProjects degraded to empty result:', + "[db] getProjects degraded to empty result:", error instanceof Error ? error.message : String(error) - ) + ); } // Transform tags to flatten the structure const transformedProjects = projects.map((project) => ({ ...project, tags: project.tags.map((pt) => pt.tag), - })) + })); return { projects: transformedProjects, @@ -296,11 +290,11 @@ export async function getProjects(options?: { total, totalPages: Math.ceil(total / safeLimit), }, - } + }; } export async function getProjectBySlug(slug: string): Promise { - const project = await withDbRetry('getProjectBySlug', () => + const project = await withDbRetry("getProjectBySlug", () => prisma.project.findUnique({ where: { slug }, include: { @@ -312,25 +306,25 @@ export async function getProjectBySlug(slug: string): Promise pt.tag), - } + }; } export async function getAllTags(): Promise { - return withDbRetry('getAllTags', () => + return withDbRetry("getAllTags", () => prisma.tag.findMany({ where: { category: { - not: 'FIXED_PROJECT_TYPE', + not: "FIXED_PROJECT_TYPE", }, }, include: { @@ -339,18 +333,18 @@ export async function getAllTags(): Promise { }, }, orderBy: { - name: 'asc', + name: "asc", }, }) - ) + ); } export async function getTagsWithProjectCounts(): Promise { - const tags = await withDbRetry('getTagsWithProjectCounts', () => + const tags = await withDbRetry("getTagsWithProjectCounts", () => prisma.tag.findMany({ where: { category: { - not: 'FIXED_PROJECT_TYPE', + not: "FIXED_PROJECT_TYPE", }, }, include: { @@ -359,16 +353,16 @@ export async function getTagsWithProjectCounts(): Promise }, }, orderBy: { - name: 'asc', + name: "asc", }, }) - ) + ); - return tags.filter(tag => tag._count.projects > 0) + return tags.filter((tag) => tag._count.projects > 0); } async function getTopTagsFromDb(limit: number): Promise { - return withDbRetry('getTopTags', () => + return withDbRetry("getTopTags", () => prisma.tag.findMany({ include: { _count: { @@ -377,49 +371,45 @@ async function getTopTagsFromDb(limit: number): Promise { }, where: { category: { - not: 'FIXED_PROJECT_TYPE', + not: "FIXED_PROJECT_TYPE", }, projects: { some: {}, }, }, - orderBy: [{ projects: { _count: 'desc' } }, { name: 'asc' }], + orderBy: [{ projects: { _count: "desc" } }, { name: "asc" }], take: limit, }) - ) + ); } -const topTagsCache = new Map Promise>() +const topTagsCache = new Map Promise>(); function getTopTagsCachedFetcher(limit: number): () => Promise { - const existing = topTagsCache.get(limit) + const existing = topTagsCache.get(limit); if (existing) { - return 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 + 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 { - return getTopTagsCachedFetcher(limit)() + return runWithCacheFallback(getTopTagsCachedFetcher(limit), () => getTopTagsFromDb(limit)); } async function getFixedProjectTypeFiltersFromDb( - status: 'ACTIVE' | 'ARCHIVED' + status: "ACTIVE" | "ARCHIVED" ): Promise { - let counts: number[] = [] + let counts: number[] = []; try { - counts = await withDbRetry('getFixedProjectTypeFilters', () => + counts = await withDbRetry("getFixedProjectTypeFilters", () => Promise.all( FIXED_PROJECT_TYPE_TAGS.map((type) => prisma.project.count({ @@ -436,12 +426,12 @@ async function getFixedProjectTypeFiltersFromDb( }) ) ) - ) + ); } catch (error) { console.error( - '[db] getFixedProjectTypeFilters degraded to zero counts:', + "[db] getFixedProjectTypeFilters degraded to zero counts:", error instanceof Error ? error.message : String(error) - ) + ); } return FIXED_PROJECT_TYPE_TAGS.map((type, index) => ({ @@ -449,20 +439,20 @@ async function getFixedProjectTypeFiltersFromDb( name: type.name, nameEn: type.nameEn, projectCount: counts[index] ?? 0, - })) + })); } const fixedProjectTypeFilterCache = new Map< - 'ACTIVE' | 'ARCHIVED', + "ACTIVE" | "ARCHIVED", () => Promise ->() +>(); function getFixedProjectTypeFilterCachedFetcher( - status: 'ACTIVE' | 'ARCHIVED' + status: "ACTIVE" | "ARCHIVED" ): () => Promise { - const existing = fixedProjectTypeFilterCache.get(status) + const existing = fixedProjectTypeFilterCache.get(status); if (existing) { - return existing + return existing; } const fetcher = unstable_cache( @@ -470,28 +460,30 @@ function getFixedProjectTypeFilterCachedFetcher( [`fixed-project-type-filters:${status}`], { revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS, - tags: ['fixed-project-type-filters'], + tags: ["fixed-project-type-filters"], } - ) - fixedProjectTypeFilterCache.set(status, fetcher) - return fetcher + ); + fixedProjectTypeFilterCache.set(status, fetcher); + return fetcher; } export async function getFixedProjectTypeFilters( - status: 'ACTIVE' | 'ARCHIVED' = 'ACTIVE' + status: "ACTIVE" | "ARCHIVED" = "ACTIVE" ): Promise { - return getFixedProjectTypeFilterCachedFetcher(status)() + return runWithCacheFallback(getFixedProjectTypeFilterCachedFetcher(status), () => + getFixedProjectTypeFiltersFromDb(status) + ); } async function getTagCategoryGroupsFromDb(): Promise { - let tags: TagWithProjectCount[] = [] + let tags: TagWithProjectCount[] = []; try { - tags = await withDbRetry('getTagCategoryGroups', () => + tags = await withDbRetry("getTagCategoryGroups", () => prisma.tag.findMany({ where: { category: { - not: 'FIXED_PROJECT_TYPE', + not: "FIXED_PROJECT_TYPE", }, projects: { some: {}, @@ -503,35 +495,35 @@ async function getTagCategoryGroupsFromDb(): Promise { }, }, orderBy: { - name: 'asc', + name: "asc", }, }) - ) + ); } catch (error) { console.error( - '[db] getTagCategoryGroups degraded to empty groups:', + "[db] getTagCategoryGroups degraded to empty groups:", error instanceof Error ? error.message : String(error) - ) + ); } - const groups = new Map, TagWithProjectCount[]>() + const groups = new Map, TagWithProjectCount[]>(); for (const category of getTagCategoryOrder()) { - if (category === 'FIXED_PROJECT_TYPE') { - continue + if (category === "FIXED_PROJECT_TYPE") { + continue; } - groups.set(category, []) + groups.set(category, []); } for (const tag of tags) { - if (tag.category === 'FIXED_PROJECT_TYPE') { - continue + if (tag.category === "FIXED_PROJECT_TYPE") { + continue; } - const current = groups.get(tag.category) + const current = groups.get(tag.category); if (!current) { - groups.set(tag.category, [tag]) - continue + groups.set(tag.category, [tag]); + continue; } - current.push(tag) + current.push(tag); } return Array.from(groups.entries()) @@ -540,39 +532,39 @@ async function getTagCategoryGroupsFromDb(): Promise { name: TAG_CATEGORY_META[category].name, nameEn: TAG_CATEGORY_META[category].nameEn, tags: categoryTags.sort((a, b) => { - const countDiff = b._count.projects - a._count.projects + const countDiff = b._count.projects - a._count.projects; if (countDiff !== 0) { - return countDiff + return countDiff; } - return a.name.localeCompare(b.name, 'zh') + return a.name.localeCompare(b.name, "zh"); }), })) - .filter((group) => group.tags.length > 0) + .filter((group) => group.tags.length > 0); } const getCachedTagCategoryGroups = unstable_cache( getTagCategoryGroupsFromDb, - ['tag-category-groups:v1'], + ["tag-category-groups:v1"], { revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS, - tags: ['tag-category-groups'], + tags: ["tag-category-groups"], } -) +); export async function getTagCategoryGroups(): Promise { - return getCachedTagCategoryGroups() + return runWithCacheFallback(getCachedTagCategoryGroups, getTagCategoryGroupsFromDb); } // 定义 AI 搜索结果类型 export type AISearchResultItem = ProjectWithFlatTags & { - similarity: number -} + similarity: number; +}; // n8n 返回的简化搜索结果类型 export type N8NSearchResult = { - id: string - similarity: number -} + id: string; + similarity: number; +}; /** * 根据 ID 列表批量获取项目(用于 AI 搜索结果组装) @@ -581,10 +573,10 @@ export type N8NSearchResult = { */ export async function getProjectsByIds(ids: string[]): Promise { if (ids.length === 0) { - return [] + return []; } - const projects = await withDbRetry('getProjectsByIds', () => + const projects = await withDbRetry("getProjectsByIds", () => prisma.project.findMany({ where: { id: { @@ -600,11 +592,11 @@ export async function getProjectsByIds(ids: string[]): Promise ({ ...project, tags: project.tags.map((pt) => pt.tag), - })) + })); } diff --git a/src/lib/cache.ts b/src/lib/cache.ts new file mode 100644 index 0000000..acc928a --- /dev/null +++ b/src/lib/cache.ts @@ -0,0 +1,22 @@ +export function isUnstableCacheUnavailableError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return error.message.toLowerCase().includes("incrementalcache missing in unstable_cache"); +} + +export async function runWithCacheFallback( + cachedFetcher: () => Promise, + fallbackFetcher: () => Promise +): Promise { + try { + return await cachedFetcher(); + } catch (error) { + if (!isUnstableCacheUnavailableError(error)) { + throw error; + } + + return fallbackFetcher(); + } +}