From eeb5659419bb1e4ad4310e93b04e40cbc28b099a Mon Sep 17 00:00:00 2001 From: longsizhuo Date: Thu, 16 Apr 2026 18:41:03 +0000 Subject: [PATCH 1/4] =?UTF-8?q?fix(profile):=20/u/[username]=20=E6=8A=93?= =?UTF-8?q?=E5=8F=96=E5=8A=A0=E9=87=8D=E8=AF=95=20+=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20error=20=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 线上偶发"Application error: a server-side exception has occurred"报错(digest 3734429282)。 Vercel runtime logs 定位到 GET /api/user-center/profile/{id} 收到 Cloudflare 403(直连后端 200), 疑似 Vercel SSR 出口被 CF Bot/Managed Challenge 偶发拦截。单次 403 直接冒泡到 Next 默认错误页, 用户体验就是白屏 + digest。 两处改动: 1) app/u/[username]/page.tsx — fetchProfile 加重试 + 诊断日志 - 首次走 ISR(300s),失败后两次 cache:"no-store" 重试(300ms/800ms 退避),绕开 Data Cache 把 403 粘住 5 分钟的问题 - 显式带 UA / Accept header,降低被 CF 判 bot 概率 - 每次非 2xx 都记录 status / cf-ray / cf-mitigated / content-type / 响应体前 300 字符, 下次再发生时 Vercel 日志里直接有证据(原先只在 dev 打 warn) 2) app/u/[username]/error.tsx — 新增路由级错误边界 - 取代 Next 默认的白屏错误页,给出"重试 / 返回首页 / 排行榜"三个出口 - 展示 digest 让用户可以回贴排查 --- app/u/[username]/error.tsx | 77 ++++++++++++++++++++++++ app/u/[username]/page.tsx | 120 +++++++++++++++++++++++++++++-------- 2 files changed, 172 insertions(+), 25 deletions(-) create mode 100644 app/u/[username]/error.tsx diff --git a/app/u/[username]/error.tsx b/app/u/[username]/error.tsx new file mode 100644 index 00000000..2fa3d156 --- /dev/null +++ b/app/u/[username]/error.tsx @@ -0,0 +1,77 @@ +"use client"; + +// 个人主页路由的错误边界。 +// +// 为什么要单独加这个文件: +// 原先 /u/[username] 的 SSR 抓取如果命中 Cloudflare 偶发 403 / 后端 5xx, +// 会直接冒泡到 Next 默认的全局错误页("Application error: a server-side exception +// has occurred while loading involutionhell.com"),用户看到的是一堆 digest 没有 +// 任何可操作的信息。这里给一个本地化的、带"重试"按钮的降级界面。 +// +// 注意: +// - 必须是 client component("use client"),因为需要 useEffect / onClick。 +// - 不要依赖任何服务端 state,error 本身就是 SSR 失败的产物。 +// - reset() 来自 Next,会尝试重新渲染该路由段(会重新触发 SSR fetch),适合瞬时抖动场景。 + +import Link from "next/link"; +import { useEffect } from "react"; + +export default function ProfileError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // 把 digest 和 message 打到浏览器控制台,方便用户把 digest 回贴给我们排查。 + // 服务端那份 stack 在 Vercel runtime logs 里(fetchProfile 里也已经记录)。 + console.error("[UserProfile error boundary]", { + message: error.message, + digest: error.digest, + }); + }, [error]); + + return ( +
+
+
+ Profile · Temporary Failure +
+

+ 个人主页暂时加载失败 +

+

+ 服务端在拉取这个用户的资料时遇到了一次瞬时错误(可能是上游 CDN + 拦截或后端抖动)。 通常重试一次就能恢复。 +

+ {error.digest ? ( +

+ Error digest: {error.digest} +

+ ) : null} +
+ + + 返回首页 + + + 查看排行榜 + +
+
+
+ ); +} diff --git a/app/u/[username]/page.tsx b/app/u/[username]/page.tsx index 4bf5749a..5e3a3370 100644 --- a/app/u/[username]/page.tsx +++ b/app/u/[username]/page.tsx @@ -113,42 +113,112 @@ interface ProfileResponse { * 让 Next error boundary 兜底,避免把"后端故障"伪装成"用户不存在"。 */ function warnFetchProfile(message: string, details?: Record) { - if (process.env.NODE_ENV !== "production") { - console.warn(`[fetchProfile] ${message}`, details ?? {}); - } + // 生产环境也打印:在 Vercel runtime logs 排查偶发 403/5xx 需要看到上下文 + // (原先只在开发输出,导致线上 Cloudflare 拦截时只看到 Next 抛出的 Error 行, + // 缺少 res 状态/cf-ray/响应体片段,排查成本高) + console.warn(`[fetchProfile] ${message}`, details ?? {}); } +/** + * 带重试的 profile 抓取。 + * 背景:Cloudflare 偶发会对 Vercel SSR 出口返回 403(疑似 Bot 挑战/Managed Challenge), + * 单次失败就 500 会让正常访问的用户直接看到 Next 默认 "Application error" 黑屏。 + * + * 策略: + * - 404 / 200 直接返回(用户不存在或成功) + * - 其他非 2xx(403/5xx/网关异常)做最多 2 次重试,退避 300ms / 800ms + * - 每次失败都 console.warn,记录 status / cf-ray / 响应体前 300 字符,便于 Vercel 日志定位 + * - 重试全败才抛,让 error.tsx 兜底(不再是裸露的 Application error 页) + * + * 重试走 cache: "no-store",避免把上次的 403 命中 Next Data Cache 导致 5 分钟内 + * 所有访问都拿到同一份错误。 + */ async function fetchProfile(identifier: string): Promise { const backendUrl = process.env.BACKEND_URL; if (!backendUrl) { // 关键配置缺失不能静默 notFound,给个可见错误 throw new Error("BACKEND_URL is not configured"); } - const res = await fetch( - `${backendUrl}/api/user-center/profile/${encodeURIComponent(identifier)}`, - { next: { revalidate: 300 } }, - ); - // 404:用户确实不存在 → notFound - if (res.status === 404) { - warnFetchProfile("backend 404", { identifier }); - return null; - } - // 其他非 2xx 都抛,进 Next error boundary - if (!res.ok) { - throw new Error( - `profile backend ${res.status} ${res.statusText} for ${identifier}`, - ); - } - const json = (await res.json()) as ProfileResponse; - // 后端用 {success:false, message:"用户不存在"} 表示软 404 - if (!json.success || !json.data) { - warnFetchProfile("backend success=false", { + const url = `${backendUrl}/api/user-center/profile/${encodeURIComponent(identifier)}`; + const attempts: Array<{ revalidate: number } | { noStore: true }> = [ + { revalidate: 300 }, // 首次命中:走 Next Data Cache(5min ISR),命中快 + { noStore: true }, // 第一次重试:绕过缓存 + { noStore: true }, // 第二次重试:再绕一次,防瞬时抖动 + ]; + + for (let i = 0; i < attempts.length; i++) { + const attempt = attempts[i]; + const init: RequestInit & { next?: { revalidate: number } } = + "noStore" in attempt + ? { cache: "no-store" } + : { next: { revalidate: attempt.revalidate } }; + // 显式设置 UA / Accept,降低被 Cloudflare 误判 bot 的概率 + // (Node 原生 fetch 默认 UA 在某些 CF 规则下会被挑战) + init.headers = { + accept: "application/json", + "user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)", + }; + + let res: Response; + try { + res = await fetch(url, init); + } catch (networkErr) { + warnFetchProfile("fetch network error", { + identifier, + attempt: i, + error: String(networkErr), + }); + if (i === attempts.length - 1) throw networkErr; + await sleep(i === 0 ? 300 : 800); + continue; + } + + // 404:用户确实不存在 → notFound(不重试) + if (res.status === 404) { + warnFetchProfile("backend 404", { identifier }); + return null; + } + if (res.ok) { + const json = (await res.json()) as ProfileResponse; + // 后端用 {success:false, message:"用户不存在"} 表示软 404 + if (!json.success || !json.data) { + warnFetchProfile("backend success=false", { + identifier, + message: json.message, + }); + return null; + } + return json.data; + } + + // 非 2xx:记录诊断信息,准备重试或最终抛错 + const bodySnippet = await res + .text() + .then((t) => t.slice(0, 300)) + .catch(() => ""); + warnFetchProfile("backend non-2xx", { identifier, - message: json.message, + attempt: i, + status: res.status, + statusText: res.statusText, + cfRay: res.headers.get("cf-ray"), + cfMitigated: res.headers.get("cf-mitigated"), + contentType: res.headers.get("content-type"), + bodySnippet, }); - return null; + if (i === attempts.length - 1) { + throw new Error( + `profile backend ${res.status} ${res.statusText} for ${identifier} (cf-ray=${res.headers.get("cf-ray") ?? "none"})`, + ); + } + await sleep(i === 0 ? 300 : 800); } - return json.data; + // 理论上不会走到:上面循环要么 return,要么 throw + throw new Error("profile fetch exhausted without resolution"); +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); } /** From 98a9ad2aedcda4d178f6e60776b1fdfed73ee56e Mon Sep 17 00:00:00 2001 From: Siz Long Date: Fri, 17 Apr 2026 02:50:52 +0800 Subject: [PATCH 2/4] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/u/[username]/error.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/u/[username]/error.tsx b/app/u/[username]/error.tsx index 2fa3d156..838d1b6d 100644 --- a/app/u/[username]/error.tsx +++ b/app/u/[username]/error.tsx @@ -53,7 +53,7 @@ export default function ProfileError({