fix: add cache fallbacks for tag data

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