Skip to content

Commit 6401d37

Browse files
authored
Merge pull request #346 from InvolutionHell/perf/ssg-orphaned-routes
perf(routes): 6 条 ƒ → ● / ○,按 next build 输出验证(CPU 超额修复)
2 parents f42bdba + e5c858b commit 6401d37

10 files changed

Lines changed: 454 additions & 39 deletions

File tree

CLAUDE.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# CLAUDE.md — 给 Claude / AI coding agent 的项目级硬约束
2+
3+
`AGENT.md` 写 workflow 和 coding style。这里只写**已被 review 推回过的反模式**
4+
确保下次不再犯。
5+
6+
## 1. 最佳实践优先于"省一点资源"
7+
8+
线上 Vercel CPU 接近 / 超配额时,**第一反应不是降级 observability 或藏起错误**
9+
正确的次序:
10+
11+
1. 找真实的 waste(scanner 烧 Fluid、缺 SSG 配置的路由、cache miss 风暴)
12+
2. 用 best practice 修掉 waste(让 SSG / ISR 真正生效,edge 早返)
13+
3. 还是过线就升 Pro plan / 上 Cloudflare proxy 挡 crawler
14+
15+
**不要做**("丢西瓜捡芝麻" 反模式,已被推回过):
16+
17+
- ❌ 把 `Sentry tracesSampleRate` 从 0.1 调到 0.02 省 CPU —— 10% 是行业标准,
18+
observability 不能为这点 CPU 让步;server/edge/client 三处必须一致才能跨
19+
runtime 串 trace
20+
- ❌ 把后端 fetch 失败一律返空数组 —— 这隐藏 prod 故障,把"backend 挂了"误
21+
显示成"暂无活动",Sentry / 错误页都抓不到。**正确做法**:用
22+
`process.env.NEXT_PHASE === "phase-production-build"` guard,只在 build
23+
阶段降级返空(避免 SSG build 挂),运行时仍 throw
24+
- ❌ 把活跃流量当 bug 优化掉 —— 如果 SEO PR 的 IndexNow ping 让搜索引擎重抓
25+
是 4× 流量增长的原因,那是工作成功的代价,应该付费而不是回滚 SEO
26+
27+
## 2. 路由分类必须用 `next build` 输出验证,不要凭感觉
28+
29+
历史教训:上一轮 SSR 优化(commit `8517332`)声称"首页 SSG 化",但 build 表
30+
显示**只翻转了 1 条路由**。剩 17 条还是 ƒ Dynamic,没人发现。
31+
32+
**强制流程**
33+
34+
```bash
35+
# 修前快照
36+
pnpm build 2>&1 | tee /tmp/build-before.txt
37+
38+
# 修复后
39+
pnpm build 2>&1 | tee /tmp/build-after.txt
40+
41+
# 直接 diff,看哪些 ƒ → ● / ○
42+
diff <(grep -E '^[┌├└] ' /tmp/build-before.txt) \
43+
<(grep -E '^[┌├└] ' /tmp/build-after.txt)
44+
```
45+
46+
**不接受"我加了 force-static 应该就行"这种自证。** 看 build 表,看
47+
`x-vercel-cache: HIT` header,看 Vercel dashboard 24h 后实测 CPU。
48+
49+
### next-intl SSG 的硬要求
50+
51+
每个 `[locale]/*/page.tsx` 想 SSG / ISR 都必须满足**全部三条**
52+
53+
1. `params: Promise<{ locale: string }>` 接收 + `await params`
54+
2. `setRequestLocale(locale)` 调用(必须在任何 `getTranslations` / `getLocale` 之前)
55+
3. `export function generateStaticParams() { return routing.locales.map(...); }`
56+
57+
缺任一条 → next-intl 退回 `cookies()` 推断 locale → 整页 ƒ Dynamic。
58+
parent layout 的 setRequestLocale 不传染到子 page。
59+
60+
## 3. 注释规则(CLAUDE.md 顶层约束的项目特化)
61+
62+
**默认不写注释**。只在以下场景写:
63+
64+
- 非显然的工程约束(如 next-intl SSG 三条件、`NEXT_PHASE` guard 的作用域)
65+
- 维护时容易踩坑的不变量(如 "BOT_PATH_PATTERNS 不要加 admin / login")
66+
67+
**严禁写**(已被推回过的反模式):
68+
69+
- ❌ 引 dev_docs/ 文件路径(doc 改名 / 删除时注释 rot)
70+
- ❌ 引 PR / commit / issue 编号(提供不了上下文,要看就 `git blame`
71+
- ❌ "原版/之前是 X,现在改成 Y" 的历史叙事(PR 描述里写就好)
72+
- ❌ "修复 XX bug" / "为 YY 任务加" 类引用当前任务的注释
73+
- ❌ 大段 docstring 描述代码功能 —— 命名清楚就够
74+
75+
## 4. CR 反馈直接改,不分级问用户
76+
77+
Copilot / 人工 review 给的意见**先判真假**
78+
79+
- 真问题 → 直接 commit 修,挂 `Co-authored-by: copilot-pull-request-reviewer[bot]`
80+
- 噪音 → 直接 dismiss 并说理由
81+
82+
**不要**列 P0/P1/P2 让用户选 —— 我已经读完 CR 了,用户没读,让他筛选是把
83+
任务推回给他。
84+
85+
## 5. Build 产物 commit 进 git 的特例
86+
87+
`generated/leetcode-slug-map.json``pnpm build` prebuild 产物,但**必须
88+
commit 进 git**——`proxy.ts` 在 edge runtime 静态 import 它,不 commit
89+
就要把 pinyin-pro 字典塞进 edge bundle(不可行)。
90+
91+
任何改 `content/docs/career/interview-prep/leetcode/` 下题目(新增 / 删除 /
92+
重命名)的 PR,commit 前**必须**跑一次 `pnpm build` 让 prebuild 同步这个
93+
JSON,否则下一个 contributor 跑 build 时会被强迫顺手清你的 orphan entry。

app/[locale]/docs/page.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ import { ensureSeoDescription } from "@/lib/seo-description";
1717
* 件,避免 drift。
1818
*/
1919

20+
// force-static 必需:SectionIndex 内部用 getLocale(),Next 16 会按"可能 dynamic"
21+
// 处理,加这条显式 opt-in 静态化(pageTree 是 build-time 数据,无运行时依赖)。
22+
export const dynamic = "force-static";
23+
2024
interface Props {
2125
params: Promise<{ locale: string }>;
2226
}
@@ -64,3 +68,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
6468
}),
6569
};
6670
}
71+
72+
export function generateStaticParams() {
73+
return routing.locales.map((locale) => ({ locale }));
74+
}

app/[locale]/events/page.tsx

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,17 @@
11
import type { Metadata } from "next";
22
import Link from "next/link";
3+
import { setRequestLocale } from "next-intl/server";
4+
import { hasLocale } from "next-intl";
5+
import { notFound } from "next/navigation";
36
import { Header } from "@/app/components/Header";
47
import { Footer } from "@/app/components/Footer";
58
import type { EventView } from "./types";
69
import { sanitizeMediaUrl } from "@/lib/url-safety";
10+
import { routing } from "@/i18n/routing";
711

8-
/**
9-
* /events 列表页。
10-
*
11-
* SSR 直连后端(BACKEND_URL)拉 published + archived 活动。
12-
* 错误策略参考 /u/[username]/page.tsx:只有网络 / 5xx 才抛,空列表不是错误。
13-
*
14-
* revalidate: 300 把 Neon 打压力压到每 5min 一次 SSR,和 PR #286 的 profile 策略一致。
15-
*/
16-
12+
// ISR 5min:和 profile/feed 同一节流策略,控后端 QPS。
13+
// setRequestLocale + generateStaticParams 是 next-intl SSG 的必要条件,
14+
// 缺任一项会让 next-intl 退回 cookies() 把这条路由钉成 ƒ Dynamic。
1715
export const revalidate = 300;
1816

1917
interface ApiResponse<T> {
@@ -22,24 +20,50 @@ interface ApiResponse<T> {
2220
message?: string;
2321
}
2422

23+
// 只在 build 阶段允许 fetch 失败降级(让 SSG 不挂),运行时仍 throw 给 Sentry。
24+
const IS_BUILD = process.env.NEXT_PHASE === "phase-production-build";
25+
2526
async function fetchEvents(): Promise<EventView[]> {
2627
const backendUrl = process.env.BACKEND_URL;
2728
if (!backendUrl) {
28-
// 开发环境或 misconfig 时给一个清晰报错,而不是静默空列表
29+
if (IS_BUILD) {
30+
console.warn(
31+
"[events] BACKEND_URL not set at build, rendering empty shell; ISR will fetch real data after deploy",
32+
);
33+
return [];
34+
}
2935
throw new Error("BACKEND_URL is not configured");
3036
}
31-
const res = await fetch(`${backendUrl}/api/events`, {
32-
next: { revalidate: 300 },
33-
headers: {
34-
accept: "application/json",
35-
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
36-
},
37-
});
38-
if (!res.ok) {
39-
throw new Error(`/api/events backend ${res.status} ${res.statusText}`);
37+
try {
38+
const res = await fetch(`${backendUrl}/api/events`, {
39+
next: { revalidate: 300 },
40+
headers: {
41+
accept: "application/json",
42+
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
43+
},
44+
});
45+
if (!res.ok) {
46+
if (IS_BUILD) {
47+
console.warn(
48+
`[events] backend ${res.status} at build, rendering empty shell`,
49+
);
50+
return [];
51+
}
52+
throw new Error(`/api/events backend ${res.status} ${res.statusText}`);
53+
}
54+
const json = (await res.json()) as ApiResponse<EventView[]>;
55+
return json.success && json.data ? json.data : [];
56+
} catch (err) {
57+
if (IS_BUILD) {
58+
console.warn(
59+
"[events] fetch failed at build, rendering empty shell:",
60+
err,
61+
);
62+
return [];
63+
}
64+
// 运行时失败仍然 throw —— Sentry 抓到,错误页正常显示,不掩盖故障
65+
throw err;
4066
}
41-
const json = (await res.json()) as ApiResponse<EventView[]>;
42-
return json.success && json.data ? json.data : [];
4367
}
4468

4569
export const metadata: Metadata = {
@@ -48,7 +72,15 @@ export const metadata: Metadata = {
4872
"Coffee Chat、Mock Interview、Career Journey、Open.Onion 等社群活动汇总,直播入口和历史回放一站式。",
4973
};
5074

51-
export default async function EventsListPage() {
75+
interface Props {
76+
params: Promise<{ locale: string }>;
77+
}
78+
79+
export default async function EventsListPage({ params }: Props) {
80+
const { locale } = await params;
81+
if (!hasLocale(routing.locales, locale)) notFound();
82+
setRequestLocale(locale);
83+
5284
const all = await fetchEvents();
5385
// 按时间划分:进行中 / 即将开始 / 已结束。ongoing + past 由后端标记,剩下的归"即将开始"
5486
const ongoing = all.filter((e) => e.ongoing);
@@ -204,3 +236,7 @@ function formatDate(iso: string): string {
204236
return iso;
205237
}
206238
}
239+
240+
export function generateStaticParams() {
241+
return routing.locales.map((locale) => ({ locale }));
242+
}

app/[locale]/login/page.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { Metadata } from "next";
2-
import { getTranslations } from "next-intl/server";
2+
import { setRequestLocale, getTranslations } from "next-intl/server";
3+
import { hasLocale } from "next-intl";
4+
import { notFound } from "next/navigation";
35
import { SignInButton } from "@/app/components/SignInButton";
6+
import { routing } from "@/i18n/routing";
47

58
// SEO: 登录页不参与 index(搜索引擎不需要收录登录入口)
69
export const metadata: Metadata = {
@@ -10,7 +13,15 @@ export const metadata: Metadata = {
1013
robots: { index: false, follow: true },
1114
};
1215

13-
export default async function LoginPage() {
16+
interface Props {
17+
params: Promise<{ locale: string }>;
18+
}
19+
20+
export default async function LoginPage({ params }: Props) {
21+
const { locale } = await params;
22+
if (!hasLocale(routing.locales, locale)) notFound();
23+
setRequestLocale(locale);
24+
1425
const t = await getTranslations("login");
1526
return (
1627
<div className="min-h-screen flex items-center justify-center bg-background">
@@ -26,3 +37,7 @@ export default async function LoginPage() {
2637
</div>
2738
);
2839
}
40+
41+
export function generateStaticParams() {
42+
return routing.locales.map((locale) => ({ locale }));
43+
}

app/not-found.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,24 @@
11
import Link from "next/link";
2-
import { getTranslations } from "next-intl/server";
32
import { Button } from "@/app/components/ui/button";
43
import NotFoundTracker from "./not-found-tracker";
54

6-
// 必须是 Server Component:爬虫向 / 发 POST 时 Next 走 Server Action 路径,
7-
// not-found 渲染不经过 layout,NextIntlClientProvider 不在树里,
8-
// useTranslations 会抛 "No intl context"。getTranslations 走 server,
9-
// 直接读 i18n/request.ts,没有 provider 依赖。
10-
export default async function NotFound() {
11-
const t = await getTranslations("notFound");
12-
5+
// 根 not-found 必须保持静态:用 next-intl 的 getTranslations 会触发 cookies()
6+
// 让这条路由退化成 ƒ Dynamic,每条 404 / scanner 扫描就吃一次 Fluid CPU。
7+
// 双语并列是 trade-off —— 根级 not-found 拿不到 locale。
8+
export default function NotFound() {
139
return (
1410
<div className="flex h-screen w-full flex-col items-center justify-center bg-background text-foreground">
1511
<div className="bg-[url('/cloud_2.png')] bg-cover bg-center absolute inset-0 opacity-10 pointer-events-none" />
1612
<div className="z-10 flex flex-col items-center space-y-6 text-center">
1713
<h1 className="text-9xl font-black italic tracking-tighter">404</h1>
1814
<h2 className="text-2xl font-bold uppercase tracking-widest">
19-
{t("heading")}
15+
页面不存在 · Page not found
2016
</h2>
21-
<p className="max-w-md text-muted-foreground">{t("body")}</p>
17+
<p className="max-w-md text-muted-foreground">
18+
你访问的页面可能已被移动或不存在。Try going back home.
19+
</p>
2220
<Button asChild size="lg" className="mt-8">
23-
<Link href="/">{t("cta")}</Link>
21+
<Link href="/">返回首页 · Back to home</Link>
2422
</Button>
2523
</div>
2624
<NotFoundTracker />

0 commit comments

Comments
 (0)