Files
OJ2/docs/plans/2026-08-06-phase0-endpoint-inventory.md
yuetsh 3da7482741 docs: 阶段 0 实施计划 + 端点提取器
计划覆盖端点盘点、人工裁决、jieba 验证、取回生产库 schema 四件事。
相对设计文档有两处偏离,已在计划内说明:阶段 0 不删旧代码只产出清单
(与"旧仓库全程冻结"一致);不写测试(遵循项目既定策略)。

盘点中发现两处事实,已修正设计文档:
- /api/sessions 前端从不调用,User.session_keys 只写不读,选 opaque
  token 而非 JWT 的原始理由不成立,理由已换成账号封禁需即时生效
- SessionRecordMiddleware 每个已登录请求都写库,新后端不要复刻

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 19:24:41 -06:00

467 lines
18 KiB
Markdown
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.
# 阶段 0存量盘点与做减法 —— 实施计划
> **给执行者:** 用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans` 逐任务实施。步骤用 `- [ ]` 复选框跟踪。
**目标:** 产出一张经人工裁决的端点清单,明确新后端要实现哪些、砍掉哪些;并取回数据库 schema dump 以解除阶段 1 的阻塞。
**架构:** 纯静态分析 + 人工裁决,不修改任何现有代码。分别从 Django `urls/*.py` 与 ojnext 的 `api.ts` 提取端点全集与调用全集,机器对账产出三态清单(保留 / 砍掉 / 待裁决),待裁决项由人决定。全部脚本落在 `OJ2/docs/spikes/`,产物落在 `OJ2/docs/specs/`
**技术栈:** Bun 1.3.11、TypeScript。无需数据库、无需 Docker、无需 Python 环境。
## 全局约束
- **不修改 `OnlineJudge/``ojnext/` 任何文件。** 两个旧仓库全程冻结,回滚路径依赖于此。阶段 0 的"砍"是决策层面的,产物是清单不是 diff。
- **不写测试。** 项目既定策略(根 `CLAUDE.md`Do not write new tests。本计划用"跑脚本核对输出数字"替代测试环节。
- **本机无 PostgreSQL / Redis / Docker。** 任何需要数据库连接的操作只能在服务器上做。
- 后端端点 ground truth**122 个oj 74 / admin 48**,其中 **16 个**已由人工标注 `# DEPRECATED: 前端未调用`。任何提取脚本的输出必须与这两个数字吻合,不吻合就是脚本有 bug。
- 前端调用路径基线:**78 条**(用字面量 `get("...")` 形式统计,未含模板字符串,实际应 ≥ 78
- 工作目录统一为 `OJ2/docs/spikes/`,脚本用绝对路径接收 `OnlineJudge` / `ojnext` 位置。
---
## 文件结构
| 文件 | 职责 |
|---|---|
| `docs/spikes/extract-endpoints.ts` | **已完成。** 从 Django `urls/*.py` 提取后端端点全集 → `endpoints-backend.json` |
| `docs/spikes/extract-frontend-calls.ts` | 从 ojnext 提取前端调用全集 → `endpoints-frontend.json` |
| `docs/spikes/reconcile.ts` | 对账两份 JSON产出三态清单 → `endpoint-inventory.md` |
| `docs/spikes/jieba-spike.ts` | 验证 `@node-rs/jieba` 在 Bun 下可用 |
| `docs/specs/endpoint-inventory.md` | **本阶段主产物**:经人工裁决的端点清单 |
| `docs/specs/schema.sql` | 从服务器取回的 schema-only dump供阶段 1 使用 |
---
## Task 1: 后端端点提取器
**状态:已完成**(写计划过程中一并做掉了,代码已在仓库)
**Files:**
- Created: `docs/spikes/extract-endpoints.ts`
**Interfaces:**
- Produces: `endpoints-backend.json`,元素形如
```ts
type Endpoint = {
app: string // "problem"
side: "oj" | "admin"
pattern: string // "/api/problem/"
view: string // "ProblemAPI"
name: string // "problem_api",无则空串
deprecated: boolean // 是否已标 # DEPRECATED
}
```
实现中踩过的三个坑,改脚本时别踩回去:
1. 18 处 `path(` 参数换行写,按行扫会漏 → 用括号深度扫描找完整片段。
2. 行尾注释在 `.as_view()` 的右括号之后,切片到第一个 `)` 会截断 → 片段要延伸到该行行尾。
3. 注释可能出现在 `path(` 后、字符串后、逗号后任意位置 → 匹配前先 `replace(/#[^\n]*/g, "")` 剥掉,判 `DEPRECATED` 时仍用原文。
- [ ] **Step 1: 确认脚本输出与 ground truth 一致**
```bash
cd /home/xuyue/Projects/OJ/OJ2/docs/spikes
bun run extract-endpoints.ts /home/xuyue/Projects/OJ/OnlineJudge
```
预期输出,两个数字必须完全一致:
```
后端端点合计 122 (oj 74 / admin 48)
其中已标 DEPRECATED: 16
→ endpoints-backend.json
```
---
## Task 2: 前端调用提取器
**Files:**
- Create: `docs/spikes/extract-frontend-calls.ts`
**Interfaces:**
- Consumes: 无
- Produces: `endpoints-frontend.json`,元素形如
```ts
type Call = {
file: string // "src/oj/api.ts"
fn: string // 所属导出函数名,取不到则空串
method: string // "get" | "post" | "put" | "delete"
path: string // "problem" 或 "problem/${id}" → 归一化为 "problem/:param"
}
```
前端有两种写法都要覆盖:字面量 `get("problem")` 和模板串 `` get(`problem/${id}`) ``。模板串里的 `${...}` 统一归一化成 `:param`,否则无法与后端的 `<int:pk>` 之类对账。
- [ ] **Step 1: 写提取脚本**
```typescript
#!/usr/bin/env bun
// 从 ojnext 提取前端实际发起的 API 调用
import { readdirSync, readFileSync, statSync } from "node:fs"
import { join, relative } from "node:path"
const ROOT = process.argv[2] ?? "/home/xuyue/Projects/OJ/ojnext"
const SRC = join(ROOT, "src")
type Call = { file: string; fn: string; method: string; path: string }
function walkFiles(dir: string, out: string[] = []): string[] {
for (const e of readdirSync(dir)) {
const p = join(dir, e)
if (statSync(p).isDirectory()) walkFiles(p, out)
else if (/\.(ts|vue)$/.test(p)) out.push(p)
}
return out
}
// 同时吃 get("x") 和 get(`x/${id}`)
const CALL_RE = /\b(get|post|put|delete)\(\s*(["'`])([^"'`]*)\2/g
// 就近向上找所属的导出函数名
const FN_RE = /export\s+(?:async\s+)?function\s+(\w+)|export\s+const\s+(\w+)\s*=/g
function normalize(p: string): string {
return p.replace(/\$\{[^}]*\}/g, ":param").replace(/^\/+/, "")
}
const calls: Call[] = []
for (const file of walkFiles(SRC)) {
const src = readFileSync(file, "utf8")
// 先建立 "偏移量 -> 函数名" 的索引
const fns: { at: number; name: string }[] = []
FN_RE.lastIndex = 0
let f: RegExpExecArray | null
while ((f = FN_RE.exec(src)) !== null) fns.push({ at: f.index, name: f[1] ?? f[2] })
CALL_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = CALL_RE.exec(src)) !== null) {
const path = normalize(m[3])
if (!path || path.startsWith("http")) continue // 跳过外部 URL 和空串
const owner = fns.filter((x) => x.at < m!.index).pop()
calls.push({
file: relative(ROOT, file),
fn: owner?.name ?? "",
method: m[1],
path,
})
}
}
const uniq = new Set(calls.map((c) => `${c.method} ${c.path}`))
console.log(`前端调用点 ${calls.length} 处,去重后 ${uniq.size} 条`)
await Bun.write("endpoints-frontend.json", JSON.stringify(calls, null, 2))
console.log("→ endpoints-frontend.json")
```
- [ ] **Step 2: 运行并核对基线**
```bash
cd /home/xuyue/Projects/OJ/OJ2/docs/spikes
bun run extract-frontend-calls.ts /home/xuyue/Projects/OJ/ojnext
```
预期:去重后条数 **≥ 78**78 是只数字面量的基线,加上模板串应当更多)。若明显低于 78说明正则漏了写法检查 `src/utils/http.ts` 里 http 客户端的实际调用形式再修。
- [ ] **Step 3: 抽查 3 条结果**
```bash
bun -e 'const c=await Bun.file("endpoints-frontend.json").json(); console.log(c.filter(x=>x.path.includes(":param")).slice(0,3))'
```
确认模板串确实被归一化成了 `:param`,且 `fn` 字段能对上 `src/oj/api.ts` 里的实际函数名。
- [ ] **Step 4: 提交**
```bash
cd /home/xuyue/Projects/OJ/OJ2
git add docs/spikes/extract-frontend-calls.ts
git commit -m "chore(阶段0): 前端 API 调用提取器"
```
---
## Task 3: 对账并产出三态清单
**Files:**
- Create: `docs/spikes/reconcile.ts`
- Create: `docs/specs/endpoint-inventory.md`(脚本生成)
**Interfaces:**
- Consumes: `endpoints-backend.json`Task 1 的 `Endpoint[]`)、`endpoints-frontend.json`Task 2 的 `Call[]`
- Produces: `docs/specs/endpoint-inventory.md`,每个端点落入三态之一:
- `KEEP` —— 前端有调用,新后端必须实现
- `CUT` —— 已标 DEPRECATED 且前端确无调用,不实现
- `REVIEW` —— 机器判不准需人工裁决Task 4 处理)
后端 `pattern` 与前端 `path` 无法直接字符串相等:后端是 `/api/problem/`,前端是 `problem`;后端有 `<int:pk>` 之类占位符,前端归一化成了 `:param`。所以要各自降到一个可比的 key。
- [ ] **Step 1: 写对账脚本**
```typescript
#!/usr/bin/env bun
// 对账后端端点全集与前端调用全集,产出三态清单
type Endpoint = { app: string; side: "oj" | "admin"; pattern: string; view: string; name: string; deprecated: boolean }
type Call = { file: string; fn: string; method: string; path: string }
const backend: Endpoint[] = await Bun.file("endpoints-backend.json").json()
const frontend: Call[] = await Bun.file("endpoints-frontend.json").json()
// 归一化到可比 key去掉 /api 前缀、去掉首尾斜杠、占位符统一成 :param、转小写
function key(s: string): string {
return s
.replace(/^\/?api\/(admin\/)?/, "")
.replace(/<[^>]*>/g, ":param")
.replace(/:param/g, ":param")
.replace(/^\/+|\/+$/g, "")
.toLowerCase()
}
const feKeys = new Set(frontend.map((c) => key(c.path)))
type Verdict = "KEEP" | "CUT" | "REVIEW"
const rows = backend.map((e) => {
const k = key(e.pattern)
const called = feKeys.has(k)
let verdict: Verdict
if (called && !e.deprecated) verdict = "KEEP"
else if (!called && e.deprecated) verdict = "CUT"
else verdict = "REVIEW" // 标了 DEPRECATED 却有人调,或没标却没人调 —— 两种都要人看
return { ...e, key: k, called, verdict }
})
const count = (v: Verdict) => rows.filter((r) => r.verdict === v).length
console.log(`KEEP ${count("KEEP")} / CUT ${count("CUT")} / REVIEW ${count("REVIEW")} 合计 ${rows.length}`)
const md = [
"# 端点清单(机器初判)",
"",
`生成时间:${new Date().toISOString().slice(0, 10)}`,
`合计 ${rows.length} 个端点 —— KEEP ${count("KEEP")}、CUT ${count("CUT")}、REVIEW ${count("REVIEW")}`,
"",
"> REVIEW 项需人工裁决,裁决后把本行的 REVIEW 改成 KEEP 或 CUT并在末列写明理由。",
"",
"| 裁决 | app | 侧 | 路径 | 视图 | 前端有调用 | 已标 DEPRECATED | 理由 |",
"|---|---|---|---|---|---|---|---|",
...rows
.sort((a, b) => a.verdict.localeCompare(b.verdict) || a.app.localeCompare(b.app))
.map((r) => `| ${r.verdict} | ${r.app} | ${r.side} | \`${r.pattern}\` | ${r.view} | ${r.called ? "是" : "否"} | ${r.deprecated ? "是" : "否"} | |`),
"",
].join("\n")
await Bun.write("../specs/endpoint-inventory.md", md)
console.log("→ docs/specs/endpoint-inventory.md")
```
- [ ] **Step 2: 运行**
```bash
cd /home/xuyue/Projects/OJ/OJ2/docs/spikes
bun run reconcile.ts
```
预期:三态之和等于 122。REVIEW 数量若超过 40说明 `key()` 归一化不够,多半是后端 `pattern` 里还有没处理的占位符写法 —— 先抽查几个 REVIEW 行确认是真需人工判还是归一化没做对。
- [ ] **Step 3: 抽查归一化质量**
```bash
bun -e 'const r=await Bun.file("endpoints-backend.json").json(); console.log([...new Set(r.map(e=>e.pattern))].filter(p=>/[<>{}]/.test(p)).slice(0,10))'
```
把后端所有含占位符的 pattern 列出来,确认 `key()` 里的 `<[^>]*>` 覆盖了全部写法。如有 `{id}` 之类别的形式,补进正则重跑 Step 2。
- [ ] **Step 4: 提交**
```bash
cd /home/xuyue/Projects/OJ/OJ2
git add docs/spikes/reconcile.ts docs/specs/endpoint-inventory.md
git commit -m "chore(阶段0): 端点对账脚本与机器初判清单"
```
---
## Task 4: 人工裁决 REVIEW 项
**Files:**
- Modify: `docs/specs/endpoint-inventory.md`
**Interfaces:**
- Consumes: Task 3 产出的清单
- Produces: 同一文件,`REVIEW` 归零,每个端点确定为 `KEEP` 或 `CUT`,末列写明理由
**这一步必须由项目所有者本人做,不能由 agent 代劳。** 机器只知道"前端有没有调用",不知道"这个功能是不是我打算下学期启用的"。
裁决时的判断顺序:
1. **标了 DEPRECATED 但前端有调用** —— 优先查是不是提取脚本漏了写法,不是脚本问题再判。这类是高风险误砍。
2. **没标 DEPRECATED 且前端无调用** —— 大概率是死代码,但要排除三种例外:被 `admin/` 后台页面以外的方式调用(如直接开浏览器访问)、被外部脚本/定时任务调用、`open_api_appkey` 那种给第三方用的接口。
3. **拿不准的一律判 KEEP。** 阶段 0 的目的是省掉确定不用的工作量,不是极限压缩。误砍一个要在阶段 3 才发现,成本远高于多写一个 CRUD 端点。
- [ ] **Step 1: 逐行裁决**
打开 `docs/specs/endpoint-inventory.md`,把每个 `REVIEW` 改成 `KEEP` 或 `CUT`,末列填理由(一句话即可)。
- [ ] **Step 2: 确认无残留**
```bash
grep -c "| REVIEW |" /home/xuyue/Projects/OJ/OJ2/docs/specs/endpoint-inventory.md
```
预期输出:`0`
- [ ] **Step 3: 记录最终结论**
在文件开头的统计行下面补一句实际结果,例如:
```markdown
**裁决结果:新后端需实现 NN 个端点,砍掉 MM 个(占 XX%)。**
```
- [ ] **Step 4: 提交**
```bash
cd /home/xuyue/Projects/OJ/OJ2
git add docs/specs/endpoint-inventory.md
git commit -m "docs(阶段0): 端点清单人工裁决完成"
```
---
## Task 5: 验证 @node-rs/jieba
**Files:**
- Create: `docs/spikes/jieba-spike.ts`
**Interfaces:**
- Consumes: 无
- Produces: 结论写入设计文档 7.3 节
设计文档已把这条列为"不构成方案级风险"—— 它只影响 `flowchart/views/admin.py` 一个文件,不通也有纯 JS 兜底。所以验不过不要停下来修,记录结论继续走。
参照物是现有用法(`flowchart/views/admin.py:65,191``jieba.add_word(w, freq=9999)` 加自定义词,然后 `jieba.cut(text)` 切词。新库必须支持这两件事。
- [ ] **Step 1: 装依赖**
```bash
cd /home/xuyue/Projects/OJ/OJ2/docs/spikes
bun add @node-rs/jieba
```
- [ ] **Step 2: 写验证脚本**
```typescript
#!/usr/bin/env bun
// 验证 @node-rs/jieba 能否替代 Python jieba
// 对照 flowchart/views/admin.py:65,191 的用法add_word + cut
import { Jieba } from "@node-rs/jieba"
import { dict } from "@node-rs/jieba/dict"
const jieba = Jieba.withDict(dict)
const text = "输入两个整数并输出它们的和"
console.log("默认切词:", jieba.cut(text).join(" / "))
// 对应 jieba.add_word(_w, freq=9999)
jieba.insertWord("两个整数")
console.log("加词后 :", jieba.cut(text).join(" / "))
const t0 = performance.now()
for (let i = 0; i < 1000; i++) jieba.cut(text)
console.log("1000 次切词耗时:", (performance.now() - t0).toFixed(0), "ms")
```
- [ ] **Step 3: 运行**
```bash
cd /home/xuyue/Projects/OJ/OJ2/docs/spikes
bun run jieba-spike.ts
```
预期:两行切词结果都能正常输出,且"加词后"的结果里 `两个整数` 不再被拆开。
若 import 失败NAPI 二进制在 Bun 下加载不了),记录错误信息,改用纯 JS 的 `segmentit` 或 `nodejieba` 再试一次;两个都不行就在设计文档里记"降级为不分词的 LIKE 匹配",继续下一个任务。
- [ ] **Step 4: 把结论写回设计文档**
编辑 `docs/specs/2026-08-06-bun-backend-rewrite-design.md` 第 7.3 节,把"待验证"改成实测结论(通过 / 不通过 + 采用的方案)。
- [ ] **Step 5: 提交**
```bash
cd /home/xuyue/Projects/OJ/OJ2
git add docs/spikes/jieba-spike.ts docs/specs/2026-08-06-bun-backend-rewrite-design.md
git commit -m "chore(阶段0): 验证 jieba 替代方案"
```
---
## Task 6: 取回数据库 schema
**Files:**
- Create: `docs/specs/schema.sql`
**Interfaces:**
- Consumes: 无
- Produces: `docs/specs/schema.sql` —— 阶段 1 的 `drizzle-kit pull` 依赖它
**这是阶段 1 的解阻塞前提。** 本机没有 PostgreSQL`drizzle-kit pull` 连不上库,所以必须先从服务器把 schema 拿下来。只取结构不取数据,文件里不含任何学生信息,可以安全入库。
- [ ] **Step 1: 在服务器上导出**
登录跑着生产库的服务器,执行(容器名以实际 `docker-compose.yml` 为准):
```bash
docker exec oj-postgres pg_dump -U onlinejudge -d onlinejudge \
--schema-only --no-owner --no-privileges > schema.sql
```
- [ ] **Step 2: 传回本机**
```bash
scp <服务器>:~/schema.sql /home/xuyue/Projects/OJ/OJ2/docs/specs/schema.sql
```
- [ ] **Step 3: 确认内容干净且完整**
```bash
cd /home/xuyue/Projects/OJ/OJ2/docs/specs
echo "表数量: $(grep -c '^CREATE TABLE' schema.sql)"
echo "含 COPY/INSERT应为 0: $(grep -cE '^(COPY|INSERT)' schema.sql)"
grep -oE '^CREATE TABLE [a-z_."]+' schema.sql | sed 's/CREATE TABLE //' | sort
```
预期:
- 表数量约 30+26 张业务表 + Django 框架表)
- `COPY`/`INSERT` 计数为 **0** —— 非 0 说明误导出了数据,删掉重来
- 表名列表里应能看到 `django_migrations`、`django_content_type`、`django_session`、`auth_permission` 等框架表,它们在阶段 1 会被剪掉
- [ ] **Step 4: 提交**
```bash
cd /home/xuyue/Projects/OJ/OJ2
git add docs/specs/schema.sql
git commit -m "chore(阶段0): 取回生产库 schema解阻塞阶段 1"
```
---
## 阶段 0 完成标准
四项全部满足才算完成,缺一项都不要进阶段 1
- [ ] `docs/specs/endpoint-inventory.md` 中 `REVIEW` 计数为 0每个端点都有 KEEP/CUT 裁决
- [ ] 清单顶部记录了最终数字:新后端需实现 N 个端点
- [ ] jieba 替代方案有明确结论,已写回设计文档 7.3 节
- [ ] `docs/specs/schema.sql` 已入库,`CREATE TABLE` 数量正常且不含数据
---
## 自查记录
**规格覆盖:** 设计文档第 11 节阶段 0 列的三项 —— 端点筛查Task 1-4、删除死代码改为清单决策见全局约束、验证 jiebaTask 5—— 均已覆盖。额外补了 Task 6因为设计文档第 8 节要求 `drizzle-kit pull`,而本机无数据库,不先取 schema 阶段 1 无法开工。
**与设计文档的两处偏离,均已在上文说明理由:**
1. 阶段 0 不删旧代码,只产出清单 —— 与文档第 5 节"旧仓库全程冻结"保持一致。
2. 不写测试 —— 遵循项目既定策略,用核对输出数字替代。
**类型一致性:** `Endpoint` 类型在 Task 1、Task 3 中定义一致(含 `deprecated: boolean``Call` 类型在 Task 2、Task 3 中定义一致。Task 3 的 `key()` 同时作用于后端 `pattern` 与前端 `path`,归一化规则单点定义。