feat: 路由遮蔽检查脚本;切换前把前后端接口逐条对了一遍
## 切换前核对:前端调的接口后端有没有漏
写脚本把前端 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 <noreply@anthropic.com>
This commit is contained in:
119
apps/api/src/scripts/check-route-shadowing.ts
Normal file
119
apps/api/src/scripts/check-route-shadowing.ts
Normal file
@@ -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<string, string>()
|
||||
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)
|
||||
Reference in New Issue
Block a user