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
93 changes: 93 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# CLAUDE.md — 给 Claude / AI coding agent 的项目级硬约束

`AGENT.md` 写 workflow 和 coding style。这里只写**已被 review 推回过的反模式**,
确保下次不再犯。

## 1. 最佳实践优先于"省一点资源"

线上 Vercel CPU 接近 / 超配额时,**第一反应不是降级 observability 或藏起错误**。
正确的次序:

1. 找真实的 waste(scanner 烧 Fluid、缺 SSG 配置的路由、cache miss 风暴)
2. 用 best practice 修掉 waste(让 SSG / ISR 真正生效,edge 早返)
3. 还是过线就升 Pro plan / 上 Cloudflare proxy 挡 crawler

**不要做**("丢西瓜捡芝麻" 反模式,已被推回过):

- ❌ 把 `Sentry tracesSampleRate` 从 0.1 调到 0.02 省 CPU —— 10% 是行业标准,
observability 不能为这点 CPU 让步;server/edge/client 三处必须一致才能跨
runtime 串 trace
- ❌ 把后端 fetch 失败一律返空数组 —— 这隐藏 prod 故障,把"backend 挂了"误
显示成"暂无活动",Sentry / 错误页都抓不到。**正确做法**:用
`process.env.NEXT_PHASE === "phase-production-build"` guard,只在 build
阶段降级返空(避免 SSG build 挂),运行时仍 throw
- ❌ 把活跃流量当 bug 优化掉 —— 如果 SEO PR 的 IndexNow ping 让搜索引擎重抓
是 4× 流量增长的原因,那是工作成功的代价,应该付费而不是回滚 SEO

## 2. 路由分类必须用 `next build` 输出验证,不要凭感觉

历史教训:上一轮 SSR 优化(commit `8517332`)声称"首页 SSG 化",但 build 表
显示**只翻转了 1 条路由**。剩 17 条还是 ƒ Dynamic,没人发现。

**强制流程**:

```bash
# 修前快照
pnpm build 2>&1 | tee /tmp/build-before.txt

# 修复后
pnpm build 2>&1 | tee /tmp/build-after.txt

# 直接 diff,看哪些 ƒ → ● / ○
diff <(grep -E '^[┌├└] ' /tmp/build-before.txt) \
<(grep -E '^[┌├└] ' /tmp/build-after.txt)
```

**不接受"我加了 force-static 应该就行"这种自证。** 看 build 表,看
`x-vercel-cache: HIT` header,看 Vercel dashboard 24h 后实测 CPU。

### next-intl SSG 的硬要求

每个 `[locale]/*/page.tsx` 想 SSG / ISR 都必须满足**全部三条**:

1. `params: Promise<{ locale: string }>` 接收 + `await params`
2. `setRequestLocale(locale)` 调用(必须在任何 `getTranslations` / `getLocale` 之前)
3. `export function generateStaticParams() { return routing.locales.map(...); }`

缺任一条 → next-intl 退回 `cookies()` 推断 locale → 整页 ƒ Dynamic。
parent layout 的 setRequestLocale 不传染到子 page。

## 3. 注释规则(CLAUDE.md 顶层约束的项目特化)

**默认不写注释**。只在以下场景写:

- 非显然的工程约束(如 next-intl SSG 三条件、`NEXT_PHASE` guard 的作用域)
- 维护时容易踩坑的不变量(如 "BOT_PATH_PATTERNS 不要加 admin / login")

**严禁写**(已被推回过的反模式):

- ❌ 引 dev_docs/ 文件路径(doc 改名 / 删除时注释 rot)
- ❌ 引 PR / commit / issue 编号(提供不了上下文,要看就 `git blame`)
- ❌ "原版/之前是 X,现在改成 Y" 的历史叙事(PR 描述里写就好)
- ❌ "修复 XX bug" / "为 YY 任务加" 类引用当前任务的注释
- ❌ 大段 docstring 描述代码功能 —— 命名清楚就够

## 4. CR 反馈直接改,不分级问用户

Copilot / 人工 review 给的意见**先判真假**:

- 真问题 → 直接 commit 修,挂 `Co-authored-by: copilot-pull-request-reviewer[bot]`
- 噪音 → 直接 dismiss 并说理由

**不要**列 P0/P1/P2 让用户选 —— 我已经读完 CR 了,用户没读,让他筛选是把
任务推回给他。

## 5. Build 产物 commit 进 git 的特例

`generated/leetcode-slug-map.json` 是 `pnpm build` prebuild 产物,但**必须
commit 进 git**——`proxy.ts` 在 edge runtime 静态 import 它,不 commit
就要把 pinyin-pro 字典塞进 edge bundle(不可行)。

任何改 `content/docs/career/interview-prep/leetcode/` 下题目(新增 / 删除 /
重命名)的 PR,commit 前**必须**跑一次 `pnpm build` 让 prebuild 同步这个
JSON,否则下一个 contributor 跑 build 时会被强迫顺手清你的 orphan entry。
8 changes: 8 additions & 0 deletions app/[locale]/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import { ensureSeoDescription } from "@/lib/seo-description";
* 件,避免 drift。
*/

// force-static 必需:SectionIndex 内部用 getLocale(),Next 16 会按"可能 dynamic"
// 处理,加这条显式 opt-in 静态化(pageTree 是 build-time 数据,无运行时依赖)。
export const dynamic = "force-static";

interface Props {
params: Promise<{ locale: string }>;
}
Expand Down Expand Up @@ -64,3 +68,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}),
};
}

export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
80 changes: 58 additions & 22 deletions app/[locale]/events/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
import type { Metadata } from "next";
import Link from "next/link";
import { setRequestLocale } from "next-intl/server";
import { hasLocale } from "next-intl";
import { notFound } from "next/navigation";
import { Header } from "@/app/components/Header";
import { Footer } from "@/app/components/Footer";
import type { EventView } from "./types";
import { sanitizeMediaUrl } from "@/lib/url-safety";
import { routing } from "@/i18n/routing";

/**
* /events 列表页。
*
* SSR 直连后端(BACKEND_URL)拉 published + archived 活动。
* 错误策略参考 /u/[username]/page.tsx:只有网络 / 5xx 才抛,空列表不是错误。
*
* revalidate: 300 把 Neon 打压力压到每 5min 一次 SSR,和 PR #286 的 profile 策略一致。
*/

// ISR 5min:和 profile/feed 同一节流策略,控后端 QPS。
// setRequestLocale + generateStaticParams 是 next-intl SSG 的必要条件,
// 缺任一项会让 next-intl 退回 cookies() 把这条路由钉成 ƒ Dynamic。
export const revalidate = 300;

interface ApiResponse<T> {
Expand All @@ -22,24 +20,50 @@ interface ApiResponse<T> {
message?: string;
}

// 只在 build 阶段允许 fetch 失败降级(让 SSG 不挂),运行时仍 throw 给 Sentry。
const IS_BUILD = process.env.NEXT_PHASE === "phase-production-build";

async function fetchEvents(): Promise<EventView[]> {
const backendUrl = process.env.BACKEND_URL;
if (!backendUrl) {
// 开发环境或 misconfig 时给一个清晰报错,而不是静默空列表
if (IS_BUILD) {
console.warn(
"[events] BACKEND_URL not set at build, rendering empty shell; ISR will fetch real data after deploy",
);
return [];
}
throw new Error("BACKEND_URL is not configured");
}
const res = await fetch(`${backendUrl}/api/events`, {
next: { revalidate: 300 },
headers: {
accept: "application/json",
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
},
});
if (!res.ok) {
throw new Error(`/api/events backend ${res.status} ${res.statusText}`);
try {
const res = await fetch(`${backendUrl}/api/events`, {
next: { revalidate: 300 },
headers: {
accept: "application/json",
"user-agent": "InvolutionHell-SSR/1.0 (+https://involutionhell.com)",
},
});
if (!res.ok) {
if (IS_BUILD) {
console.warn(
`[events] backend ${res.status} at build, rendering empty shell`,
);
return [];
}
throw new Error(`/api/events backend ${res.status} ${res.statusText}`);
}
const json = (await res.json()) as ApiResponse<EventView[]>;
return json.success && json.data ? json.data : [];
} catch (err) {
if (IS_BUILD) {
console.warn(
"[events] fetch failed at build, rendering empty shell:",
err,
);
return [];
}
// 运行时失败仍然 throw —— Sentry 抓到,错误页正常显示,不掩盖故障
throw err;
}
const json = (await res.json()) as ApiResponse<EventView[]>;
return json.success && json.data ? json.data : [];
}

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

export default async function EventsListPage() {
interface Props {
params: Promise<{ locale: string }>;
}

export default async function EventsListPage({ params }: Props) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) notFound();
setRequestLocale(locale);

const all = await fetchEvents();
// 按时间划分:进行中 / 即将开始 / 已结束。ongoing + past 由后端标记,剩下的归"即将开始"
const ongoing = all.filter((e) => e.ongoing);
Expand Down Expand Up @@ -204,3 +236,7 @@ function formatDate(iso: string): string {
return iso;
}
}

export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
19 changes: 17 additions & 2 deletions app/[locale]/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { setRequestLocale, getTranslations } from "next-intl/server";
import { hasLocale } from "next-intl";
import { notFound } from "next/navigation";
import { SignInButton } from "@/app/components/SignInButton";
import { routing } from "@/i18n/routing";

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

export default async function LoginPage() {
interface Props {
params: Promise<{ locale: string }>;
}

export default async function LoginPage({ params }: Props) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) notFound();
setRequestLocale(locale);

const t = await getTranslations("login");
return (
<div className="min-h-screen flex items-center justify-center bg-background">
Expand All @@ -26,3 +37,7 @@ export default async function LoginPage() {
</div>
);
}

export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
20 changes: 9 additions & 11 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Button } from "@/app/components/ui/button";
import NotFoundTracker from "./not-found-tracker";

// 必须是 Server Component:爬虫向 / 发 POST 时 Next 走 Server Action 路径,
// not-found 渲染不经过 layout,NextIntlClientProvider 不在树里,
// useTranslations 会抛 "No intl context"。getTranslations 走 server,
// 直接读 i18n/request.ts,没有 provider 依赖。
export default async function NotFound() {
const t = await getTranslations("notFound");

// 根 not-found 必须保持静态:用 next-intl 的 getTranslations 会触发 cookies()
// 让这条路由退化成 ƒ Dynamic,每条 404 / scanner 扫描就吃一次 Fluid CPU。
// 双语并列是 trade-off —— 根级 not-found 拿不到 locale。
export default function NotFound() {
return (
<div className="flex h-screen w-full flex-col items-center justify-center bg-background text-foreground">
<div className="bg-[url('/cloud_2.png')] bg-cover bg-center absolute inset-0 opacity-10 pointer-events-none" />
<div className="z-10 flex flex-col items-center space-y-6 text-center">
<h1 className="text-9xl font-black italic tracking-tighter">404</h1>
<h2 className="text-2xl font-bold uppercase tracking-widest">
{t("heading")}
页面不存在 · Page not found
</h2>
<p className="max-w-md text-muted-foreground">{t("body")}</p>
<p className="max-w-md text-muted-foreground">
你访问的页面可能已被移动或不存在。Try going back home.
</p>
<Button asChild size="lg" className="mt-8">
<Link href="/">{t("cta")}</Link>
<Link href="/">返回首页 · Back to home</Link>
</Button>
</div>
<NotFoundTracker />
Expand Down
Loading
Loading