From 7828000cf9f5e7f315079e49a244c7a38537c903 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 19:31:09 +0000 Subject: [PATCH 1/4] =?UTF-8?q?perf(routes):=206=20=E6=9D=A1=20=C6=92=20?= =?UTF-8?q?=E2=86=92=20=E2=97=8F=20/=20=E2=97=8B=EF=BC=8C=E6=8C=89=20next?= =?UTF-8?q?=20build=20=E8=BE=93=E5=87=BA=E9=AA=8C=E8=AF=81=EF=BC=88CPU=20?= =?UTF-8?q?=E8=B6=85=E9=A2=9D=E4=BF=AE=E5=A4=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 Hobby plan Fluid Active CPU 7h54m / 4h(198% 超额)。上一轮 SSR 优化 (commit 8517332,2026-05-06)只翻转了首页 1 条路由,剩 18 条 ƒ Dynamic 没碰。dev_docs/vercel-cpu-overage-2026-05.md 记录完整调查 + 修复 + 验证。 ## 修复(每条都用 next build 输出验证翻转) | 路由 | 修前 | 修后 | |---|---|---| | /_not-found | ƒ | **○ Static** ← scanner 不再烧 Fluid | | /[locale]/docs | ƒ | ● SSG | | /[locale]/events | ƒ | **● ISR 5m** ← revalidate=300 终于生效 | | /[locale]/login | ƒ | ● SSG | | /[locale]/editor | ƒ | ● SSG (cascade) | | /[locale]/share | ƒ | ● SSG (cascade) | ### H1: /_not-found 静态化 原 await getTranslations 让根 not-found ƒ Dynamic,所有 .env / wp-* / graphql 漏洞扫描 + 真实 404 都烧 Fluid CPU。改成 hardcoded 双语,去掉 next-intl 依赖。 ### H1b: proxy.ts edge bot blocklist .php / wp-* / .env / graphql / werkzeug 等指纹直接在 edge 返 404,不进 Fluid 函数。明确不包含 login/admin 等真业务路径。 ### H2: 给 [locale]/* pages 加 setRequestLocale + generateStaticParams events / login: 加 params + setRequestLocale + generateStaticParams 让 revalidate=300 真正工作。 docs: 已有 setRequestLocale 但仍 ƒ,加 dynamic="force-static" 显式 opt-in。 events fetchEvents 改成失败降级返空(build 时后端不可达不挂 build)。 ### H3: Sentry tracesSampleRate 0.1 → 0.02 server + edge 都改。10% 在月百万级请求量产生 10w+ traces 叠在 CPU 上。 ## 验证(修复前/后 next build 输出 diff) ``` 修前: 18 ƒ / 2 ● / 6 ○ 修后: 20 ƒ / 7 ● / 7 ○ ``` ƒ → ● / ○ 翻转 6 条。剩 ƒ 多是 auth-gated admin / searchParams 类,留作 follow-up(见 dev doc TODO 列表)。 49/49 vitest 通过。完整调查方法 + 量化预期写在 dev_docs/vercel-cpu-overage-2026-05.md。 --- app/[locale]/docs/page.tsx | 12 ++ app/[locale]/events/page.tsx | 64 ++++++-- app/[locale]/login/page.tsx | 27 +++- app/not-found.tsx | 36 +++-- dev_docs/vercel-cpu-overage-2026-05.md | 198 +++++++++++++++++++++++++ generated/leetcode-slug-map.json | 4 - proxy.ts | 51 +++++++ sentry.edge.config.ts | 4 +- sentry.server.config.ts | 5 +- 9 files changed, 366 insertions(+), 35 deletions(-) create mode 100644 dev_docs/vercel-cpu-overage-2026-05.md diff --git a/app/[locale]/docs/page.tsx b/app/[locale]/docs/page.tsx index 00b708fa..8ba1353d 100644 --- a/app/[locale]/docs/page.tsx +++ b/app/[locale]/docs/page.tsx @@ -15,8 +15,16 @@ import { ensureSeoDescription } from "@/lib/seo-description"; * 内容交给 ``(root 不传 → 渲染 pageTree 顶层分区)。所有 * 渲染逻辑和 community / career/interview-prep/leetcode 两处共用同一个组 * 件,避免 drift。 + * + * SSG 化(dev_docs/vercel-cpu-overage-2026-05.md H2): + * 原版 build 表里是 ƒ Dynamic,即便 setRequestLocale 都加了。原因是 page + * 没显式 `dynamic = "force-static"`,Next 16 默认让带 await fetch 或 RSC + * getLocale 的 [locale] page 跑 dynamic。SectionIndex 内部用的 getLocale() + * 只读静态 pageTree(无 IO),所以加 force-static 不会丢任何东西。 */ +export const dynamic = "force-static"; + interface Props { params: Promise<{ locale: string }>; } @@ -64,3 +72,7 @@ export async function generateMetadata({ params }: Props): Promise { }), }; } + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} diff --git a/app/[locale]/events/page.tsx b/app/[locale]/events/page.tsx index 2204ebc0..5f3518f4 100644 --- a/app/[locale]/events/page.tsx +++ b/app/[locale]/events/page.tsx @@ -1,15 +1,23 @@ 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 才抛,空列表不是错误。 + * ISR 化(dev_docs/vercel-cpu-overage-2026-05.md H2): + * 原版 export const revalidate = 300 但 build 输出仍是 ƒ Dynamic —— 因为 + * 没 setRequestLocale,next-intl 退回 cookies() 推断 locale,整页变 dynamic。 + * 每条访问 = 1 Fluid 调用。加 params + setRequestLocale + generateStaticParams + * 让 revalidate=300 真正生效:build 时各 locale 预渲染一份,5min 内访问 + * 直接命中 CDN,过期时后台静默更新。 * * revalidate: 300 把 Neon 打压力压到每 5min 一次 SSR,和 PR #286 的 profile 策略一致。 */ @@ -24,22 +32,34 @@ interface ApiResponse { async function fetchEvents(): Promise { const backendUrl = process.env.BACKEND_URL; + // 改成"失败降级返回空"而非 throw:build 时 SSG(generateStaticParams 触发预渲染) + // 如果后端不可达,throw 会让整次 build 失败。返回空数组让页面 build 出"暂无活动" + // 的静态壳,等 revalidate=300 到点后台刷新拿到真数据。 + // 同步好处:Vercel CF 偶发挡 build IP / 后端临时挂时不再 break deploy。 if (!backendUrl) { - // 开发环境或 misconfig 时给一个清晰报错,而不是静默空列表 - throw new Error("BACKEND_URL is not configured"); + console.warn("[events] BACKEND_URL not set, rendering empty list"); + return []; } - 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) { + console.warn( + `[events] backend ${res.status} ${res.statusText}, rendering empty list`, + ); + return []; + } + const json = (await res.json()) as ApiResponse; + return json.success && json.data ? json.data : []; + } catch (err) { + console.warn("[events] fetch failed, rendering empty list:", err); + return []; } - const json = (await res.json()) as ApiResponse; - return json.success && json.data ? json.data : []; } export const metadata: Metadata = { @@ -48,7 +68,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); @@ -204,3 +232,7 @@ function formatDate(iso: string): string { return iso; } } + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 69772bb0..55aded28 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -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 = { @@ -10,7 +13,23 @@ export const metadata: Metadata = { robots: { index: false, follow: true }, }; -export default async function LoginPage() { +/** + * /[locale]/login —— SSG 化(dev_docs/vercel-cpu-overage-2026-05.md H2)。 + * + * 之前没 setRequestLocale,next-intl 退回 cookies() 推断 locale,整页 ƒ + * Dynamic。login 是纯静态卡片 + 一个 client 按钮,没有理由每请求都 SSR。 + * 加 params + setRequestLocale + generateStaticParams 让两个 locale build 时 + * 预渲染,登录页所有访问都从 CDN 出。 + */ +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 (
@@ -26,3 +45,7 @@ export default async function LoginPage() {
); } + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} diff --git a/app/not-found.tsx b/app/not-found.tsx index 2d742024..f87f577e 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,26 +1,40 @@ 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.tsx —— 当 URL 完全不匹配任何 route segment 时 Next 渲染这里。 + * + * 为什么必须保持静态(修复 Vercel CPU 超额,dev_docs/vercel-cpu-overage-2026-05.md): + * 之前版本 await getTranslations("notFound") 让这条路由变 ƒ Dynamic。 + * 线上所有 .env / wp- / php / graphql 漏洞扫描都落到这条 404 页面,每条扫描 + * = 1 Fluid 调用 + 1 client-side analytics POST。18:34 单一时间窗就有 + * 30+ 条扫描,月度累积是 CPU 配额的主要消耗源之一。 + * + * 修复策略:去掉 next-intl 依赖(getTranslations 内部走 cookies(),自动 + * force-dynamic),改成根目录 not-found 用 hardcoded 双语,让 Next 把这条 + * 静态生成。之后所有 scanner 404 直接由 CDN 兜底,0 Fluid CPU。 + * + * /[locale]/not-found.tsx 由 [locale] segment 各自的 not-found 处理,那里 + * 可以走完整 i18n。根 not-found 只是兜最外层 404,少数情况触发。 + * + * NotFoundTracker:保留 client 端 umami 埋点。它在浏览器里跑,不影响服务端 + * 静态化判定(client-only useEffect 不会让 RSC 树 dynamic)。 + */ +export default function NotFound() { return (

404

- {t("heading")} + 页面不存在 · Page not found

-

{t("body")}

+

+ 你访问的页面可能已被移动或不存在。Try going back home. +

diff --git a/dev_docs/vercel-cpu-overage-2026-05.md b/dev_docs/vercel-cpu-overage-2026-05.md new file mode 100644 index 00000000..3b9e1adb --- /dev/null +++ b/dev_docs/vercel-cpu-overage-2026-05.md @@ -0,0 +1,198 @@ +# Vercel CPU 超额诊断 & 修复(2026-05) + +> 2026-05-12 触发:Hobby plan **Fluid Active CPU 7h54m / 4h(198%)**, +> Fast Origin Transfer 12.04 GB / 10 GB(120%)。用户报告"之前做过 SSR +> 优化但情况更糟"。本文档记录调查方法、根因、修复、验证手段。 + +## TL;DR + +之前的 SSG 优化(2026-05-06 commit `8517332`)**只翻转了 1 条路由**(/[locale] +首页)。`next build` 输出显示**还剩 18 条 ƒ Dynamic**,其中最致命的是 +`/_not-found`:所有漏洞扫描器(.env / wp-admin / php-info / graphql)和真实 +404 全落到这条 dynamic 路由。 + +本次修复在 `next build` 输出层面把 6 条路由从 ƒ 翻成 ● / ○: + +| 路由 | 修前 | 修后 | 影响 | +| ---------------- | --------- | ------------ | --------------------------------------- | +| /\_not-found | ƒ Dynamic | **○ Static** | 所有 scanner / 404 路径不再烧 Fluid CPU | +| /[locale]/docs | ƒ | ● SSG | /zh/docs / /en/docs 走 CDN | +| /[locale]/events | ƒ | **● ISR 5m** | revalidate=300 终于真正生效 | +| /[locale]/login | ƒ | ● SSG | | +| /[locale]/editor | ƒ | ● SSG | (意外 cascade) | +| /[locale]/share | ƒ | ● SSG | (意外 cascade) | + +同时: + +- `proxy.ts` 加 bot path 早返 404(拦在 edge,根本不进 Fluid) +- Sentry tracesSampleRate 0.1 → 0.02(5× 减少 trace 开销) + +## 调查方法(验证黄金标准) + +**唯一可信源:`next build` 的 Route table。** 不能靠"我以为加了 force-static +就行"——这正是上一轮优化为什么没生效。 + +```bash +# 修前快照 +pnpm build 2>&1 | tee /tmp/build-before.txt + +# 提取 Route 表 +grep -E '^[┌├└] ' /tmp/build-before.txt > /tmp/routes-before.txt +``` + +修后跑同样命令,`diff /tmp/routes-before.txt /tmp/routes-after.txt` 直接看 +哪些 ƒ 翻成 ● / ○。这是**事实**,不是推断。 + +## 四个被证实的根因 + +### H1:`/_not-found` dynamic(最大单点) + +**证据**: + +``` +ƒ /_not-found ← build 输出 +``` + +``` +# 18:34:34-18:34:40 一波扫描,全 → /_not-found(来源 Vercel runtime logs) +POST /zh/graphql/v2 200 +POST /zh/graphql/v1 200 +GET /var/www/html/.env 404 +GET /css../../.env.production 404 +... +``` + +**根因**:原 `app/not-found.tsx` 用 `await getTranslations("notFound")`。 +`getTranslations` 内部走 `cookies()` 推断 locale,把这条路由钉成 dynamic。 +每条扫描 + 每条真实 404 都是一次 Fluid 调用。 + +**修复**:去掉 `getTranslations`,改成 hardcoded 双语 hard-coded text("页面 +不存在 · Page not found")。根 not-found 本就不知道用户期望哪种语言,双语并列 +最稳。 + +**验证**:`next build` 显示 `○ /_not-found`,curl bot 路径应该非常快: + +```bash +$ time curl -so /dev/null -w "%{http_code} %{time_total}s\n" \ + https://involutionhell.com/zh/some-fake-path +404 0.045s # ← 应该 < 100ms(CDN 静态文件) +# 修前会是 200-500ms(Fluid 函数渲染) +``` + +### H1b:Bot scanner 还是会过 i18n middleware → SSR + +即便 not-found 静态化,扫描器路径会先被 next-intl middleware 加 `/zh/` 前缀, +然后撞 `[locale]/[...slug]` 的 catch-all(● SSG 命中静态资源是 OK 的)或 +admin 路由(ƒ Dynamic,仍然烧 CPU)。 + +**修复**:`proxy.ts` 在 i18n middleware 之前加 `isBotScanPath()` 早返 404。 +列表包含 OWASP/nikto/dirbuster 常见指纹(`.php` / `wp-` / `.env` / `graphql` / +`werkzeug/console` / `phpmyadmin` 等)。明确**不包括** `login`/`admin` 等 +真实业务路径。 + +**验证**: + +```bash +# 应该 < 50ms(edge middleware 直接返) +$ curl -so /dev/null -w "%{http_code} %{time_total}s\n" \ + https://involutionhell.com/wp-admin/ +404 0.030s + +# Bot 扫 .php / .env / wp-* / graphql 全是 404 来自 edge +``` + +### H2:13 条 `[locale]/*` 缺 setRequestLocale → 全部 ƒ + +**证据**(修前): + +```bash +$ grep -l "setRequestLocale" app/[locale]/*/page.tsx +# 几乎为空,仅 docs/page.tsx 命中 +``` + +**根因**:next-intl 的 SSG 启用条件是**每个 page** 都得调 `setRequestLocale`。 +parent layout(`[locale]/layout.tsx`)调了不够。缺这一步时 next-intl 内部 +fallback 到 `cookies()`/`headers()`,让整棵 RSC 树变 dynamic。 + +参考 layout.tsx 的注释: + +> 缺这一行的话,next-intl 会回退到从 cookies()/headers() 推断 locale, +> 整棵 RSC 树重新变 dynamic,绕了一圈又回到老问题。 + +**修复**:给 `/events` `/login` 加 `params: Promise<{locale:string}>` + +`await params; setRequestLocale(locale)` + `generateStaticParams() => +routing.locales.map(l => ({locale: l}))`。 + +`/[locale]/docs/page.tsx` 已有 setRequestLocale 但仍然 ƒ —— 加 `export const +dynamic = "force-static"` 显式 opt-in。 + +**Build 期容错**:`/events` 走 SSG 后会在 build 时尝试 fetch backend。原版 +fetch 失败抛错 → 整次 build 挂。改成失败降级返回空数组,build 出"暂无活动" +壳,ISR=300s 之后台拿到真数据。也是更鲁棒的设计。 + +**验证**: + +```bash +# 修前 +ƒ /[locale]/events +ƒ /[locale]/login +ƒ /[locale]/docs + +# 修后 +● /[locale]/events 5m 1y # ← 真 ISR 了 +● /[locale]/login +● /[locale]/docs +``` + +线上 curl: + +```bash +$ curl -sI https://involutionhell.com/zh/events | grep -iE "x-vercel-cache|age" +x-vercel-cache: HIT # ← CDN 命中 +age: 142 +``` + +### H3:Sentry tracesSampleRate 0.1(10%)叠在每次调用上 + +**证据**:`sentry.server.config.ts:23` 和 `sentry.edge.config.ts:18` 都写 +`tracesSampleRate: 0.1`。在 368K 月度 invocations 下 = ~37K traces,每条 +trace 含 span 序列化 + 异步上报,叠加在每次 Fluid 调用 + 每次 edge middleware +上。 + +**修复**:0.1 → 0.02。日均仍能采到几千条 trace 监控 P95 性能趋势。 + +**验证**:Sentry dashboard `Performance` tab,Events / 24h 应该下降 5×。 + +## 还没修但记录在案的问题 + +| 问题 | 影响 | 为啥不在本 PR 修 | +| ----------------------------------------------------------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------- | +| `/api/docs-tree` 声明 `force-static` 但 build 仍 ƒ | 中(每次访问 1 invocation) | 需要改成 build 时生成 JSON 写 public/,路由读静态文件。改动较大单独 PR | +| `/feed/page.tsx` 用 server-side `searchParams` 把页面钉死 dynamic | 中(爬虫扫 8 分类 tab = 16 invocations/min) | 需要把 category 过滤改成 client-side fetch / route param。设计上重 | +| `/rank/page.tsx` 同上 | 低(rank 流量不大) | 同上 | +| `/[locale]/u/[username]/*` 三条 ƒ | 中(爬虫扫随机 username = 一次 SSR + backend 404) | 需要 dynamicParams + 限流 | +| Uptime monitor 每分钟打 `/`(GET / 307 GET /zh 200) | 验证后**不是 Fluid 主因**(/zh 是 ● SSG 走 CDN,不烧 Fluid) | 不修。可选优化:提供 `/api/health` edge 静态 200 减少 edge requests | + +## 量化预期 + +**修前**:368K function invocations / mo,CPU 7h54m / 4h(**超 198%**)。 + +**修后估算**: + +- /\_not-found 静态化 + bot blocklist:拦截 ~30% 的 scanner 流量 → 函数调用 -30% +- 5 条 [locale]/\* SSG:~15-20% 调用归零(这些是真实用户 + 爬虫的常见目标) +- Sentry 0.1 → 0.02:每次调用 CPU 开销 -5-10% + +**保守预期**:CPU 从 7h54m → ~3-4h(贴近 4h 配额,可能略超)。 +**乐观预期**:CPU < 2h,远低于配额。 + +24-48h 后 dashboard 数据出来才能确定。如果还超,下一轮修剩下的 `/feed` +和 `/rank` searchParams 问题。 + +## 后续工作 + +- [ ] 修 `/api/docs-tree` build-time 生成 +- [ ] /feed /rank 搜索参数从 server-side 改 client-side +- [ ] /[locale]/u/[username] ISR + dynamicParams +- [ ] 给 admin 路由加 edge-middleware 早返 401,不让 bot 触发 Fluid +- [ ] (可选)Cloudflare proxy 挡在 Vercel 前过滤 bot diff --git a/generated/leetcode-slug-map.json b/generated/leetcode-slug-map.json index 2c125e20..0c226954 100644 --- a/generated/leetcode-slug-map.json +++ b/generated/leetcode-slug-map.json @@ -5,7 +5,6 @@ "1664生成平衡数组的方案数_translated": "1664-sheng-cheng-ping-heng-shu-zu-de-fang-an-shu-translated", "1825求出 MK 平均值_translated": "1825-qiu-chu-mk-ping-jun-zhi-translated", "1828统计一个圆中点的数目_translated": "1828-tong-ji-yi-ge-yuan-zhong-dian-de-shu-mu-translated", - "2131. 连接两字母单词得到的最长回文串": "2131-lian-jie-liang-zi-mu-dan-ci-de-dao-de-zui-chang-hui-wen-chuan", "2299强密码检验器II_translated": "2299-qiang-mi-ma-jian-yan-qi-iitranslated", "2309兼具大小写的最好英文字母_translated": "2309-jian-ju-da-xiao-xie-de-zui-hao-ying-wen-zi-mu-translated", "2335. 装满杯子需要的最短总时长_translated": "2335-zhuang-man-bei-zi-xu-yao-de-zui-duan-zong-shi-chang-translated", @@ -15,16 +14,13 @@ "2894. 分类求和并作差": "2894-fen-lei-qiu-he-bing-zuo-cha", "3072. 将元素分配到两个数组中 II_translated": "3072-jiang-yuan-su-fen-pei-dao-liang-ge-shu-zu-zhong-iitranslated", "345. 反转字符串中的元音字母_translated": "345-fan-zhuan-zi-fu-chuan-zhong-de-yuan-yin-zi-mu-translated", - "46.全排列": "46-quan-pai-lie", "538.把二叉搜索树转换为累加树_translated": "538-ba-er-cha-sou-suo-shu-zhuan-huan-wei-lei-jia-shu-translated", "6323. 将钱分给最多的儿童_translated": "6323-jiang-qian-fen-gei-zui-duo-de-er-tong-translated", "76最小覆盖子串_translated": "76-zui-xiao-fu-gai-zi-chuan-translated", - "93复原Ip地址": "93-fu-yuan-ip-di-zhi", "994.腐烂的橘子_translated": "994-fu-lan-de-ju-zi-translated", "[121]买卖股票的最佳时期_translated": "121-mai-mai-gu-piao-de-zui-jia-shi-qi-translated", "[1333]餐厅过滤器_translated": "1333-can-ting-guo-l-qi-translated", "[146]LRU 缓存_translated": "146lru-huan-cun-translated", - "[1545]找出第 N 个二进制字符串中的第 K 位": "1545-zhao-chu-di-n-ge-er-jin-zhi-zi-fu-chuan-zhong-de-di-k-wei", "[213]打家劫舍 II_translated": "213-da-jia-jie-she-iitranslated", "[2490]回环句_translated": "2490-hui-huan-ju-translated", "[2562]找出数组的串联值_translated": "2562-zhao-chu-shu-zu-de-chuan-lian-zhi-translated", diff --git a/proxy.ts b/proxy.ts index 342237f1..fa78faf9 100644 --- a/proxy.ts +++ b/proxy.ts @@ -29,6 +29,46 @@ const LEETCODE_OLD_PATH_TAIL = "/docs/CommunityShare/Leetcode"; const intlMiddleware = createMiddleware(routing); +/** + * Bot / vulnerability scanner path patterns —— 在 edge 早返 404, + * 不让请求穿透到 Fluid 函数(dev_docs/vercel-cpu-overage-2026-05.md 的 H1b)。 + * + * 背景:Vercel runtime 日志显示线上有持续的漏洞扫描(.env / wp-admin / + * php-info / werkzeug / graphql 等)。原来这些路径会被 next-intl 加 /zh 前缀 + * 后落到 [locale]/[...slug] 或根 /_not-found,每条扫描 ≈ 1 Fluid 调用。 + * 整理列表参考 OWASP top-10 + 主流 scanner(nikto / nmap / dirbuster)的指纹。 + * + * 注意:列表里**不要**放可能跟真实业务路径重名的 segment(不要加 admin、login + * 这些,admin 路由是有的)。只放 100% 业务用不到的字符串。 + */ +const BOT_PATH_PATTERNS = [ + // PHP / 老 CMS:本站根本没装 PHP,所有 *.php 都是扫描 + /\.php(?:$|[?#/])/i, + // wp-* 系列:WordPress 扫描 + /\/wp-(admin|content|includes|login|json|config)(?:$|[/?#])/i, + // .env / config 文件直接探测 + /\.env(?:\.[a-z]+)?(?:$|[?#/])/i, + /\/(config|settings|secrets|credentials)\.(yml|yaml|json|ini|toml|xml)(?:$|[?#])/i, + // GraphQL / GQL endpoint 扫描(本站没 GraphQL) + /\/(graphql|gql|api\/graphql|v\d+\/graphql)(?:$|[/?#])/i, + // Werkzeug / Flask debug console + /\/werkzeug\/console/i, + // 常见 admin / debug panels 探测路径(本站 admin 在 /[locale]/admin, + // 这些是其他平台特有路径,扫到必是 bot) + /\/(phpmyadmin|adminer|pma|dbadmin|mysqladmin)(?:$|[/?#])/i, + // git / svn 仓库文件暴露探测 + /\/\.(git|svn|hg|bzr)\/(?:config|HEAD|entries)/i, + // 已知敏感文件(war/jar/key/pem)扫描 + /\.(war|jar|sql|bak|key|pem|pfx)$/i, +]; + +function isBotScanPath(pathname: string): boolean { + for (const re of BOT_PATH_PATTERNS) { + if (re.test(pathname)) return true; + } + return false; +} + function redirectLeetcodeIfNeeded(req: NextRequest): NextResponse | null { const { pathname } = req.nextUrl; @@ -72,6 +112,17 @@ function redirectLeetcodeIfNeeded(req: NextRequest): NextResponse | null { } export function proxy(req: NextRequest) { + // 0. Bot / scanner path 早返 404 —— 拦在 edge,不让进 Fluid 函数。 + // 放在最前面是因为 Leetcode redirect 和 i18n middleware 都会让请求 + // 穿到 [...slug] / not-found,烧 Fluid CPU。这里 0 函数调用直接挡。 + if (isBotScanPath(req.nextUrl.pathname)) { + // 显式 404 + no-cache:返回 200 的话攻击者会以为命中漏洞继续扫 + return new NextResponse(null, { + status: 404, + headers: { "cache-control": "no-store" }, + }); + } + // 1. Leetcode 中文 slug 优先做 301 const leetcodeRedirect = redirectLeetcodeIfNeeded(req); if (leetcodeRedirect) return leetcodeRedirect; diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 46f301c2..4d95d175 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -15,7 +15,9 @@ const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ dsn, enabled: process.env.NODE_ENV === "production" && !!dsn, - tracesSampleRate: 0.1, + // 10% → 2%(dev_docs/vercel-cpu-overage-2026-05.md H3)。Edge middleware + // 每条请求都过这里,10% trace 直接叠在 Fluid CPU 上。降到 2% 显著省 CPU。 + tracesSampleRate: 0.02, debug: false, beforeSend(event) { if (event.request?.headers) { diff --git a/sentry.server.config.ts b/sentry.server.config.ts index ae522920..07530941 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -20,7 +20,10 @@ const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ dsn, enabled: process.env.NODE_ENV === "production" && !!dsn, - tracesSampleRate: 0.1, + // 10% → 2%(dev_docs/vercel-cpu-overage-2026-05.md H3)。 + // 10% 在月百万级请求量下产生 10w+ traces,每条 trace 上报又叠加 Fluid CPU。 + // 2% 仍能日采几千条覆盖 P95 性能趋势,trace 配额也更宽裕。 + tracesSampleRate: 0.02, debug: false, beforeSend(event) { if (event.request?.headers) { From 69ae9860ddf72936de375e51928ea8b48590e1ba Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 19:41:14 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E6=92=A4=E5=9B=9E=E7=9C=81=20CPU=20?= =?UTF-8?q?hack=EF=BC=8C=E6=81=A2=E5=A4=8D=20best=20practice=EF=BC=88dashb?= =?UTF-8?q?oard=20=E6=95=B0=E6=8D=AE=E4=BF=AE=E8=AE=A2=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 review 后指出"丢西瓜捡芝麻"风险,复盘 Vercel 30 天 dashboard 后修订: ## 真实根因(不是 /_not-found) dashboard 30 天曲线显示 5/11 CPU 峰值 80-90min(基线 5-15min/day),完美 对应 SEO PR 落地时间: - 5/11 15:01-15:39 UTC: PR #341 (253 MDX descriptions + 32 新 EN 翻译) - 5/11 16:02-18:41 UTC: PR #342 (remark heading shift + leetcode dedup) - 5/11 19:01-19:27 UTC: PR #343 + #340,4 小时 4 次 deploy 清空 ISR 加上 deploy.yml 里 IndexNow 主动告诉 Bing 重抓 → 5/10-5/12 crawler 风暴。 **这是 SEO 工作 successful 的代价,不是 bug。** 真实流量。 /_not-found 静态化 + bot blocklist 是真实 waste 清理(保留),但不能独立 解释 4× 激增。 ## 撤回的两条 hack 1. Sentry tracesSampleRate 0.1 → 0.02:撤回,保持 10% observability 不能为这点 CPU 让步,10% 是行业标准,client/server/edge 三处必须一致才能跨 runtime 串联 trace。 2. fetchEvents 失败一律返空:改成只在 NEXT_PHASE === phase-production-build 时返空,运行时仍 throw 让 Sentry 抓真故障。否则 prod backend 挂了会被 误显示成"暂无活动",掩盖故障。 ## 保留的修复(best practice,不是 hack) - /_not-found ƒ → ○:根 404 本就不需要 i18n - proxy.ts bot blocklist:扫描器不该烧 Fluid - /[locale]/docs /events /login 缺 setRequestLocale → 补:SSG/ISR 本就该工作 - /editor /share cascade ●:纯 client component,安全 ## Build 验证 pnpm build 重跑: - /[locale]/events 仍是 ● ISR 5m 1y - [events] fetch failed at build, rendering empty shell(NEXT_PHASE guard 工作) --- app/[locale]/events/page.tsx | 43 +++++++++++++------ dev_docs/vercel-cpu-overage-2026-05.md | 59 ++++++++++++++++++++------ sentry.edge.config.ts | 6 +-- sentry.server.config.ts | 8 ++-- 4 files changed, 85 insertions(+), 31 deletions(-) diff --git a/app/[locale]/events/page.tsx b/app/[locale]/events/page.tsx index 5f3518f4..1c4c8af2 100644 --- a/app/[locale]/events/page.tsx +++ b/app/[locale]/events/page.tsx @@ -30,15 +30,24 @@ interface ApiResponse { message?: string; } +/** + * 在 build 阶段才允许"后端不可达就降级返空"。Next 16 用 NEXT_PHASE 标记 + * phase-production-build,build 时返空让 generateStaticParams 能跑完不挂; + * 运行时仍然 throw,Sentry / 错误页才能感知真故障,不至于把 prod backend + * 挂了误显示成"暂无活动"。 + */ +const IS_BUILD = process.env.NEXT_PHASE === "phase-production-build"; + async function fetchEvents(): Promise { const backendUrl = process.env.BACKEND_URL; - // 改成"失败降级返回空"而非 throw:build 时 SSG(generateStaticParams 触发预渲染) - // 如果后端不可达,throw 会让整次 build 失败。返回空数组让页面 build 出"暂无活动" - // 的静态壳,等 revalidate=300 到点后台刷新拿到真数据。 - // 同步好处:Vercel CF 偶发挡 build IP / 后端临时挂时不再 break deploy。 if (!backendUrl) { - console.warn("[events] BACKEND_URL not set, rendering empty list"); - return []; + 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"); } try { const res = await fetch(`${backendUrl}/api/events`, { @@ -49,16 +58,26 @@ async function fetchEvents(): Promise { }, }); if (!res.ok) { - console.warn( - `[events] backend ${res.status} ${res.statusText}, rendering empty list`, - ); - return []; + 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; return json.success && json.data ? json.data : []; } catch (err) { - console.warn("[events] fetch failed, rendering empty list:", err); - return []; + if (IS_BUILD) { + console.warn( + "[events] fetch failed at build, rendering empty shell:", + err, + ); + return []; + } + // 运行时失败仍然 throw —— Sentry 抓到,错误页正常显示,不掩盖故障 + throw err; } } diff --git a/dev_docs/vercel-cpu-overage-2026-05.md b/dev_docs/vercel-cpu-overage-2026-05.md index 3b9e1adb..7f5b9441 100644 --- a/dev_docs/vercel-cpu-overage-2026-05.md +++ b/dev_docs/vercel-cpu-overage-2026-05.md @@ -4,12 +4,30 @@ > Fast Origin Transfer 12.04 GB / 10 GB(120%)。用户报告"之前做过 SSR > 优化但情况更糟"。本文档记录调查方法、根因、修复、验证手段。 -## TL;DR +## TL;DR(看完 Vercel dashboard 30 天图表后的修订诊断) -之前的 SSG 优化(2026-05-06 commit `8517332`)**只翻转了 1 条路由**(/[locale] -首页)。`next build` 输出显示**还剩 18 条 ƒ Dynamic**,其中最致命的是 -`/_not-found`:所有漏洞扫描器(.env / wp-admin / php-info / graphql)和真实 -404 全落到这条 dynamic 路由。 +**真正的元凶**:5/11 一天里 PR #341(253 MDX descriptions backfill + 32 新 EN +翻译页)+ PR #342(remark h1 plugin)+ PR #343(escape-angles)连续 4 次 +deploy。每次 deploy 自动 ping IndexNow → Bing/Google 5/10-5/12 大规模重抓 + +索引 32 个新 URL。Dashboard 30 天曲线显示 5/11 CPU 峰值 80-90min(pre-spike +基线 5-15min/day),完美对应 SEO PR 落地时间。 + +**这是 SEO 工作 successful 的代价,不是 bug**。流量是真实的搜索引擎 + 真实 +用户增长,付费 Pro plan 阈值就是这么到的。 + +**本 PR 做的事是真实 waste 的清理**(不是 hack): + +- `/_not-found` 之前是 ƒ Dynamic(每条 scanner 都烧 Fluid)→ ○ Static +- `proxy.ts` 加 bot path 早返 404,scanner 不再进 Fluid +- `/[locale]/docs` `/events` `/login` 缺 setRequestLocale 导致退回 dynamic → + 补上 + generateStaticParams 让 SSG/ISR 真正工作 + +**撤回的"省 CPU hack"(写完才意识到丢了西瓜)**: + +- ~~Sentry tracesSampleRate 0.1 → 0.02~~:保持 10%。observability 不能为这 + 点 CPU 让步,10% 是行业标准 +- ~~fetchEvents 失败一律返空~~:改成只在 `NEXT_PHASE === "phase-production-build"` + 时返空,运行时仍然 throw 让 Sentry 抓到真故障 本次修复在 `next build` 输出层面把 6 条路由从 ƒ 翻成 ● / ○: @@ -152,16 +170,33 @@ x-vercel-cache: HIT # ← CDN 命中 age: 142 ``` -### H3:Sentry tracesSampleRate 0.1(10%)叠在每次调用上 +### H3:~~Sentry tracesSampleRate 0.1 → 0.02~~(撤回) + +最初打算改 10% → 2% 省 CPU。CR 后撤回——Sentry trace 在 30 天 CPU 占比远不及 +crawler 流量,2% 省的是芝麻丢的是西瓜(observability)。10% 是行业标准, +client/server/edge 三处必须一致才能跨 runtime 串联请求链路。 + +**结论**:保持 0.1,不动 Sentry config。 + +### H4:dashboard 数据让根因更清晰(SEO 重抓风暴) + +补观察后修订: -**证据**:`sentry.server.config.ts:23` 和 `sentry.edge.config.ts:18` 都写 -`tracesSampleRate: 0.1`。在 368K 月度 invocations 下 = ~37K traces,每条 -trace 含 span 序列化 + 异步上报,叠加在每次 Fluid 调用 + 每次 edge middleware -上。 +| 日期 | CPU | 主要 deploy | +| -------------------- | ------------ | -------------------------------------------------------- | +| 4/14 - 5/5 | 5-15min/day | 普通流量 + 周期扫描 baseline | +| 5/11 15:01-15:39 UTC | 30 → 50min | PR #341:253 MDX descriptions backfill + 32 新 EN 翻译 | +| 5/11 16:02-18:41 UTC | 50 → 80min | PR #342:remark heading shift + leetcode dedup | +| 5/11 19:01-19:27 UTC | **90min 峰** | PR #343 + #340 deps,4 小时内 4 次 deploy ISR cache wipe | +| 5/12 | ~45min | crawler 余波未消,但日益下降 | -**修复**:0.1 → 0.02。日均仍能采到几千条 trace 监控 P95 性能趋势。 +`.github/workflows/deploy.yml` 的 `INDEXNOW_API` 让每次 deploy 都**主动告诉** +Bing / Google "URL 变了,快重抓"。PR #341 一次性改 253 个 MDX + 加 32 个新 +URL,触发的就是这一波重抓。 -**验证**:Sentry dashboard `Performance` tab,Events / 24h 应该下降 5×。 +**结论**:5.11 之后激增是真实流量。本 PR 修的是**不该花的 CPU**(scanner / +缺 SSG 的路由),让真实流量的边际成本最小化,但解决不了"SEO 太成功"这件事。 +长期路径:上 Pro plan 或 Cloudflare proxy 挡 crawler。 ## 还没修但记录在案的问题 diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 4d95d175..7b61cf6c 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -15,9 +15,9 @@ const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ dsn, enabled: process.env.NODE_ENV === "production" && !!dsn, - // 10% → 2%(dev_docs/vercel-cpu-overage-2026-05.md H3)。Edge middleware - // 每条请求都过这里,10% trace 直接叠在 Fluid CPU 上。降到 2% 显著省 CPU。 - tracesSampleRate: 0.02, + // 10% 采样:与 server config 对齐,保证 client/server/edge 三处 trace 比例一致, + // 跨 runtime 串联请求链路才完整。省 CPU 的 hack 撤回,observability 优先。 + tracesSampleRate: 0.1, debug: false, beforeSend(event) { if (event.request?.headers) { diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 07530941..67b97b4c 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -20,10 +20,10 @@ const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ dsn, enabled: process.env.NODE_ENV === "production" && !!dsn, - // 10% → 2%(dev_docs/vercel-cpu-overage-2026-05.md H3)。 - // 10% 在月百万级请求量下产生 10w+ traces,每条 trace 上报又叠加 Fluid CPU。 - // 2% 仍能日采几千条覆盖 P95 性能趋势,trace 配额也更宽裕。 - tracesSampleRate: 0.02, + // 10% 采样:行业标准,足以发现 P95/P99 慢请求 + 偶发错误关联的请求链路。 + // (曾试过 2% 想省 Vercel CPU,但 trace overhead 占比远小于 SEO 重抓带来的 + // crawler 流量,2% 是省芝麻丢西瓜的 hack —— observability 不能为这点 CPU 让步。) + tracesSampleRate: 0.1, debug: false, beforeSend(event) { if (event.request?.headers) { From c6348211af65a48402856261417ffc93768b8f6a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 19:51:31 +0000 Subject: [PATCH 3/4] =?UTF-8?q?chore:=20=E7=A0=8D=E5=A4=9A=E4=BD=99?= =?UTF-8?q?=E6=B3=A8=E9=87=8A=20+=20=E6=96=B0=E5=A2=9E=20CLAUDE.md=20?= =?UTF-8?q?=E5=9B=BA=E5=8C=96=20review=20=E6=8E=A8=E5=9B=9E=E7=9A=84?= =?UTF-8?q?=E5=8F=8D=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 砍掉的注释(被推回:"丢西瓜捡芝麻"叙事 / dev_docs 引用 / 历史回顾) - app/not-found.tsx: 12 行 docstring → 1 行约束说明 - app/[locale]/events: ISR docstring + IS_BUILD 段落 → 单行 each - app/[locale]/login: 删整段"SSG 化"docstring - app/[locale]/docs: 删历史叙事,留 force-static 必要性说明 - proxy.ts: 删 dev_docs 引用 + 历史回顾,留 BOT_PATH 维护警告 - sentry.{server,edge}.config: 删"曾试过 2%"叙事,留三处一致约束 引用 dev_docs 文件路径 / PR 编号 / "原版/之前" 叙事都会 rot —— 全删, 真要找上下文用 git blame。 ## 新增 CLAUDE.md(不重复 AGENT.md) 固化 5 条被 review 推回过的反模式: 1. 最佳实践 > 微小资源优化(不降 Sentry / 不藏 backend 错误 / 流量真增长就付费) 2. 路由分类必须用 next build 输出验证,不要凭感觉 + next-intl SSG 三条件 3. 注释规则项目特化(默认不写,禁 dev_docs/PR/历史引用) 4. CR 反馈直接改不分级问用户 5. generated/leetcode-slug-map.json 必须 commit 进 git 的特例 build 重跑 6 条路由翻转保持,无 regression。 --- CLAUDE.md | 93 ++++++++++++++++++++++++++++++++++++ app/[locale]/docs/page.tsx | 8 +--- app/[locale]/events/page.tsx | 23 ++------- app/[locale]/login/page.tsx | 8 ---- app/not-found.tsx | 22 ++------- proxy.ts | 20 ++------ sentry.edge.config.ts | 3 +- sentry.server.config.ts | 4 +- 8 files changed, 108 insertions(+), 73 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..2b96a396 --- /dev/null +++ b/CLAUDE.md @@ -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。 diff --git a/app/[locale]/docs/page.tsx b/app/[locale]/docs/page.tsx index 8ba1353d..3d0c830f 100644 --- a/app/[locale]/docs/page.tsx +++ b/app/[locale]/docs/page.tsx @@ -15,14 +15,10 @@ import { ensureSeoDescription } from "@/lib/seo-description"; * 内容交给 ``(root 不传 → 渲染 pageTree 顶层分区)。所有 * 渲染逻辑和 community / career/interview-prep/leetcode 两处共用同一个组 * 件,避免 drift。 - * - * SSG 化(dev_docs/vercel-cpu-overage-2026-05.md H2): - * 原版 build 表里是 ƒ Dynamic,即便 setRequestLocale 都加了。原因是 page - * 没显式 `dynamic = "force-static"`,Next 16 默认让带 await fetch 或 RSC - * getLocale 的 [locale] page 跑 dynamic。SectionIndex 内部用的 getLocale() - * 只读静态 pageTree(无 IO),所以加 force-static 不会丢任何东西。 */ +// force-static 必需:SectionIndex 内部用 getLocale(),Next 16 会按"可能 dynamic" +// 处理,加这条显式 opt-in 静态化(pageTree 是 build-time 数据,无运行时依赖)。 export const dynamic = "force-static"; interface Props { diff --git a/app/[locale]/events/page.tsx b/app/[locale]/events/page.tsx index 1c4c8af2..922a3fba 100644 --- a/app/[locale]/events/page.tsx +++ b/app/[locale]/events/page.tsx @@ -9,19 +9,9 @@ import type { EventView } from "./types"; import { sanitizeMediaUrl } from "@/lib/url-safety"; import { routing } from "@/i18n/routing"; -/** - * /events 列表页。 - * - * ISR 化(dev_docs/vercel-cpu-overage-2026-05.md H2): - * 原版 export const revalidate = 300 但 build 输出仍是 ƒ Dynamic —— 因为 - * 没 setRequestLocale,next-intl 退回 cookies() 推断 locale,整页变 dynamic。 - * 每条访问 = 1 Fluid 调用。加 params + setRequestLocale + generateStaticParams - * 让 revalidate=300 真正生效:build 时各 locale 预渲染一份,5min 内访问 - * 直接命中 CDN,过期时后台静默更新。 - * - * 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 { @@ -30,12 +20,7 @@ interface ApiResponse { message?: string; } -/** - * 在 build 阶段才允许"后端不可达就降级返空"。Next 16 用 NEXT_PHASE 标记 - * phase-production-build,build 时返空让 generateStaticParams 能跑完不挂; - * 运行时仍然 throw,Sentry / 错误页才能感知真故障,不至于把 prod backend - * 挂了误显示成"暂无活动"。 - */ +// 只在 build 阶段允许 fetch 失败降级(让 SSG 不挂),运行时仍 throw 给 Sentry。 const IS_BUILD = process.env.NEXT_PHASE === "phase-production-build"; async function fetchEvents(): Promise { diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index 55aded28..a22d62d5 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -13,14 +13,6 @@ export const metadata: Metadata = { robots: { index: false, follow: true }, }; -/** - * /[locale]/login —— SSG 化(dev_docs/vercel-cpu-overage-2026-05.md H2)。 - * - * 之前没 setRequestLocale,next-intl 退回 cookies() 推断 locale,整页 ƒ - * Dynamic。login 是纯静态卡片 + 一个 client 按钮,没有理由每请求都 SSR。 - * 加 params + setRequestLocale + generateStaticParams 让两个 locale build 时 - * 预渲染,登录页所有访问都从 CDN 出。 - */ interface Props { params: Promise<{ locale: string }>; } diff --git a/app/not-found.tsx b/app/not-found.tsx index f87f577e..23a9266a 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -2,25 +2,9 @@ import Link from "next/link"; import { Button } from "@/app/components/ui/button"; import NotFoundTracker from "./not-found-tracker"; -/** - * 根 not-found.tsx —— 当 URL 完全不匹配任何 route segment 时 Next 渲染这里。 - * - * 为什么必须保持静态(修复 Vercel CPU 超额,dev_docs/vercel-cpu-overage-2026-05.md): - * 之前版本 await getTranslations("notFound") 让这条路由变 ƒ Dynamic。 - * 线上所有 .env / wp- / php / graphql 漏洞扫描都落到这条 404 页面,每条扫描 - * = 1 Fluid 调用 + 1 client-side analytics POST。18:34 单一时间窗就有 - * 30+ 条扫描,月度累积是 CPU 配额的主要消耗源之一。 - * - * 修复策略:去掉 next-intl 依赖(getTranslations 内部走 cookies(),自动 - * force-dynamic),改成根目录 not-found 用 hardcoded 双语,让 Next 把这条 - * 静态生成。之后所有 scanner 404 直接由 CDN 兜底,0 Fluid CPU。 - * - * /[locale]/not-found.tsx 由 [locale] segment 各自的 not-found 处理,那里 - * 可以走完整 i18n。根 not-found 只是兜最外层 404,少数情况触发。 - * - * NotFoundTracker:保留 client 端 umami 埋点。它在浏览器里跑,不影响服务端 - * 静态化判定(client-only useEffect 不会让 RSC 树 dynamic)。 - */ +// 根 not-found 必须保持静态:用 next-intl 的 getTranslations 会触发 cookies() +// 让这条路由退化成 ƒ Dynamic,每条 404 / scanner 扫描就吃一次 Fluid CPU。 +// 双语并列是 trade-off —— 根级 not-found 拿不到 locale。 export default function NotFound() { return (
diff --git a/proxy.ts b/proxy.ts index fa78faf9..606e30ce 100644 --- a/proxy.ts +++ b/proxy.ts @@ -29,18 +29,9 @@ const LEETCODE_OLD_PATH_TAIL = "/docs/CommunityShare/Leetcode"; const intlMiddleware = createMiddleware(routing); -/** - * Bot / vulnerability scanner path patterns —— 在 edge 早返 404, - * 不让请求穿透到 Fluid 函数(dev_docs/vercel-cpu-overage-2026-05.md 的 H1b)。 - * - * 背景:Vercel runtime 日志显示线上有持续的漏洞扫描(.env / wp-admin / - * php-info / werkzeug / graphql 等)。原来这些路径会被 next-intl 加 /zh 前缀 - * 后落到 [locale]/[...slug] 或根 /_not-found,每条扫描 ≈ 1 Fluid 调用。 - * 整理列表参考 OWASP top-10 + 主流 scanner(nikto / nmap / dirbuster)的指纹。 - * - * 注意:列表里**不要**放可能跟真实业务路径重名的 segment(不要加 admin、login - * 这些,admin 路由是有的)。只放 100% 业务用不到的字符串。 - */ +// Bot / vulnerability scanner path patterns —— 在 edge 早返 404,不让进 Fluid。 +// 维护约束:只能放 100% 业务用不到的指纹(不要加 admin / login,业务有真路由)。 +// 参考 OWASP top-10 + nikto / dirbuster 常见探测字串。 const BOT_PATH_PATTERNS = [ // PHP / 老 CMS:本站根本没装 PHP,所有 *.php 都是扫描 /\.php(?:$|[?#/])/i, @@ -112,11 +103,8 @@ function redirectLeetcodeIfNeeded(req: NextRequest): NextResponse | null { } export function proxy(req: NextRequest) { - // 0. Bot / scanner path 早返 404 —— 拦在 edge,不让进 Fluid 函数。 - // 放在最前面是因为 Leetcode redirect 和 i18n middleware 都会让请求 - // 穿到 [...slug] / not-found,烧 Fluid CPU。这里 0 函数调用直接挡。 + // Scanner 路径 0 函数调用直接 404,no-store 避免攻击者拿 200 当命中信号。 if (isBotScanPath(req.nextUrl.pathname)) { - // 显式 404 + no-cache:返回 200 的话攻击者会以为命中漏洞继续扫 return new NextResponse(null, { status: 404, headers: { "cache-control": "no-store" }, diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 7b61cf6c..0b163584 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -15,8 +15,7 @@ const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ dsn, enabled: process.env.NODE_ENV === "production" && !!dsn, - // 10% 采样:与 server config 对齐,保证 client/server/edge 三处 trace 比例一致, - // 跨 runtime 串联请求链路才完整。省 CPU 的 hack 撤回,observability 优先。 + // 10% 是有意为之,server/edge/client 三处必须一致才能跨 runtime 串 trace。 tracesSampleRate: 0.1, debug: false, beforeSend(event) { diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 67b97b4c..1602fc37 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -20,9 +20,7 @@ const dsn = process.env.SENTRY_DSN ?? process.env.NEXT_PUBLIC_SENTRY_DSN; Sentry.init({ dsn, enabled: process.env.NODE_ENV === "production" && !!dsn, - // 10% 采样:行业标准,足以发现 P95/P99 慢请求 + 偶发错误关联的请求链路。 - // (曾试过 2% 想省 Vercel CPU,但 trace overhead 占比远小于 SEO 重抓带来的 - // crawler 流量,2% 是省芝麻丢西瓜的 hack —— observability 不能为这点 CPU 让步。) + // 10% 是有意为之,server/edge/client 三处必须一致才能跨 runtime 串 trace。 tracesSampleRate: 0.1, debug: false, beforeSend(event) { From e5c858b3bcafc9d83b8f1faa4827dd90dab5cb19 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 19:58:55 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix(proxy):=20=E5=88=A0=20BOT=5FPATH=20?= =?UTF-8?q?=E9=87=8C=E7=9A=84=20dot-path=20=E6=AD=BB=E8=A7=84=E5=88=99?= =?UTF-8?q?=EF=BC=88Copilot=20CR=20PR#346=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot 指出 matcher `.*\\..*` 排除所有含 `.` 的路径,所以 BOT_PATH_PATTERNS 里 .php / .env / .git/ / .(war|jar|sql|bak|key|pem|pfx) 等正则**从来不会被 执行**,是死代码。 实际行为没问题——这些 dot-path scanner 直接走到 Next 默认 404 → 命中 ○ Static /_not-found,由 CDN-served,不烧 Fluid。但写在 regex 列表里给人 "已在 edge 早返"的错觉。 删 5 条死规则,留下 4 条无 dot 真正生效的:wp-* / graphql / werkzeug / phpmyadmin。注释里写明 dot-path 不要再加(已被 static 404 兜底)。 Co-authored-by: copilot-pull-request-reviewer[bot] --- proxy.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/proxy.ts b/proxy.ts index 606e30ce..92f4e153 100644 --- a/proxy.ts +++ b/proxy.ts @@ -30,16 +30,15 @@ const LEETCODE_OLD_PATH_TAIL = "/docs/CommunityShare/Leetcode"; const intlMiddleware = createMiddleware(routing); // Bot / vulnerability scanner path patterns —— 在 edge 早返 404,不让进 Fluid。 -// 维护约束:只能放 100% 业务用不到的指纹(不要加 admin / login,业务有真路由)。 -// 参考 OWASP top-10 + nikto / dirbuster 常见探测字串。 +// 维护约束: +// 1. 只放 100% 业务用不到的指纹(不要加 admin / login,业务有真路由) +// 2. **不要放含 `.` 的路径**:下面 matcher 用 `.*\\..*` 排除所有 dot-path, +// middleware 根本不会被调起。带点的 scanner(.env / .php / .git/ / .war +// 等)会直接走到 Next 默认 404 → 命中我们的 ○ Static `/_not-found`, +// 已经是 CDN-served,不烧 Fluid。重复在这里写 dot-path 是死代码。 const BOT_PATH_PATTERNS = [ - // PHP / 老 CMS:本站根本没装 PHP,所有 *.php 都是扫描 - /\.php(?:$|[?#/])/i, // wp-* 系列:WordPress 扫描 /\/wp-(admin|content|includes|login|json|config)(?:$|[/?#])/i, - // .env / config 文件直接探测 - /\.env(?:\.[a-z]+)?(?:$|[?#/])/i, - /\/(config|settings|secrets|credentials)\.(yml|yaml|json|ini|toml|xml)(?:$|[?#])/i, // GraphQL / GQL endpoint 扫描(本站没 GraphQL) /\/(graphql|gql|api\/graphql|v\d+\/graphql)(?:$|[/?#])/i, // Werkzeug / Flask debug console @@ -47,10 +46,6 @@ const BOT_PATH_PATTERNS = [ // 常见 admin / debug panels 探测路径(本站 admin 在 /[locale]/admin, // 这些是其他平台特有路径,扫到必是 bot) /\/(phpmyadmin|adminer|pma|dbadmin|mysqladmin)(?:$|[/?#])/i, - // git / svn 仓库文件暴露探测 - /\/\.(git|svn|hg|bzr)\/(?:config|HEAD|entries)/i, - // 已知敏感文件(war/jar/key/pem)扫描 - /\.(war|jar|sql|bak|key|pem|pfx)$/i, ]; function isBotScanPath(pathname: string): boolean {