perf: harden lovart home data loading

This commit is contained in:
Your Name 2026-05-26 20:10:36 +08:00
parent bac7d6f196
commit 104187e2fe
12 changed files with 437 additions and 143 deletions

View File

@ -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,
});
}
}

View File

@ -1,78 +1,101 @@
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 offset = (page - 1) * pageSize;
const searchParams = request.nextUrl.searchParams;
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,
},
};
// 查询精选图片,关联用户和模型信息
const featuredImages = await db
.select({
id: generatedImage.id,
thumbnailUrl: generatedImage.thumbnailUrl,
originalUrl: generatedImage.originalUrl,
imageSize: generatedImage.imageSize,
prompt: generatedImage.prompt,
modelId: generatedImage.modelId,
featuredCategory: generatedImage.featuredCategory,
createdAt: generatedImage.createdAt,
featuredAt: generatedImage.featuredAt,
userId: generatedImage.userId,
userName: user.name,
userEmail: user.email,
userDepartment: user.department,
modelName: aiModel.displayName,
})
.from(generatedImage)
.leftJoin(user, eq(generatedImage.userId, user.id))
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
.where(eq(generatedImage.isFeatured, true))
.orderBy(desc(generatedImage.featuredAt))
.limit(pageSize)
.offset(offset);
return cachedPublicJson({
cacheKey: `featured:${page}:${pageSize}`,
fallbackData,
timeoutMs: FEATURED_TIMEOUT_MS,
load: async () => {
// 查询精选图片,关联用户和模型信息
const featuredImages = await db
.select({
id: generatedImage.id,
thumbnailUrl: generatedImage.thumbnailUrl,
originalUrl: generatedImage.originalUrl,
imageSize: generatedImage.imageSize,
prompt: generatedImage.prompt,
modelId: generatedImage.modelId,
featuredCategory: generatedImage.featuredCategory,
createdAt: generatedImage.createdAt,
featuredAt: generatedImage.featuredAt,
userId: generatedImage.userId,
userName: user.name,
userEmail: user.email,
userDepartment: user.department,
modelName: aiModel.displayName,
totalCount: sql<number>`count(*) over()::int`,
})
.from(generatedImage)
.leftJoin(user, eq(generatedImage.userId, user.id))
.leftJoin(aiModel, eq(generatedImage.modelId, aiModel.modelId))
.where(eq(generatedImage.isFeatured, true))
.orderBy(desc(generatedImage.featuredAt))
.limit(pageSize)
.offset(offset);
// 获取总数
const totalResult = await db
.select({ count: sql<number>`count(*)::int` })
.from(generatedImage)
.where(eq(generatedImage.isFeatured, true));
const total = featuredImages[0]?.totalCount || 0;
const totalPages = Math.ceil(total / pageSize);
const total = totalResult[0]?.count || 0;
const totalPages = Math.ceil(total / pageSize);
return NextResponse.json({
images: featuredImages.map(img => ({
id: img.id,
thumbnailUrl: img.thumbnailUrl,
originalUrl: img.originalUrl,
imageSize: img.imageSize,
prompt: img.prompt,
modelId: img.modelId,
modelName: img.modelName || img.modelId || "未知模型",
featuredCategory: img.featuredCategory,
createdAt: img.createdAt,
featuredAt: img.featuredAt,
user: {
id: img.userId,
name: img.userName || img.userEmail?.split("@")[0] || "未知用户",
department: img.userDepartment || "未设置部门",
return {
images: featuredImages.map((img) => ({
id: img.id,
thumbnailUrl: img.thumbnailUrl,
originalUrl: img.originalUrl,
imageSize: img.imageSize,
prompt: img.prompt,
modelId: img.modelId,
modelName: img.modelName || img.modelId || "未知模型",
featuredCategory: img.featuredCategory,
createdAt: img.createdAt,
featuredAt: img.featuredAt,
user: {
id: img.userId,
name: img.userName || img.userEmail?.split("@")[0] || "未知用户",
department: img.userDepartment || "未设置部门",
},
})),
pagination: {
page,
pageSize,
total,
totalPages,
},
})),
pagination: {
page,
pageSize,
total,
totalPages,
},
});
} catch (error) {
console.error("[Featured API] Error:", error);
return NextResponse.json({ error: "Failed to fetch featured images" }, { status: 500 });
}
};
},
});
}

View File

@ -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";
const FALLBACK_HOME_ANIMATION = {
enabled: false,
fileName: "",
htmlContent: "",
updatedAt: null,
usesDefaultFallback: false,
};
export async function GET() {
try {
const setting = await getSystemSetting({
key: HOME_CHAT_ANIMATION_CONFIG_KEY,
}).catch(() => null);
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);
const payload = resolvePublicHomeChatAnimationConfig(setting?.value);
return NextResponse.json(payload, {
headers: {
"Cache-Control": "no-store, max-age=0",
},
});
} catch {
return NextResponse.json(
{
enabled: false,
fileName: "",
htmlContent: "",
updatedAt: null,
usesDefaultFallback: false,
},
{
headers: {
"Cache-Control": "no-store, max-age=0",
},
},
);
}
return resolvePublicHomeChatAnimationConfig(setting?.value);
},
});
}

View File

@ -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 {
const links = await db
.select()
.from(navLinks)
.where(eq(navLinks.isEnabled, true))
.orderBy(navLinks.order);
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({

View File

@ -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, {
brandName,
cacheKey,
});
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);

View File

@ -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 {
const videos = await db
.select()
.from(tutorialVideos)
.where(eq(tutorialVideos.isEnabled, true))
.orderBy(asc(tutorialVideos.order));
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({

View File

@ -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">

View File

@ -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 });

View File

@ -0,0 +1,3 @@
CREATE INDEX IF NOT EXISTS "GeneratedImage_featured_featuredAt_idx"
ON "GeneratedImage" ("isFeatured", "featuredAt" DESC)
WHERE "isFeatured" = true;

View File

@ -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
}
]
}

View 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,
});
}
}

View File

@ -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();