Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions app/u/[username]/error.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="min-h-screen bg-[var(--background)] flex items-center justify-center px-6 py-24">
<div className="max-w-xl w-full border border-[var(--foreground)] p-8 lg:p-12 flex flex-col gap-6">
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-[#CC0000]">
Profile · Temporary Failure
</div>
<h1 className="font-serif text-3xl md:text-4xl font-black uppercase tracking-tight text-[var(--foreground)]">
个人主页暂时加载失败
</h1>
<p className="text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
服务端在拉取这个用户的资料时遇到了一次瞬时错误(可能是上游 CDN
拦截或后端抖动)。通常重试一次就能恢复。
</p>
{error.digest ? (
<p className="font-mono text-[10px] uppercase tracking-widest text-neutral-500">
Error digest: {error.digest}
</p>
) : null}
<div className="flex flex-wrap gap-3 pt-2">
<button
type="button"
onClick={reset}
className="font-mono text-xs uppercase tracking-widest px-4 py-2 border border-[var(--foreground)] bg-[var(--foreground)] text-[var(--background)] hover:bg-[#CC0000] hover:border-[#CC0000] transition-colors"
>
重试
</button>
<Link
href="/"
className="font-mono text-xs uppercase tracking-widest px-4 py-2 border border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors"
>
返回首页
</Link>
<Link
href="/rank"
className="font-mono text-xs uppercase tracking-widest px-4 py-2 border border-[var(--foreground)] hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-colors"
>
查看排行榜
</Link>
</div>
</div>
</main>
);
}
128 changes: 104 additions & 24 deletions app/u/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,42 +113,122 @@ interface ProfileResponse {
* 让 Next error boundary 兜底,避免把"后端故障"伪装成"用户不存在"。
*/
function warnFetchProfile(message: string, details?: Record<string, unknown>) {
if (process.env.NODE_ENV !== "production") {
console.warn(`[fetchProfile] ${message}`, details ?? {});
const isProduction = process.env.NODE_ENV === "production";
const status = typeof details?.status === "number" ? details.status : undefined;
const success = typeof details?.success === "boolean" ? details.success : undefined;
const isExpectedNotFound = status === 404 || success === false;

// 生产环境仅记录需要诊断的异常场景;404 / success=false 属于预期控制流,
// 否则像爬虫扫描随机 /u/* 会产生大量无意义 warn 日志。
if (isProduction && isExpectedNotFound) {
return;
}

// 对异常/需诊断场景仍然打印:例如 403/5xx、网关异常、解析失败等,
// 便于在 Vercel runtime logs 中查看状态码、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<ProfileData | null> {
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(() => "<read body failed>");
warnFetchProfile("backend non-2xx", {
Comment on lines +205 to +209
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code logs a bodySnippet (first 300 chars) from upstream responses into server logs. Even though this only happens on non-2xx, those bodies can still contain sensitive data (e.g., backend error pages, stack traces, Cloudflare challenge HTML). Consider redacting/sanitizing this snippet, logging only when content-type is safe/expected, or logging a hash/length instead of raw body content.

Copilot uses AI. Check for mistakes.
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<void> {
return new Promise((r) => setTimeout(r, ms));
}

/**
Expand Down
Loading