fix: add cache fallbacks for tag data
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user