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 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<string>()
const selectedTagSlugSet = new Set<string>();
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<string, { id: string; slug: string; category: TagCategory }>
item: ProjectTagResetItem;
categories: ResettableTagCategory[];
existingTagBySlug: Map<string, { id: string; slug: string; category: TagCategory }>;
}): 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 }
)
);
}
}
+18 -18
View File
@@ -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,
+98 -104
View File
@@ -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<T>(operationName: string, fallback: T, task: () => Promise<T>): Promise<T> {
async function safeQuery<T>(
operationName: string,
fallback: T,
task: () => Promise<T>
): Promise<T> {
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<HomeProjectSummary[]> {
const projects = await safeQuery('getLatestProjects', [] as ProjectWithRelations[], () =>
async function getLatestProjects(
limit: number,
createdAfter?: Date
): Promise<HomeProjectSummary[]> {
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<Ho
},
},
orderBy: {
createdAt: 'desc',
createdAt: "desc",
},
take: limit,
})
)
);
return projects.map(mapProjectSummary)
return projects.map(mapProjectSummary);
}
async function getTopStarsProjects(limit: number): Promise<HomeProjectSummary[]> {
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<HomeProjectSummary[]>
},
},
},
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<HomePageData> {
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<HomePageData> {
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<HomePageData> {
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<HomePageData> {
},
rankings: {
latestByWindow: {
'24h': latest24h,
'7d': latest7d,
'30d': latest30d,
"24h": latest24h,
"7d": latest7d,
"30d": latest30d,
},
topStars,
},
@@ -243,14 +237,14 @@ async function buildHomePageData(): Promise<HomePageData> {
})),
},
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<HomePageData> {
return getCachedHomePageData()
return runWithCacheFallback(getCachedHomePageData, buildHomePageData);
}
+190 -198
View File
@@ -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<void> {
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<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) {
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<T>(operationName: string, task: () => Promise<T>): 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<ProjectWithTagsAndLinks, 'tags'> & {
tags: Prisma.TagGetPayload<{}>[]
}
export type ProjectWithFlatTags = Omit<ProjectWithTagsAndLinks, "tags"> & {
tags: Prisma.TagGetPayload<{}>[];
};
// 定义标签计数类型
export type TagWithProjectCount = Prisma.TagGetPayload<{
include: {
_count: { select: { projects: true } }
}
}>
_count: { select: { projects: true } };
};
}>;
export type FilterTagCategoryGroup = {
category: Exclude<TagCategory, 'FIXED_PROJECT_TYPE'>
name: string
nameEn: string
tags: TagWithProjectCount[]
}
category: Exclude<TagCategory, "FIXED_PROJECT_TYPE">;
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<ProjectWithFlatTags | null> {
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<ProjectWithFlatTag
links: true,
},
})
)
);
if (!project) {
return null
return null;
}
// Transform tags to flatten the structure
return {
...project,
tags: project.tags.map((pt) => pt.tag),
}
};
}
export async function getAllTags(): Promise<TagWithProjectCount[]> {
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<TagWithProjectCount[]> {
},
},
orderBy: {
name: 'asc',
name: "asc",
},
})
)
);
}
export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]> {
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<TagWithProjectCount[]>
},
},
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[]> {
return withDbRetry('getTopTags', () =>
return withDbRetry("getTopTags", () =>
prisma.tag.findMany({
include: {
_count: {
@@ -377,49 +371,45 @@ async function getTopTagsFromDb(limit: number): Promise<TagWithProjectCount[]> {
},
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<number, () => Promise<TagWithProjectCount[]>>()
const topTagsCache = new Map<number, () => Promise<TagWithProjectCount[]>>();
function getTopTagsCachedFetcher(limit: number): () => Promise<TagWithProjectCount[]> {
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<TagWithProjectCount[]> {
return getTopTagsCachedFetcher(limit)()
return runWithCacheFallback(getTopTagsCachedFetcher(limit), () => getTopTagsFromDb(limit));
}
async function getFixedProjectTypeFiltersFromDb(
status: 'ACTIVE' | 'ARCHIVED'
status: "ACTIVE" | "ARCHIVED"
): Promise<FixedProjectTypeFilter[]> {
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<FixedProjectTypeFilter[]>
>()
>();
function getFixedProjectTypeFilterCachedFetcher(
status: 'ACTIVE' | 'ARCHIVED'
status: "ACTIVE" | "ARCHIVED"
): () => Promise<FixedProjectTypeFilter[]> {
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<FixedProjectTypeFilter[]> {
return getFixedProjectTypeFilterCachedFetcher(status)()
return runWithCacheFallback(getFixedProjectTypeFilterCachedFetcher(status), () =>
getFixedProjectTypeFiltersFromDb(status)
);
}
async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
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<FilterTagCategoryGroup[]> {
},
},
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<Exclude<TagCategory, 'FIXED_PROJECT_TYPE'>, TagWithProjectCount[]>()
const groups = new Map<Exclude<TagCategory, "FIXED_PROJECT_TYPE">, 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<FilterTagCategoryGroup[]> {
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<FilterTagCategoryGroup[]> {
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<ProjectWithFlatTags[]> {
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<ProjectWithFlatTa
links: true,
},
})
)
);
// Transform tags to flatten the structure
return projects.map((project) => ({
...project,
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();
}
}