Deploy / deploy (push) Canceled after 0s
按外部分析报告(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>
131 lines
4.7 KiB
TypeScript
131 lines
4.7 KiB
TypeScript
/**
|
||
* 检查有没有路由被先注册的同形路由「吃掉」。
|
||
*
|
||
* 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)
|