From 967e9ef7f732eb97c767fa59887072597f4a4148 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Sat, 8 Aug 2026 03:42:37 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B7=AF=E7=94=B1=E9=81=AE=E8=94=BD?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E8=84=9A=E6=9C=AC=EF=BC=9B=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E5=89=8D=E6=8A=8A=E5=89=8D=E5=90=8E=E7=AB=AF=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E9=80=90=E6=9D=A1=E5=AF=B9=E4=BA=86=E4=B8=80=E9=81=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 切换前核对:前端调的接口后端有没有漏 写脚本把前端 224 个调用点和后端注册的路由逐条比对。**零缺口** —— 唯一报出来的 一条是我的正则被嵌套括号截断了(`users/${encodeURIComponent(...)}/badges`), 后端那条路由是有的。「后端有、前端没调」那 25 条也逐个看过,全是脚本的假阳性 (把 `.get("user")` 这类非路由调用当成了路由)和假阴性(前端用三元表达式拼路径, 正则看不见,比如 `GET /me` 其实在 shared/api.ts 里被调)。 结论是没发现缺口,但这个脚本不够可靠、不足以证明"一定没有",所以没留进仓库。 ## 路由遮蔽检查(留成常驻脚本) 比"有没有漏"更值得防的是遮蔽:**Hono 按注册顺序匹配,不是静态优先**。 `/problems/:id` 注册在 `/problems/random` 前面的话,后者永远进不去 —— 不报错、不警告,只是静默走进前一条的 handler。阶段 4 真实发生过一次, 两个教师用的分析端点被吃掉,一直到评审才发现。 全仓 167 条路由按真实注册顺序扫:**零遮蔽**。 这个结论敢下,是因为检测器本身也验了: - 自检用例里放了阶段 4 那个历史真实案例,能抓到;边界(两边都是参数、 段数不同、不同前缀)不误报 - 核对了 24 个 router 全在扫描范围内,没有漏扫 - 反向验证:往 problem.ts 末尾加一条注册在 `:displayId` 之后的字面量路由, 脚本立刻报出来并 exit 1 未经验证的检测器报"没问题"是没有意义的 —— 这个教训今天已经吃过两次 (tree-sitter 那次、SQL 内存那次)。 脚本落在 apps/api/src/scripts/check-route-shadowing.ts, `bun run --filter '@oj2/api' check:routes`,加完路由跑一下。 CLAUDE.md 里也写了。 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 14 ++- apps/api/package.json | 1 + apps/api/src/scripts/check-route-shadowing.ts | 119 ++++++++++++++++++ 3 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/scripts/check-route-shadowing.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3d60007..1ba6a79 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,8 +35,9 @@ bun run dev # api(3000) + worker + web(5173) 一起起 常用检查: ```bash -bunx tsc --noEmit -p apps/api # 后端类型检查 -cd apps/web && bun run build # 前端构建(vite 不做类型检查,构建即验证) +bunx tsc --noEmit -p apps/api # 后端类型检查 +bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路由跑一下 +cd apps/web && bun run build # 前端构建(vite 不做类型检查,构建即验证) ``` **不要写测试** —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。 @@ -69,6 +70,15 @@ dev 直接起不来。 「把整个程序再跑一遍」→ 指数级 fork。这不是假想,开发时炸过一次开发机。 `OJ2_SQL_CHILD` 那道递归闸不要删。 +### 加路由要防遮蔽 + +**Hono 按注册顺序匹配,不是静态优先**(实测确认过,别凭直觉)。`/problems/:id` +注册在 `/problems/random` 前面的话,后者永远进不去 —— 而且不报错、不警告, +只是静默走进前一条的 handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉, +一直到评审才发现。 + +加完路由跑 `bun run --filter '@oj2/api' check:routes`。 + ### 判题状态码要三处同步 `apps/api/src/judge/status.ts`、`apps/web/src/utils/constants.ts`、 diff --git a/apps/api/package.json b/apps/api/package.json index d62807d..bb6e167 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,6 +12,7 @@ "build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api", "seed:dev": "bun src/scripts/seed-dev.ts", "typecheck": "tsc --noEmit", + "check:routes": "bun src/scripts/check-route-shadowing.ts", "db:pull": "drizzle-kit pull" }, "dependencies": { diff --git a/apps/api/src/scripts/check-route-shadowing.ts b/apps/api/src/scripts/check-route-shadowing.ts new file mode 100644 index 0000000..028dc94 --- /dev/null +++ b/apps/api/src/scripts/check-route-shadowing.ts @@ -0,0 +1,119 @@ +/** + * 检查有没有路由被先注册的同形路由「吃掉」。 + * + * bun run --filter '@oj2/api' check:routes + * + * ## 为什么需要这个 + * + * **Hono 按注册顺序匹配,不是静态优先**(已实测确认,别凭直觉假设)。所以 + * + * problemRoutes.get("/problems/:id", …) // 先注册 + * problemRoutes.get("/problems/random", …) // 永远进不去 + * + * 第二条不会报错、不会警告,只是静默走进第一条的 handler,然后因为 "random" + * 不是合法 id 而返回 404 或者一堆看不懂的结果。阶段 4 真实发生过一次: + * 两个教师用的分析端点被 `/problems/:id` 吃掉,评审时才发现。 + * + * 加路由时顺手跑一下,比事后靠人眼在 200 多条路由里看出顺序问题可靠。 + * + * 局限:靠正则读源码,只认 `xxxRoutes.get("字面量", …)` 这种写法。 + * 动态拼出来的路径看不见 —— 但本仓库没有那种写法,加的时候请保持。 + */ + +import { readFileSync, readdirSync, statSync } from "node:fs" +import { join, resolve } from "node:path" + +const SRC = resolve(import.meta.dir, "..") + +interface Route { + method: string + path: string + file: string +} + +function walk(dir: string, out: string[] = []) { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry) + if (statSync(path).isDirectory()) walk(path, out) + else if (entry.endsWith(".ts")) out.push(path) + } + return out +} + +/** 先注册的 pattern 会不会把后注册的 target 吃掉 */ +export function shadows(pattern: string, target: string) { + const a = pattern.split("/").filter(Boolean) + const b = target.split("/").filter(Boolean) + if (a.length !== b.length) return false + let usedParam = false + for (let i = 0; i < a.length; i++) { + const seg = a[i]! + const other = b[i]! + if (seg.startsWith(":")) { + // 参数段吃得掉任何字面量段;两边都是参数说明本来就是同一条,不算遮蔽 + if (other.startsWith(":")) continue + usedParam = true + continue + } + if (seg !== other) return false + } + return usedParam +} + +function collect(): Route[] { + const routerFile = new Map() + for (const file of walk(SRC)) { + for (const m of readFileSync(file, "utf8").matchAll(/export const (\w+) = new Hono/g)) { + routerFile.set(m[1]!, file) + } + } + + const routesOf = (router: string, prefix: string): Route[] => { + const file = routerFile.get(router) + if (!file) return [] + const text = readFileSync(file, "utf8") + const pattern = new RegExp(`${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"`, "g") + return [...text.matchAll(pattern)].map((m) => ({ + method: m[1]!.toUpperCase(), + path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/", + file: file.replace(SRC + "/", ""), + })) + } + + // 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平 + const index = readFileSync(join(SRC, "index.ts"), "utf8") + const adminIndex = readFileSync(join(SRC, "routes/admin/index.ts"), "utf8") + const adminMounts = [...adminIndex.matchAll(/\.route\(\s*"([^"]*)"\s*,\s*(\w+)\s*\)/g)] + + const all: Route[] = [] + for (const m of index.matchAll(/app\.route\(\s*"([^"]+)"\s*,\s*(\w+)\s*\)/g)) { + const [, prefix, router] = m + if (router === "adminRoutes") { + for (const a of adminMounts) all.push(...routesOf(a[2]!, prefix! + a[1]!)) + } else { + all.push(...routesOf(router!, prefix!)) + } + } + return all +} + +const routes = collect() +const hits: [Route, Route][] = [] +for (let i = 0; i < routes.length; i++) { + for (let j = i + 1; j < routes.length; j++) { + if (routes[i]!.method !== routes[j]!.method) continue + if (shadows(routes[i]!.path, routes[j]!.path)) hits.push([routes[i]!, routes[j]!]) + } +} + +console.log(`按注册顺序检查了 ${routes.length} 条路由`) +if (hits.length === 0) { + console.log("✓ 没有路由被遮蔽") + process.exit(0) +} +for (const [first, second] of hits) { + console.log(`\n⚠ ${second.method} ${second.path} (${second.file})`) + console.log(` 进不去:被先注册的 ${first.method} ${first.path} 吃掉(${first.file})`) + console.log(` 改法:把它挪到那条之前注册,或换一个不同形的路径`) +} +process.exit(1)