fix(阶段0): 端点提取器漏抓 5 个端点,补反向对账

提取器按文件名白名单扫 <app>/urls/{oj,admin}.py,漏掉两类真实挂载:
tutorial/urls/tutorial.py(文件名不在白名单)和 utils/urls.py(没有
urls/ 目录,被 statSync 的 catch 吞掉),共 5 个端点,其中 4 个前端在用。

改为以 OnlineJudge/oj/urls.py 为唯一入口解析 26 条 include,side 与路径
前缀直接取挂载前缀,app 取 Python 模块名首段。127(oj 77 / admin 50),
DEPRECATED 17,与 cat */urls/*.py utils/urls.py | grep -c "path(" 一致。

reconcile.ts 补上反向对账:前端调用了但后端查无此端点的路径会告警并写进
产出的 markdown,让同类系统性盲区不再依赖人眼评审。修完后 orphan 为 0。

另:生成日期改用本地时区(toISOString 是 UTC,本地 UTC+8 晚间会写成前一天);
补记盲点 3(src/utils/download.ts 的独立 axios 实例,提取器抓不到)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 20:08:53 -06:00
parent ec563ef64b
commit ae5eea7250
3 changed files with 100 additions and 58 deletions

View File

@@ -1,26 +1,42 @@
#!/usr/bin/env bun
// 从 Django urls/*.py 提取后端端点全集
import { readdirSync, readFileSync, statSync } from "node:fs"
import { readFileSync } from "node:fs"
import { join } from "node:path"
const ROOT = process.argv[2] ?? "../../OnlineJudge"
type Endpoint = { app: string; side: "oj" | "admin"; pattern: string; view: string; name: string; deprecated: boolean }
function findUrlFiles(root: string): string[] {
const out: string[] = []
for (const app of readdirSync(root)) {
const dir = join(root, app, "urls")
try {
if (!statSync(dir).isDirectory()) continue
} catch {
continue
}
for (const f of readdirSync(dir)) {
if (f === "oj.py" || f === "admin.py") out.push(join(dir, f))
}
// 唯一入口是 oj/urls.py —— 它才是 Django 真实的挂载表。
//
// 不要按文件名白名单oj.py / admin.py去猜 urls 文件:那样会漏掉两类真实存在的形态,
// 1. urls/ 目录里文件名不叫 oj/admin 的,如 tutorial/urls/tutorial.py3 个端点,前端在用)
// 2. 根本没有 urls/ 目录、直接是模块文件的,如 utils/urls.py2 个端点,其一前端在用)
// 历史上这两类共 5 个端点被静默漏掉,其中 4 个前端在用。
// 现在改为解析 oj/urls.py 的 include 清单,挂载前缀直接取自这里,不再从文件名反推 side。
const INCLUDE_RE = /path\(\s*["'](api\/(?:admin\/)?)["']\s*,\s*include\(\s*["']([\w.]+)["']\s*\)/g
type Mount = { prefix: string; module: string; file: string; app: string; side: "oj" | "admin" }
function findMounts(root: string): Mount[] {
const src = readFileSync(join(root, "oj", "urls.py"), "utf8")
const mounts: Mount[] = []
INCLUDE_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = INCLUDE_RE.exec(src)) !== null) {
const prefix = m[1]
const mod = m[2]
// Python 模块名 → 文件路径。"tutorial.urls.tutorial" → tutorial/urls/tutorial.py
// "utils.urls" → utils/urls.py。app 一律取模块名首段,不依赖目录层数。
mounts.push({
prefix,
module: mod,
file: join(root, ...mod.split(".")) + ".py",
app: mod.split(".")[0],
side: prefix === "api/admin/" ? "admin" : "oj",
})
}
return out.sort()
return mounts
}
// 必须跨行匹配18 处 path( 的参数换行写,按行扫会漏
@@ -33,12 +49,9 @@ const PATH_RE = /path\(\s*r?["']([^"']*)["']\s*,\s*([A-Za-z_][\w.]*)/g
const NAME_RE = /name\s*=\s*["']([^"']+)["']/
const endpoints: Endpoint[] = []
for (const file of findUrlFiles(ROOT)) {
const parts = file.split("/")
const app = parts[parts.length - 3]
const side = parts[parts.length - 1] === "oj.py" ? "oj" : "admin"
const mounts = findMounts(ROOT)
for (const { file, app, side, prefix } of mounts) {
const src = readFileSync(file, "utf8")
const prefix = side === "oj" ? "/api/" : "/api/admin/"
// 按括号深度找出每个 path(...) 的完整片段,再连同该行剩余部分(行尾注释可能带
// DEPRECATED 标记)一起分析。单纯用正则切片会在 .as_view() 的右括号处截断。
@@ -68,7 +81,7 @@ for (const file of findUrlFiles(ROOT)) {
endpoints.push({
app,
side,
pattern: prefix + m[1],
pattern: "/" + prefix + m[1],
view: m[2],
name: chunk.match(NAME_RE)?.[1] ?? "",
deprecated: /DEPRECATED/.test(chunk),
@@ -78,6 +91,7 @@ for (const file of findUrlFiles(ROOT)) {
const oj = endpoints.filter((e) => e.side === "oj").length
const dep = endpoints.filter((e) => e.deprecated).length
console.log(`挂载点 ${mounts.length} 个(来自 oj/urls.py 的 include`)
console.log(`后端端点合计 ${endpoints.length} (oj ${oj} / admin ${endpoints.length - oj})`)
console.log(`其中已标 DEPRECATED: ${dep}`)
await Bun.write("endpoints-backend.json", JSON.stringify(endpoints, null, 2))

View File

@@ -40,10 +40,23 @@ const rows = backend.map((e) => {
const count = (v: Verdict) => rows.filter((r) => r.verdict === v).length
console.log(`KEEP ${count("KEEP")} / CUT ${count("CUT")} / REVIEW ${count("REVIEW")} 合计 ${rows.length}`)
// 反向对账:前端调用了、后端却查无此端点。
// 只查"后端端点有没有被前端调用"是单向的,提取器漏抓整个 urls 文件时这一侧毫无反应
// —— 历史上 tutorial/urls/tutorial.py 与 utils/urls.py 共 5 个端点被漏掉,靠人眼评审才发现。
// 加上这条反向差集,同类系统性盲区会由脚本自己报警。
// 残留的 orphan 不一定是 bug前端可能确实留着调用死路径的代码那本身就是 Task 4 要清的信息。
const beKeys = new Set(rows.map((r) => r.key))
const orphans = [...new Set(frontend.map((c) => key(c.path)))].filter((k) => !beKeys.has(k)).sort()
if (orphans.length) console.warn(`⚠ 前端调用无对应后端端点 ${orphans.length} 条:`, orphans)
// 生成日期用本地时区。toISOString() 是 UTC本地 UTC+8 晚间生成会写成前一天,
// 而这份产物要靠生成时间判断是否需要重跑。
const today = new Date().toLocaleDateString("sv-SE")
const md = [
"# 端点清单(机器初判)",
"",
`生成时间:${new Date().toISOString().slice(0, 10)}`,
`生成时间:${today}`,
`合计 ${rows.length} 个端点 —— KEEP ${count("KEEP")}、CUT ${count("CUT")}、REVIEW ${count("REVIEW")}`,
"",
"> REVIEW 项需人工裁决,裁决后把本行的 REVIEW 改成 KEEP 或 CUT并在末列写明理由。",
@@ -52,7 +65,13 @@ const md = [
"",
"> 已知盲点 2`ojnext` 里有 4 处用原生 `fetch(\"/api/...\")` 而非 `http.get/post(...)` 发起请求AI 流式响应场景:`src/oj/store/ai.ts`、`src/oj/problem/components/SubmissionResult.vue`、`src/oj/rank/list.vue`、`src/oj/class/pk.vue`),提取脚本只认 `get/post/put/delete(...)` 调用形式,完全抓不到 `fetch(...)`。本轮 REVIEW 里的 `/api/ai/analysis`、`/api/ai/hint`、`/api/ai/class_pk`、`/api/ai/class_single` 经人工核实均属此类,实际都在用。",
"",
"> 上述两类盲点都是“前端有调用=否”但实际有调用,人工裁决时不要仅凭本表这一列就判 CUTREVIEW 里唯一不属于此类的是 `/api/judge_server_heartbeat/`——它是判题机而非前端调用的接口,不受提取脚本盲点影响,是否保留需按后端间调用来判断。",
"> 已知盲点 3`ojnext/src/utils/download.ts` 是一个独立的 axios 实例(`baseURL: \"/api/admin\"`,与 `src/utils/http.ts` 那个共用实例无关),对外只暴露 `download(url)` 一个函数,内部走 `http.get(url)`。提取脚本既不认 `download(...)` 这种调用名,也抓不到内部那个变量 `url`,所以这条通道上的调用一律是假阴性。当前两个调用点(`src/admin/problem/components/Actions.vue:46`、`src/admin/problem/detail.vue:316`)都指向 `admin/test_case`,而该端点已因别处的字面量调用被判成 KEEP**本轮结论不受影响**。但日后新增的 `download(...)` 调用会静默变成假阴性 CUT裁决时留意。",
"",
"> 盲点 1、2、3 都是“前端有调用=否”但实际有调用,人工裁决时不要仅凭本表这一列就判 CUTREVIEW 里唯一不属于此类的是 `/api/judge_server_heartbeat/`——它是判题机而非前端调用的接口,不受提取脚本盲点影响,是否保留需按后端间调用来判断。",
"",
`> 反向对账(前端调用了、后端却查无此端点):${
orphans.length === 0 ? "**0 条**,前端全部调用路径都能在后端端点全集里找到对应。" : `**${orphans.length} 条**,见下。这类路径要么是提取器又漏了某个 urls 文件,要么是前端留着调用死路径的代码,两种都要查。\n>\n${orphans.map((o) => `> - \`${o}\``).join("\n")}`
}`,
"",
"| 裁决 | app | 侧 | 路径 | 视图 | 前端有调用 | 已标 DEPRECATED | 理由 |",
"|---|---|---|---|---|---|---|---|",