perf: harden lovart home data loading
This commit is contained in:
parent
bac7d6f196
commit
104187e2fe
@ -4,6 +4,21 @@ import { db } from "@/lib/db";
|
||||
import { authSettings } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { publicJson, withTimeout } from "@/lib/public-api/performance";
|
||||
|
||||
const PUBLIC_AUTH_SETTINGS_FALLBACK = {
|
||||
dingTalkAppKey: "",
|
||||
isDingTalkEnabled: false,
|
||||
isGoogleEnabled: false,
|
||||
authUrl: process.env.AUTH_URL || "",
|
||||
isGoogleConfigured: false,
|
||||
isRegistrationEnabled: true,
|
||||
isEmailVerificationEnabled: false,
|
||||
emailVerificationExpiresMinutes: 10,
|
||||
isEmailServiceConfigured: false,
|
||||
dingTalkOrganizations: [],
|
||||
organizations: [],
|
||||
};
|
||||
|
||||
function jsonNoStore(data: unknown, init?: ResponseInit) {
|
||||
const response = NextResponse.json(data, init);
|
||||
@ -16,11 +31,16 @@ function jsonNoStore(data: unknown, init?: ResponseInit) {
|
||||
|
||||
// GET /api/auth-settings - 获取认证配置
|
||||
export async function GET() {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const session = await auth();
|
||||
const isAdmin = session?.user?.type === "admin";
|
||||
|
||||
const [settings] = await db.select().from(authSettings).limit(1);
|
||||
const [settings] = await withTimeout(
|
||||
db.select().from(authSettings).limit(1),
|
||||
1600,
|
||||
"auth-settings"
|
||||
);
|
||||
const resolvedGoogleClientId =
|
||||
settings?.googleClientId || process.env.GOOGLE_CLIENT_ID || "";
|
||||
const resolvedGoogleClientSecret =
|
||||
@ -100,10 +120,12 @@ export async function GET() {
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Auth Settings API] Get error:", error);
|
||||
return jsonNoStore(
|
||||
{ error: "Failed to fetch auth settings" },
|
||||
{ status: 500 }
|
||||
);
|
||||
return publicJson(PUBLIC_AUTH_SETTINGS_FALLBACK, {
|
||||
cacheStatus: "FALLBACK",
|
||||
cacheControl: "no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||
fallback: true,
|
||||
startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,46 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextRequest } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage, user, aiModel } from "@/lib/db/schema";
|
||||
import { eq, desc, sql } from "drizzle-orm";
|
||||
import { cachedPublicJson } from "@/lib/public-api/performance";
|
||||
|
||||
const FEATURED_TIMEOUT_MS = 1800;
|
||||
const FEATURED_DEFAULT_PAGE_SIZE = 24;
|
||||
const FEATURED_MAX_PAGE_SIZE = 48;
|
||||
|
||||
function clampPositiveInt(value: string | null, fallback: number, max: number) {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(parsed, max);
|
||||
}
|
||||
|
||||
// GET: 获取精选图片列表
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = Number.parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = Number.parseInt(searchParams.get("pageSize") || "24");
|
||||
const page = clampPositiveInt(searchParams.get("page"), 1, 500);
|
||||
const pageSize = clampPositiveInt(
|
||||
searchParams.get("pageSize"),
|
||||
FEATURED_DEFAULT_PAGE_SIZE,
|
||||
FEATURED_MAX_PAGE_SIZE
|
||||
);
|
||||
const offset = (page - 1) * pageSize;
|
||||
const fallbackData = {
|
||||
images: [],
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
},
|
||||
};
|
||||
|
||||
return cachedPublicJson({
|
||||
cacheKey: `featured:${page}:${pageSize}`,
|
||||
fallbackData,
|
||||
timeoutMs: FEATURED_TIMEOUT_MS,
|
||||
load: async () => {
|
||||
// 查询精选图片,关联用户和模型信息
|
||||
const featuredImages = await db
|
||||
.select({
|
||||
@ -28,6 +58,7 @@ export async function GET(request: NextRequest) {
|
||||
userEmail: user.email,
|
||||
userDepartment: user.department,
|
||||
modelName: aiModel.displayName,
|
||||
totalCount: sql<number>`count(*) over()::int`,
|
||||
})
|
||||
.from(generatedImage)
|
||||
.leftJoin(user, eq(generatedImage.userId, user.id))
|
||||
@ -37,17 +68,11 @@ export async function GET(request: NextRequest) {
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
// 获取总数
|
||||
const totalResult = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(generatedImage)
|
||||
.where(eq(generatedImage.isFeatured, true));
|
||||
|
||||
const total = totalResult[0]?.count || 0;
|
||||
const total = featuredImages[0]?.totalCount || 0;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return NextResponse.json({
|
||||
images: featuredImages.map(img => ({
|
||||
return {
|
||||
images: featuredImages.map((img) => ({
|
||||
id: img.id,
|
||||
thumbnailUrl: img.thumbnailUrl,
|
||||
originalUrl: img.originalUrl,
|
||||
@ -70,9 +95,7 @@ export async function GET(request: NextRequest) {
|
||||
total,
|
||||
totalPages,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Featured API] Error:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch featured images" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,39 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
HOME_CHAT_ANIMATION_CONFIG_KEY,
|
||||
resolvePublicHomeChatAnimationConfig,
|
||||
} from "@/lib/home/home-animation-config";
|
||||
import { cachedPublicJson } from "@/lib/public-api/performance";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const setting = await getSystemSetting({
|
||||
key: HOME_CHAT_ANIMATION_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
|
||||
const payload = resolvePublicHomeChatAnimationConfig(setting?.value);
|
||||
|
||||
return NextResponse.json(payload, {
|
||||
headers: {
|
||||
"Cache-Control": "no-store, max-age=0",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{
|
||||
const FALLBACK_HOME_ANIMATION = {
|
||||
enabled: false,
|
||||
fileName: "",
|
||||
htmlContent: "",
|
||||
updatedAt: null,
|
||||
usesDefaultFallback: false,
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
return cachedPublicJson({
|
||||
cacheKey: "home-animation",
|
||||
fallbackData: FALLBACK_HOME_ANIMATION,
|
||||
timeoutMs: 1600,
|
||||
load: async () => {
|
||||
const setting = await getSystemSetting({
|
||||
key: HOME_CHAT_ANIMATION_CONFIG_KEY,
|
||||
}).catch(() => null);
|
||||
|
||||
return resolvePublicHomeChatAnimationConfig(setting?.value);
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store, max-age=0",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -4,24 +4,24 @@ import { db } from "@/lib/db";
|
||||
import { navLinks } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { cachedPublicJson } from "@/lib/public-api/performance";
|
||||
|
||||
// GET /api/nav-links - 获取所有导航链接 (公开)
|
||||
export async function GET() {
|
||||
try {
|
||||
return cachedPublicJson({
|
||||
cacheKey: "nav-links",
|
||||
fallbackData: { links: [] },
|
||||
timeoutMs: 1600,
|
||||
load: async () => {
|
||||
const links = await db
|
||||
.select()
|
||||
.from(navLinks)
|
||||
.where(eq(navLinks.isEnabled, true))
|
||||
.orderBy(navLinks.order);
|
||||
|
||||
return NextResponse.json({ links });
|
||||
} catch (error) {
|
||||
console.error("[Nav Links API] Get error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch nav links" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return { links };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
|
||||
@ -116,6 +116,7 @@ import {
|
||||
normalizeEnhanceProKreaConfig,
|
||||
} from "@/lib/enhance-pro/config";
|
||||
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
|
||||
import { withTimeout } from "@/lib/public-api/performance";
|
||||
import type { AgentCategoryTreeConfig } from "@/types/agent";
|
||||
|
||||
// Historically stored as "home_surprise_categories" but used for the featured gallery categories.
|
||||
@ -504,13 +505,44 @@ const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
||||
"home",
|
||||
]);
|
||||
|
||||
const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
||||
siteName: "AI Chatbot",
|
||||
siteLogo: null,
|
||||
siteLogoDark: null,
|
||||
favicon: null,
|
||||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: "立即升级",
|
||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
||||
surpriseCategories: ["全部", "精选"],
|
||||
canvasDefaultModelId: null,
|
||||
lovartComposerPrefix: "让Buyi-ai",
|
||||
lovartComposerPreviewTexts: [],
|
||||
lovartPromptTagVisibility: {},
|
||||
contactConfig: normalizeContactConfig(undefined),
|
||||
membershipConfig: normalizeMembershipConfig(undefined),
|
||||
toolbox1Name: "工具箱",
|
||||
toolbox1Enabled: true,
|
||||
toolbox2Name: "工具箱2",
|
||||
toolbox2Enabled: false,
|
||||
productPlanningEnabled: true,
|
||||
agentEnabled: true,
|
||||
videoCanvasEnabled: true,
|
||||
tutorialEnabled: false,
|
||||
tutorialVideoUrl: null,
|
||||
pointsPackages: normalizePointsPackages(undefined),
|
||||
dulupayConfig: normalizeDulupayForPublic(undefined),
|
||||
lovartHomeSidebarConfig: normalizeLovartHomeSidebarConfig(undefined),
|
||||
};
|
||||
|
||||
function createScopedSiteSettingsResponse(
|
||||
scope: PublicSiteSettingsScope,
|
||||
data: Record<string, unknown>,
|
||||
cacheStatus: "HIT" | "MISS" = "MISS",
|
||||
cacheKey: string = scope
|
||||
cacheStatus: "HIT" | "MISS" | "STALE" | "FALLBACK" = "MISS",
|
||||
cacheKey: string = scope,
|
||||
options: { fallback?: boolean; startedAt?: number } = {}
|
||||
) {
|
||||
if (cacheStatus !== "HIT") {
|
||||
if (cacheStatus === "MISS") {
|
||||
setScopedSiteSettingsCache(
|
||||
cacheKey,
|
||||
data,
|
||||
@ -527,6 +559,14 @@ function createScopedSiteSettingsResponse(
|
||||
);
|
||||
response.headers.set("X-Site-Settings-Scope", scope);
|
||||
response.headers.set("X-Site-Settings-Cache", cacheStatus);
|
||||
response.headers.set("X-Cache", cacheStatus);
|
||||
response.headers.set("X-Fallback", options.fallback ? "1" : "0");
|
||||
if (options.startedAt) {
|
||||
response.headers.set(
|
||||
"Server-Timing",
|
||||
`app;dur=${Math.max(0, Date.now() - options.startedAt)}`
|
||||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
@ -883,6 +923,7 @@ async function getAgentWorkflowAdminSettingsResponse() {
|
||||
// GET /api/site-settings - 获取网站设置 (公开)
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
const scope = request.nextUrl.searchParams.get("scope") || "";
|
||||
if (PUBLIC_SITE_SETTINGS_SCOPES.has(scope)) {
|
||||
const scopedScope = scope as PublicSiteSettingsScope;
|
||||
@ -902,14 +943,33 @@ export async function GET(request: NextRequest) {
|
||||
cacheKey
|
||||
);
|
||||
}
|
||||
if (cached) {
|
||||
if (cached && scopedScope !== "home") {
|
||||
deleteScopedSiteSettingsCache(cacheKey);
|
||||
}
|
||||
|
||||
return getScopedPublicSiteSettings(scopedScope, {
|
||||
try {
|
||||
const responsePromise = getScopedPublicSiteSettings(scopedScope, {
|
||||
brandName,
|
||||
cacheKey,
|
||||
});
|
||||
return scopedScope === "home"
|
||||
? await withTimeout(responsePromise, 1600, "site-settings:home")
|
||||
: await responsePromise;
|
||||
} catch (error) {
|
||||
if (scopedScope !== "home") {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[Site Settings API] Home fallback: ${message}`);
|
||||
return createScopedSiteSettingsResponse(
|
||||
scopedScope,
|
||||
cached?.data ?? HOME_SITE_SETTINGS_FALLBACK,
|
||||
cached ? "STALE" : "FALLBACK",
|
||||
cacheKey,
|
||||
{ fallback: true, startedAt }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const session = await auth().catch(() => null);
|
||||
|
||||
@ -4,24 +4,24 @@ import { db } from "@/lib/db";
|
||||
import { tutorialVideos } from "@/lib/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { cachedPublicJson } from "@/lib/public-api/performance";
|
||||
|
||||
// GET /api/tutorial-videos - 获取所有视频教程 (公开)
|
||||
export async function GET() {
|
||||
try {
|
||||
return cachedPublicJson({
|
||||
cacheKey: "tutorial-videos",
|
||||
fallbackData: { videos: [] },
|
||||
timeoutMs: 1600,
|
||||
load: async () => {
|
||||
const videos = await db
|
||||
.select()
|
||||
.from(tutorialVideos)
|
||||
.where(eq(tutorialVideos.isEnabled, true))
|
||||
.orderBy(asc(tutorialVideos.order));
|
||||
|
||||
return NextResponse.json({ videos });
|
||||
} catch (error) {
|
||||
console.error("[Tutorial Videos API] Get error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch tutorial videos" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
return { videos };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const createSchema = z.object({
|
||||
|
||||
@ -478,7 +478,9 @@ export function LovartHome({
|
||||
const [typedPreviewText, setTypedPreviewText] = useState("");
|
||||
const [previewCursorVisible, setPreviewCursorVisible] = useState(true);
|
||||
const [isSecondaryDataReady, setIsSecondaryDataReady] = useState(false);
|
||||
const [isFeaturedDataReady, setIsFeaturedDataReady] = useState(false);
|
||||
const projectSectionRef = useRef<HTMLDivElement | null>(null);
|
||||
const featuredSectionRef = useRef<HTMLElement | null>(null);
|
||||
const featuredLoadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
const filesRef = useRef<UploadFileItem[]>([]);
|
||||
|
||||
@ -506,6 +508,35 @@ export function LovartHome({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || isFeaturedDataReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const loadFeaturedData = () => setIsFeaturedDataReady(true);
|
||||
const fallbackTimer = window.setTimeout(loadFeaturedData, 2500);
|
||||
|
||||
if (!featuredSectionRef.current || !("IntersectionObserver" in window)) {
|
||||
return () => window.clearTimeout(fallbackTimer);
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
loadFeaturedData();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "900px 0px 900px 0px" }
|
||||
);
|
||||
|
||||
observer.observe(featuredSectionRef.current);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(fallbackTimer);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [isFeaturedDataReady]);
|
||||
|
||||
const { data: canvasData, mutate: mutateCanvas } = useSWR<{
|
||||
success: boolean;
|
||||
data: CanvasProject[];
|
||||
@ -514,7 +545,7 @@ export function LovartHome({
|
||||
const { data: featuredPages, size: featuredPageCount, setSize: setFeaturedPageCount, isValidating: isFeaturedValidating } =
|
||||
useSWRInfinite<FeaturedResponse>(
|
||||
(pageIndex, previousPageData) => {
|
||||
if (!isSecondaryDataReady) {
|
||||
if (!isSecondaryDataReady || !isFeaturedDataReady) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
@ -635,7 +666,7 @@ export function LovartHome({
|
||||
);
|
||||
|
||||
const { data: authSettingsData } = useSWR<PublicAuthSettings>(
|
||||
isSecondaryDataReady ? "/api/auth-settings" : null,
|
||||
loginDialogOpen ? "/api/auth-settings" : null,
|
||||
fetcher
|
||||
);
|
||||
const { data: homeAnimationData } = useSWR<{
|
||||
@ -2315,7 +2346,7 @@ export function LovartHome({
|
||||
</section>
|
||||
|
||||
{!isProjectsOnlyView && (
|
||||
<section className="mt-16">
|
||||
<section ref={featuredSectionRef} className="mt-16">
|
||||
<h2 className="text-[18px] font-semibold tracking-tight">灵感发现</h2>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
import { getEnvInt } from "@/lib/public-api/performance";
|
||||
|
||||
const client = postgres(process.env.POSTGRES_URL!);
|
||||
const client = postgres(process.env.POSTGRES_URL!, {
|
||||
connect_timeout: getEnvInt("POSTGRES_CONNECT_TIMEOUT_SECONDS", 5, 1, 60),
|
||||
idle_timeout: getEnvInt("POSTGRES_IDLE_TIMEOUT_SECONDS", 20, 1, 300),
|
||||
max: getEnvInt("POSTGRES_MAX_CONNECTIONS", 10, 1, 50),
|
||||
prepare: false,
|
||||
});
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
|
||||
3
lib/db/migrations/0083_optimize_featured_home_query.sql
Normal file
3
lib/db/migrations/0083_optimize_featured_home_query.sql
Normal file
@ -0,0 +1,3 @@
|
||||
CREATE INDEX IF NOT EXISTS "GeneratedImage_featured_featuredAt_idx"
|
||||
ON "GeneratedImage" ("isFeatured", "featuredAt" DESC)
|
||||
WHERE "isFeatured" = true;
|
||||
@ -484,6 +484,13 @@
|
||||
"when": 1779267679691,
|
||||
"tag": "0082_add_client_performance_events",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 69,
|
||||
"version": "7",
|
||||
"when": 1779797137842,
|
||||
"tag": "0083_optimize_featured_home_query",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
143
lib/public-api/performance.ts
Normal file
143
lib/public-api/performance.ts
Normal file
@ -0,0 +1,143 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
type CacheStatus = "HIT" | "MISS" | "STALE" | "FALLBACK";
|
||||
|
||||
type PublicCacheEntry<T> = {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
staleUntil: number;
|
||||
};
|
||||
|
||||
const PUBLIC_API_CACHE_KEY = Symbol.for("ai-chatbot.public-api-cache");
|
||||
|
||||
function getStore<T>() {
|
||||
const globalCache = globalThis as typeof globalThis & {
|
||||
[PUBLIC_API_CACHE_KEY]?: Map<string, PublicCacheEntry<T>>;
|
||||
};
|
||||
|
||||
if (!globalCache[PUBLIC_API_CACHE_KEY]) {
|
||||
globalCache[PUBLIC_API_CACHE_KEY] = new Map<string, PublicCacheEntry<T>>();
|
||||
}
|
||||
|
||||
return globalCache[PUBLIC_API_CACHE_KEY]!;
|
||||
}
|
||||
|
||||
export function getEnvInt(
|
||||
name: string,
|
||||
fallback: number,
|
||||
min: number,
|
||||
max: number
|
||||
) {
|
||||
const value = Number.parseInt(process.env[name] || "", 10);
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label = "operation"
|
||||
) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function publicJson(
|
||||
data: unknown,
|
||||
{
|
||||
cacheStatus,
|
||||
cacheControl = "private, max-age=30, stale-while-revalidate=120",
|
||||
fallback = false,
|
||||
startedAt,
|
||||
status,
|
||||
}: {
|
||||
cacheStatus: CacheStatus;
|
||||
cacheControl?: string;
|
||||
fallback?: boolean;
|
||||
startedAt: number;
|
||||
status?: number;
|
||||
}
|
||||
) {
|
||||
const durationMs = Math.max(0, Date.now() - startedAt);
|
||||
const response = NextResponse.json(data, status ? { status } : undefined);
|
||||
response.headers.set("Cache-Control", cacheControl);
|
||||
response.headers.set("X-Cache", cacheStatus);
|
||||
response.headers.set("X-Fallback", fallback ? "1" : "0");
|
||||
response.headers.set("Server-Timing", `app;dur=${durationMs}`);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function cachedPublicJson<T extends Record<string, unknown>>({
|
||||
cacheKey,
|
||||
fallbackData,
|
||||
load,
|
||||
timeoutMs = 1800,
|
||||
ttlMs = 30_000,
|
||||
staleMs = 5 * 60_000,
|
||||
cacheControl,
|
||||
}: {
|
||||
cacheKey: string;
|
||||
fallbackData: T;
|
||||
load: () => Promise<T>;
|
||||
timeoutMs?: number;
|
||||
ttlMs?: number;
|
||||
staleMs?: number;
|
||||
cacheControl?: string;
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
const store = getStore<T>();
|
||||
const cached = store.get(cacheKey);
|
||||
|
||||
if (cached && cached.expiresAt > startedAt) {
|
||||
return publicJson(cached.data, {
|
||||
cacheStatus: "HIT",
|
||||
cacheControl,
|
||||
startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await withTimeout(load(), timeoutMs, cacheKey);
|
||||
store.set(cacheKey, {
|
||||
data,
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
staleUntil: Date.now() + ttlMs + staleMs,
|
||||
});
|
||||
return publicJson(data, {
|
||||
cacheStatus: "MISS",
|
||||
cacheControl,
|
||||
startedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[Public API] ${cacheKey} fallback: ${message}`);
|
||||
|
||||
if (cached && cached.staleUntil > Date.now()) {
|
||||
return publicJson(cached.data, {
|
||||
cacheStatus: "STALE",
|
||||
cacheControl,
|
||||
fallback: true,
|
||||
startedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return publicJson(fallbackData, {
|
||||
cacheStatus: "FALLBACK",
|
||||
cacheControl,
|
||||
fallback: true,
|
||||
startedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
proxy.ts
8
proxy.ts
@ -252,6 +252,14 @@ export async function proxy(request: NextRequest) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 允许匿名访问新版首页的轻量渐进配置接口
|
||||
if (
|
||||
request.method === "GET" &&
|
||||
["/api/nav-links", "/api/tutorial-videos", "/api/home-animation"].includes(pathname)
|
||||
) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// 允许访问钉钉回调页面(public 文件夹中的文件)
|
||||
if (pathname === "/dingtalk-callback.html") {
|
||||
return NextResponse.next();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user