Files
OJ2/apps/api/src/scripts/check-route-shadowing.ts
T
xuyueandClaude Opus 5.5 b046af4ba0
Deploy / deploy (push) Canceled after 0s
chore(部署): push 后先过检查再部署;判题机重启那几秒交的题不再变成系统错误;worker 挂了看得出来
按外部分析报告(reports/analysis)挑出来做的几条:
- CI 在编完前端之后跑 `bunx vp run verify`,有一项不过就不部署(必须排在编前端之后:
  前端类型检查要的 auto-imports.d.ts / components.d.ts 是 vite build 时才生成的)
- 连不上判题机(连接被拒、解析不到、被重置)时,正式判题等 2/5/10 秒重试,不再当场记成
  系统错误;超时、判题机回错、页面上的运行和自测不重试
- worker 每 10 秒刷一次「还活着」,compose 加 `oj2-api healthcheck --worker`,deploy.sh 起栈后
  也等 worker 健康(docker 不会因 unhealthy 自动重启,只是看得见、部署时拦得住)
- 三套 compose 的容器日志限 10MB × 3
- `/public/%` 这类畸形编码回 404 而不是 500;找不到的接口回统一的 JSON 错误
- 删掉没人用的「停用判题机」接口,契约不再给 task_number / is_disabled(列留在库里)
- 删掉腐化的 apps/web/tests/submitButtonState.test.ts;契约包 zod 范围对齐到 ^4.6.6
- 修几处和实际对不上的注释与文档(表数、编译并发、AST 语言数、deploy.sh 流程、校验处数)

⚠️ 改了 compose 的日志配置,这次部署会把 postgres / redis / 判题机也重建一遍,各停几秒

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:59:19 +08:00

131 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 检查有没有路由被先注册的同形路由「吃掉」。
*
* bun run --filter '@oj2/api' check:routes
*
* ## 为什么需要这个
*
* **Hono 按注册顺序匹配,不是静态优先**(已实测确认,别凭直觉假设)。所以
*
* problemRoutes.get("/problems/:id", …) // 先注册
* problemRoutes.get("/problems/random", …) // 永远进不去
*
* 第二条不会报错、不会警告,只是静默走进第一条的 handler,然后因为 "random"
* 不是合法 id 而返回 404 或者一堆看不懂的结果。阶段 4 真实发生过一次:
* 两个教师用的分析端点被 `/problems/:id` 吃掉,评审时才发现。
*
* 加路由时顺手跑一下,比事后靠人眼在两百来条路由里看出顺序问题可靠。
*
* 局限:靠正则读源码,只认 `xxxRoutes.get('字面量', …)` 这种写法(单双引号都认),
* 以及 `xxxRoutes.route('字面量', 子路由)` 的嵌套挂载(按挂载位置展开)。
* 动态拼出来的路径看不见 —— 但本仓库没有那种写法,加的时候请保持。
*/
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')
// 直接注册的路由和嵌套挂载(`router.route("/", child)`)放在一起按出现位置排序:
// 子路由挂在哪个位置,它的路由就在哪个位置参与匹配
const pattern = new RegExp(
`${router}\\.(get|post|put|delete|patch)\\(\\s*['"]([^'"]+)['"]|${router}\\.route\\(\\s*['"]([^'"]*)['"]\\s*,\\s*(\\w+)\\s*\\)`,
'g',
)
return [...text.matchAll(pattern)].flatMap((m) => {
if (m[4]) return routesOf(m[4], prefix + m[3]!)
return [
{
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)