From a8408c0bb555369eca56a85ad7fb62f707f2b2e6 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 16 Sep 2026 08:03:26 -0600 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=96=87=E6=A1=A3=E6=95=B4=E7=90=86?= =?UTF-8?q?=EF=BC=8CCLAUDE.md=20=E7=98=A6=E8=BA=AB=E4=B8=80=E5=8D=8A?= =?UTF-8?q?=EF=BC=8C=E5=88=A0=E6=8E=89=E9=87=8D=E5=86=99=E6=9C=9F=E5=B7=B2?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=9A=84=2022=20=E4=BB=BD=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md 从 490 行降到 228 行:只留日常要当场记住的约束,展开拆成五份专题 文档 —— docs/deploy.md(部署与备份恢复)、database.md(迁移执行器、基线、 drizzle-kit 的坑)、timezone.md(时区口径与那次成就订正)、contract.md (出参不 parse 的四次故障)、ast-rules.md(AST 规则与 C++ 的调用形态)。 删掉的是阶段 0–5 那批一次性产物:4 份实施计划、10 份评审/核验/修复报告、 endpoint-inventory.md(110 端点是 2026-08 的快照,现在 363 条路由)、 docs/spikes/ 的 spike 与提取脚本(结论早已落进代码)。phase5 切换手册删之前 先把仍然有效的部分提炼进 docs/deploy.md:拓扑、deploy.sh、部署后验证清单、 NPM 那两个不能关的开关、pg_dumpall 恢复的两个坑、镜像体积;演练报告与回滚 两节随旧栈下线一并作废。 两份设计文档保留,补上状态行说明它们是「当初为什么这么定」而不是现状。 apps/web/CLAUDE.md 顺手订正过期内容:PUBLIC_OJ_URL / PUBLIC_WS_URL 两个变量 早已不存在(baseURL 写死 /api,dev 走 vite proxy、线上由 Caddy 同源伺服), store 与 composable 清单补齐到与目录一致。 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 479 +--- apps/web/CLAUDE.md | 22 +- docker/deploy.sh | 2 +- docs/ast-rules.md | 51 + docs/contract.md | 47 + docs/database.md | 135 ++ docs/deploy.md | 133 ++ .../2026-08-06-phase0-endpoint-inventory.md | 483 ---- docs/plans/2026-08-06-phase1-skeleton.md | 675 ------ .../plans/2026-08-06-phase2-judge-vertical.md | 45 - docs/plans/2026-08-28-collab-help-request.md | 2009 ----------------- .../2026-08-06-bun-backend-rewrite-design.md | 22 +- .../2026-08-28-collab-help-request-design.md | 3 +- docs/specs/endpoint-inventory.md | 190 -- docs/specs/phase012-verification.md | 87 - docs/specs/phase3-coverage.md | 226 -- docs/specs/phase3-fix-list.md | 199 -- docs/specs/phase3-fix-report.md | 433 ---- docs/specs/phase3-review-authz.md | 338 --- docs/specs/phase3-review-leakage.md | 583 ----- docs/specs/phase3-review-rereview.md | 153 -- docs/specs/phase4-review-authz.md | 496 ---- docs/specs/phase4-review-sql-sandbox.md | 150 -- docs/specs/phase5-cutover-runbook.md | 597 ----- docs/spikes/ast-spike.ts | 48 - docs/spikes/build-baseline-migration.ts | 105 - docs/spikes/bun.lock | 67 - docs/spikes/extract-endpoints.ts | 98 - docs/spikes/extract-frontend-calls.ts | 58 - docs/spikes/jieba-spike.ts | 20 - docs/spikes/package.json | 8 - docs/spikes/pbkdf2-spike.ts | 26 - docs/spikes/reconcile.ts | 85 - docs/timezone.md | 90 + 34 files changed, 593 insertions(+), 7570 deletions(-) create mode 100644 docs/ast-rules.md create mode 100644 docs/contract.md create mode 100644 docs/database.md create mode 100644 docs/deploy.md delete mode 100644 docs/plans/2026-08-06-phase0-endpoint-inventory.md delete mode 100644 docs/plans/2026-08-06-phase1-skeleton.md delete mode 100644 docs/plans/2026-08-06-phase2-judge-vertical.md delete mode 100644 docs/plans/2026-08-28-collab-help-request.md delete mode 100644 docs/specs/endpoint-inventory.md delete mode 100644 docs/specs/phase012-verification.md delete mode 100644 docs/specs/phase3-coverage.md delete mode 100644 docs/specs/phase3-fix-list.md delete mode 100644 docs/specs/phase3-fix-report.md delete mode 100644 docs/specs/phase3-review-authz.md delete mode 100644 docs/specs/phase3-review-leakage.md delete mode 100644 docs/specs/phase3-review-rereview.md delete mode 100644 docs/specs/phase4-review-authz.md delete mode 100644 docs/specs/phase4-review-sql-sandbox.md delete mode 100644 docs/specs/phase5-cutover-runbook.md delete mode 100644 docs/spikes/ast-spike.ts delete mode 100644 docs/spikes/build-baseline-migration.ts delete mode 100644 docs/spikes/bun.lock delete mode 100644 docs/spikes/extract-endpoints.ts delete mode 100644 docs/spikes/extract-frontend-calls.ts delete mode 100644 docs/spikes/jieba-spike.ts delete mode 100644 docs/spikes/package.json delete mode 100644 docs/spikes/pbkdf2-spike.ts delete mode 100644 docs/spikes/reconcile.ts create mode 100644 docs/timezone.md diff --git a/CLAUDE.md b/CLAUDE.md index 126044c..2ccd148 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,25 +1,27 @@ # CLAUDE.md OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScript,前后端同仓。 -上一代在 `../OnlineJudge/`(Django)和 `../ojnext/`(Vue SPA),**仍然完全冻结、 -一行都不改**。 +上一代在 `../OnlineJudge/`(Django)和 `../ojnext/`(Vue SPA)。 -> **旧栈已不可逆地下线。** `0002_drop_django_leftovers` 删掉了 Django 的框架表 -> (`django_session` 等),且已在生产库执行完毕。所以「停新栈起旧栈」「把 NPM 上游 -> 改回 8080」都已失效,**唯一退路是从数据库备份恢复** —— 切换手册里的「回滚保证」 -> 那节只剩历史价值。 +> **旧栈已不可逆地下线**(`0002_drop_django_leftovers` 删掉了 Django 的框架表并已在生产库 +> 执行完毕,漏网的一张空 `django_migrations` 由 `0014` 补删)。所以「停新栈起旧栈」已经 +> 不是退路,**唯一退路是从数据库备份恢复**。 > -> 生产库上 0002 有一张没删干净(0 行的 `django_migrations`,来源已无法复原), -> 由 `0014_drop_django_migrations` 补删,前因后果写在那个迁移文件的注释里。 -> -> **旧仓库仍然零改动**,没有例外——包括修 bug、包括不影响外部接口的内部小修。 -> 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、 -> 是否重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策 -> 不动旧仓库",不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」, -> 不再是回滚保证。 +> **旧仓库仍然零改动**,没有例外 —— 包括修 bug、包括不影响外部接口的内部小修。 +> 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、是否 +> 重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户「未处理,按当前政策不动旧仓库」, +> 不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」,不再是回滚保证。 -设计文档:`docs/specs/2026-08-06-bun-backend-rewrite-design.md` -切换手册:`docs/specs/phase5-cutover-runbook.md` ← 上线当天照这份走 +细节文档(`CLAUDE.md` 只留日常要记住的,展开都在这几份里): + +| 文档 | 什么时候读 | +|---|---| +| `docs/deploy.md` | 部署、上线、备份恢复 | +| `docs/database.md` | 写迁移、给新库打基线、drizzle-kit 抽风 | +| `docs/timezone.md` | 动日历口径、动时间出参格式 | +| `docs/contract.md` | 动 zod 契约、想给某个字段加校验 | +| `docs/ast-rules.md` | 动 AST 代码规则、升级 tree-sitter | +| `docs/specs/` | 两份设计文档:后端重写、课堂求助与协作编辑 | ## 仓库结构 @@ -28,18 +30,18 @@ OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScrip | `apps/api/` | 后端。Hono + Drizzle + BullMQ,编译成单二进制 | | `apps/web/` | 前端。从 ojnext 原样搬来的 Vue 3 SPA | | `packages/contract/` | 前后端共用的 Zod 契约 | -| `docker/` | Dockerfile + 三套 compose(dev / debian / school) | -| `docs/specs/` | 设计、端点清单、各阶段评审报告与演练报告 | +| `docker/` | Dockerfile + 三套 compose(dev / debian / school)+ 部署与运维脚本 | +| `docs/` | 上面那几份专题文档 + `specs/` 里的设计文档 | ## 本机环境 **Docker 可用,全套依赖都能在本机跑起来**(PostgreSQL、Redis、判题沙箱), -镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用"本机跑不起来后端" -的旧假设。 +镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用「本机跑不起来后端」的旧假设。 ```bash bun install bun run db:up # 起 postgres(5433) / redis(6380) / 判题沙箱(8081) +bun run db:migrate # 空库会从 0000 自举出全部结构 bun run dev # api(3000) + worker + web(5173) 一起起 ``` @@ -51,15 +53,15 @@ bun run dev # api(3000) + worker + web(5173) 一起起 ```bash bun run --filter '@oj2/api' typecheck # 后端类型检查 bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路由跑一下 -bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级 tree-sitter 后跑 +bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级 tree-sitter 后跑 cd apps/web && bun run type-check # 前端类型检查 cd apps/web && bun run build # 前端构建 ``` -⚠️ **前端类型检查只能走 `bun run type-check` 这个脚本。** 两条看起来等价的路子 -都会**静默通过**:`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个 -tsconfig 是 `files: []` + references 的壳,真正的配置在 `tsconfig.app.json`), -而 `vite build` 根本不做类型检查。改完 .vue / .ts 别拿构建当验证。 +⚠️ **前端类型检查只能走 `bun run type-check` 这个脚本。** 两条看起来等价的路子都会**静默 +通过**:`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个 tsconfig 是 `files: []` + +references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build` 根本不做类型检查。 +改完 .vue / .ts 别拿构建当验证。 **不要写测试** —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。 本机 Docker 全套都能起,实跑的成本比想象中低。 @@ -68,17 +70,16 @@ tsconfig 是 `files: []` + references 的壳,真正的配置在 `tsconfig.app. ### 单二进制是有代价的 -`apps/api` 编译成 `bun build --compile` 的单二进制,所以**运行时不能依赖 -node_modules**。任何 `require.resolve` / `Bun.resolveSync` / `__dirname` 去找文件的 -写法,本地都正常、编译后都会炸,而且**只在离开仓库目录后才炸**(在仓库里跑时它顺着 -cwd 摸到了 node_modules,假装没事)。 +`apps/api` 编译成 `bun build --compile` 的单二进制,所以**运行时不能依赖 node_modules**。 +任何 `require.resolve` / `Bun.resolveSync` / `__dirname` 去找文件的写法,本地都正常、编译后 +都会炸,而且**只在离开仓库目录后才炸**(在仓库里跑时它顺着 cwd 摸到了 node_modules, +假装没事)。 -资源要用 `with { type: "file" }` 内嵌。`.node` 原生模块还要额外注意:这个写法 -只有打包器认、`bun run` 不认,所以必须按形态分叉 —— 见 `apps/api/src/vendor/jieba.ts` -的注释,那里把坑写全了。 +资源要用 `with { type: "file" }` 内嵌。`.node` 原生模块还要额外注意:这个写法只有打包器认、 +`bun run` 不认,所以必须按形态分叉 —— 见 `apps/api/src/vendor/jieba.ts` 的注释, +那里把坑写全了。 -**改完这类代码,dev 和编译两种形态都要跑一遍。** 我吃过亏:只验了编译产物, -dev 直接起不来。 +**改完这类代码,dev 和编译两种形态都要跑一遍。** 我吃过亏:只验了编译产物,dev 直接起不来。 ### 路径解析看 `runtime.ts` @@ -87,17 +88,15 @@ dev 直接起不来。 ### SQL 判题会 spawn「自己」 -`judge/sql/index.ts` 起的子进程是二进制自身 + `sql-child` 子命令(因为编译后磁盘上 -没有 child.ts 可以 spawn)。所以**入口必须有 argv 分发**,否则「起自己」变成 -「把整个程序再跑一遍」→ 指数级 fork。这不是假想,开发时炸过一次开发机。 -`OJ2_SQL_CHILD` 那道递归闸不要删。 +`judge/sql/index.ts` 起的子进程是二进制自身 + `sql-child` 子命令(因为编译后磁盘上没有 +child.ts 可以 spawn)。所以**入口必须有 argv 分发**,否则「起自己」变成「把整个程序再跑 +一遍」→ 指数级 fork。这不是假想,开发时炸过一次开发机。`OJ2_SQL_CHILD` 那道递归闸不要删。 ### 加路由要防遮蔽 -**Hono 按注册顺序匹配,不是静态优先**(实测确认过,别凭直觉)。`/problems/:id` -注册在 `/problems/random` 前面的话,后者永远进不去 —— 而且不报错、不警告, -只是静默走进前一条的 handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉, -一直到评审才发现。 +**Hono 按注册顺序匹配,不是静态优先**(实测确认过,别凭直觉)。`/problems/:id` 注册在 +`/problems/random` 前面的话,后者永远进不去 —— 而且不报错、不警告,只是静默走进前一条的 +handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉,一直到评审才发现。 加完路由跑 `bun run --filter '@oj2/api' check:routes`。 @@ -109,381 +108,121 @@ dev 直接起不来。 ### 出参不 `parse`,用 `satisfies` -**后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。** -出参是后端自己刚拼出来的字面量,TS 已经在编译期校验过;再 `parse` 一遍拿不到任何新 -信息,唯一可能失败的输入是**库里的历史数据**,而失败的代价是 500。这一层原来有 136 处, -已经全部撤掉,撤的时候当场炸出两个一直存在的线上 500: +**后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。** 出参是后端自己刚 +拼出来的字面量,TS 已经在编译期校验过;再 parse 一遍拿不到任何新信息,唯一可能失败的输入是 +**库里的历史数据**,而失败的代价是 500 —— 这条规矩是被四次这样的线上故障换来的。 -- `adminProblemSchema.lastUpdateTime` 写的是 `z.string()`,但 `problem.last_update_time` - 是全库唯一可空的列(961 道题里 470 道是 NULL)——**后台打开任何一道没编辑过的老题都是 500**; -- `embeddedSubmissionSchema` 从 `submissionDetailSchema` 继承了 `problemDisplayId` 却没 - omit,而路由只填了同义的 `problem`——**凡是收到过站内信的人,消息页都打不开**(列表为空 - 时才碰巧不炸,所以一直没人报)。 +**闸设在写入侧**:入参 `safeParse`(58 处)、`db/schema.ts` 的 `.$type<>()` 列收窄、 +语义校验函数(`astRulesError()` / `exerciseDataError`)。JSONB 原文 +(`submission.info` / `statistic_info` / `exercise.data`)一律放行,它们的形状真相在判题机 +那边。query 的筛选值走 `routes/helpers.ts` 的 `asFilterValue()`,那是纯类型交接、不加校验。 -两个都是「读出侧校验」自己造出来的故障,不是它拦住的故障。历史上还有两次同类: -`exerciseSchema` 按题型收紧后一行脏数据让整条练习列表 500;`info` 写成 -`union([完整形状, z.object({})])` 后对不上的一律落进空对象那支且 parse **成功**, -管理员详情页的测试点表格静默消失(全量核出 9163/124192 条中招,RE 8480/8480 全中—— -沙箱在非正常退出的测试点上写 `output_md5: null`,而契约写的是 `z.string()`)。 - -**闸设在写入侧,一共三处形态:** - -1. **入参 `safeParse`**(58 处,全部保留)—— 请求体进来的那一刻校验,对不上回 400。 -2. **`db/schema.ts` 的 `.$type<>()`** —— 枚举型的列(`submission.result` / `.language`、 - `problem.difficulty` / `.languages`、`achievement.rarity`、`exercise.type`…)和几个 - 形状确定的 JSONB(`problem.template` / `.astRules` / `.sqlConfig` / `.sqlDisplay`、 - `acm_contest_rank.submission_info`)直接在列上收窄,只影响 TS、不产生任何 SQL。 - 这些断言**逐列拿根目录那份生产备份核过**(12.4 万条提交的 `result` 全在 `-2..6,10`、 - 961 道题的 `languages` 全是合法数组、10050 条榜单条目形状全对)。 - 加这类断言前先照样核一遍,别凭直觉。 -3. **语义校验函数** —— `astRulesError()`、`services/exercise.ts` 的 `exerciseDataError`。 - -**JSONB 原文(`submission.info` / `statistic_info` / `exercise.data`)仍然一律放行**, -读出侧不收窄:它们的形状真相在判题机那边。 - -query 里的筛选值要和收窄过的列比较时走 `routes/helpers.ts` 的 `asFilterValue()` —— -那是纯类型交接,**不加校验**:在那儿拦一道会把「筛出空列表」变成「筛条件被忽略、 -返回全部」。前端那侧(`utils/contract.ts` 为什么只挂三处)见 `apps/web/CLAUDE.md`。 - -唯一还留着 `parse` 的地方是 `judge/events.ts` 的 `parseSubmissionEvent` —— -那是从 Redis 收回来的报文,真边界,且失败返回 `null` 而不是 500。 +四次故障的细节、`.$type<>()` 断言该怎么核,见 `docs/contract.md`; +前端为什么只在三处挂运行时闸门,见 `apps/web/CLAUDE.md`。 ### AST 代码规则:一张表,外加一个机器检查 -契约的 `AST_NODE_TARGETS_BY_LANGUAGE` 是**唯一**一张表,一个 target 一条 -`{ label, node }`:`label` 给后台下拉和题目页,`node` 给判题机比 tree-sitter 节点类型。 -运算符表 `AST_OPERATOR_TARGETS_BY_LANGUAGE` 一份两用(它的值既是文案又是要比的 token)。 -判题机侧没有第二张表,解析统一走契约的 `astTargetNodeType()`。 - -> 这里原来是两张表:契约那张 target → 中文名,`judge/ast.ts` 的 `mappings` 是 -> target → 节点类型,靠一句「两边必须同增同减」的注释维持。**加 target 而漏配节点类型 -> 现在在结构上不可能了**,那条注释也就不必再守。 - -但**配错**仍然可能,而且完全静默:节点类型对不上就是一个都收不到,于是「必须使用 X」 -永远失败、「不能使用 X」永远通过,两头不报错,只有学生受着。所以有: +契约的 `AST_NODE_TARGETS_BY_LANGUAGE` 是**唯一**一张表(`label` 给界面、`node` 给判题机), +判题机侧没有第二张表,所以加 target 漏配节点类型在结构上不可能。但**配错**仍然可能, +而且完全静默 —— 节点类型对不上就是「必须使用 X」永远失败、「不能使用 X」永远通过。 ```bash -bun run --filter '@oj2/api' check:ast # 每个 target 的 node 在语法里是否真实存在 +bun run --filter '@oj2/api' check:ast # 升级 tree-sitter-* 之后一定要跑 ``` -**升级 `tree-sitter-*` 依赖之后一定要跑一次** —— 语法改节点名是常事,后果全静默。 -加这个检查那天,56 个 target 里就抓出一个:`f_string` 一直配的是 `format_string`, -而这个版本的 tree-sitter-python 根本没有这种节点(f-string 是 `string` 里带 -`interpolation`),所以「不能使用 f-string」从上线起就没生效过。 - -它只验节点类型**存在**,不验语义对不对(把 `while_loop` 配成 `for_statement` -这种两个都存在,机器看不出来),语义那层还是得实跑。 - -判题机只认 `AST_SUPPORTED_LANGUAGES` 里的语言(C / C++ / Python3)。别的语言配了规则 -一条都不会跑,所以后台不给它们开 tab,题目页也不把它们的规则展示成「要求」—— -**看得见却不检查**比没有更糟。 - -C++ 的语法表是「C 的全集 + C++ 独有的几条」,因为 tree-sitter-cpp 继承 tree-sitter-c, -C 那 14 个 target 在 C++ 树里逐个实测通用。但**调用形态两者不同**,加语言时必须一起看: -`a.push_back()` 和 `p->push_back()` 在 C++ 都是 `call_expression` + `field_expression`, -不是 Python 的 `attribute`;`std::sort(...)` 的 function 是 `qualified_identifier` -而不是 `identifier`,所以 `functionCalls` 对 C++ 额外比一次 `::` 末段——否则学生写了 -`using namespace std` 与否会得到不同的判定结果。 - -规则的语义校验在 `astRulesError()`,不在 zod 的 refine 上:`astRulesSchema` 同时用于 -**读**后台题目详情,在读路径上抛错会让历史脏数据把整个题目详情打不开。同理,保存前 -先 `pickAstRules()` 剔除够不着的分组再校验,否则早年配过 C++ 规则的题会把老师锁死 -——tab 里看不到那组规则,保存却被拦下。 +判题机只认 C / C++ / Python3(`AST_SUPPORTED_LANGUAGES`),别的语言配了规则一条都不会跑, +所以后台不给它们开 tab —— **看得见却不检查**比没有更糟。C++ 的调用形态和 C 不一样、 +规则的语义校验为什么不挂在 zod 上,见 `docs/ast-rules.md`。 ### 比赛只有 ACM 模式 -没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要"顺手补回来"。 +没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要「顺手补回来」。 ### 前端基线是 Chrome 105(2026-09-16 从 < 94 上调) 机房**部分**电脑是 Chrome 105,其余更新 —— 按最低那档定基线。 -- **`@vitejs/plugin-legacy` 留着,别删**:vite 8 的默认构建 target 是 `chrome111`, - 比 105 高。这个插件同时把 `build.target` 压到 `es2020/chrome105`、给现代产物补 - core-js polyfill(`toSorted` / `Set` 运算 / 迭代器辅助那批是 Chrome 110+ 才有的)。 - `modernTargets` 不写,用插件自带的基线(`chrome>=105`),正好是这一档。 - polyfill 清单写死在 `vite.config.ts`,**升级前端依赖后重新审计**: - `DEBUG=vite:legacy bun run build` 会打印探测到的全集。 -- **Chrome < 94 那套删掉了**:`mermaid-legacy`(mermaid@9)、cytoscape 的 UMD→ESM - 别名、`useMermaid.ts` 里按 UA 分叉的 v9 回调式 render —— 105 用得上 mermaid 11。 +- **`@vitejs/plugin-legacy` 留着,别删**:vite 8 的默认构建 target 是 `chrome111`,比 105 高。 + 这个插件同时把 `build.target` 压到 `es2020/chrome105`、给现代产物补 core-js polyfill + (`toSorted` / `Set` 运算 / 迭代器辅助那批是 Chrome 110+ 才有的)。`modernTargets` 不写, + 用插件自带的基线(`chrome>=105`),正好是这一档。polyfill 清单写死在 `vite.config.ts`, + **升级前端依赖后重新审计**:`DEBUG=vite:legacy bun run build` 会打印探测到的全集。 +- **Chrome < 94 那套删掉了**:`mermaid-legacy`(mermaid@9)、cytoscape 的 UMD→ESM 别名、 + `useMermaid.ts` 里按 UA 分叉的 v9 回调式 render —— 105 用得上 mermaid 11。 - **View Transitions 要 111,105 没有**,`darkTransition.ts` 的降级分支是真在用的。 ### 时间只有一个锚点:`apps/api/src/time.ts` -**凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,一律走那个模块。** -不要写 `new Date(x).getHours()`、`setHours(0,0,0,0)`、`getFullYear()`、 -`new Date(y, m, d)` 这类跟**进程时区**走的代码 —— 容器是 UTC、开发机是本机时区, -两边答案不同而且不报错。SQL 里要按日历切,用 `localTime(列)`(生成 -`列 at time zone 'Asia/Shanghai'`),别依赖数据库会话时区。 - -旧栈 Django 是 `TIME_ZONE = "Asia/Shanghai"` + `USE_TZ = True`:库里存 UTC、 -应用层按北京时间算日历。重写时这个锚点丢了,直到 2026-09 才收回来 —— 期间 -「今日提交」在北京时间 0:00–8:00 是空的,两个成就(「凌晨提交次数」0:00–5:00、 -「早起提交次数」5:00–7:00)整体偏 8 小时。别再把口径散出去。 - -时区常量 `TIME_ZONE` / `TIME_ZONE_OFFSET_MINUTES` 在 `packages/contract/src/time.ts`, -前后端共用一份。实现按**固定偏移**算(大陆 1991 年起没有夏令时),不查 tzdata、不用 -`Intl`,所以 dev / 编译产物 / 任何镜像基底 / 任何浏览器都算得一样。 -**刻意不设** Dockerfile 的 `TZ`、也不设数据库连接的 `TimeZone`:它们不改变正确代码的 -行为,只会在线上把漏写的地方掩盖掉(`/problems/:displayId/yearly-ac` 就这样漏过一次), -而 dev 上又是另一个答案。 +**凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,一律走那个模块。** 不要写 +`new Date(x).getHours()`、`setHours(0,0,0,0)`、`getFullYear()`、`new Date(y, m, d)` 这类跟 +**进程时区**走的代码 —— 容器是 UTC、开发机是本机时区,两边答案不同而且不报错。 +SQL 里要按日历切,用 `localTime(列)`(生成 `列 at time zone 'Asia/Shanghai'`), +别依赖数据库会话时区。 **分层:存 UTC 时刻 → 后端判定按东八区 → 出参 ISO UTC → 前端按东八区渲染。** -- **存**:35 个时间列全是 `timestamptz`(`without time zone` 0 个、`date` 0 个), - 写侧一律 `new Date().toISOString()`。库里永远是绝对时刻,换时区不用动数据。 -- **判定**:日历语义(哪一天/几点/哪一年)走 `time.ts`,SQL 用 `localTime()`。 -- **出参**:`db/index.ts` 给 OID 1184 挂了 parser,**所有读出来的时刻统一成 - ISO 8601 UTC**(`2026-09-14T12:00:00.000Z`,库里带微秒的保留成 `…00.123456Z`)。 - 别在这里退回去 —— 原来 drizzle 把 1184 的 parser - 换成了恒等函数,读出来是 PG 文本(`2026-09-14 20:00:00+08`),于是同一个字段在 - 接口上有两种形状(实测同一批端点:PG 文本 77 处 + ISO 14 处),对接外部系统时对方 - 得解析两套,而偏移还取决于服务器会话时区、不该进契约。 - ⚠️ **微秒不能丢**,别改回 `new Date(v).toISOString()`:读出的时刻常被原样塞回查询条件 - (提交列表翻页的分界行、班级 AC 排名的 `<= min(create_time)`),截成毫秒后分界行自己 - 被排除 —— 翻页每页丢一条、排名少 1。生产库 12.3 万条 Django 时代的提交几乎全带微秒。 - ⚠️ **`::text` 的 OID 是 25、绕过那个 parser**,所以「为了拿回和列一样形状」而写的 - `max(join_time)::text` 之类现在会变成异类,见到就撤掉。**只换 1184,别碰 1082(date)** - —— `date(... at time zone ...)` 要的是 `2026-09-14`,套上 `toISOString()` 就错了。 -- **渲染**:前端 `parseTime()` / `zonedParts()` 按同一个固定偏移取东八区部件(见 - `apps/web/CLAUDE.md`)。三条解析路径(`new Date` / date-fns `parseISO` / VueUse - `normalizeDate`)实测都能吃 ISO,改动出参格式不需要动前端。 +- **存**:35 个时间列全是 `timestamptz`,写侧一律 `new Date().toISOString()`。 +- **判定**:日历语义走 `time.ts`,SQL 用 `localTime()`。 +- **出参**:`db/index.ts` 给 OID 1184 挂了 parser,读出来的时刻统一成 ISO 8601 UTC, + **微秒必须保留**(截成毫秒会让翻页每页丢一条、班级 AC 排名少 1)。 +- **渲染**:前端 `parseTime()` / `zonedParts()` 按同一个固定偏移取东八区部件 + (见 `apps/web/CLAUDE.md`)。 -### 存量成就的口径是东八区,别再退回 UTC - -(2026-09-14 用生产备份 `db_backup_2026_09_08_19_22_11.sql` 实测过,结论和直觉相反, -记在这里省得下次重新推。下面「那两周留下的实际后果」和脚本的跑数,又用 -`db_backup_2026_09_14_18_17_37.sql`(最新提交到北京时间 9-14 17:06)逐项复核过,一致。) - -**历史指标本来就是北京时间。** 旧栈 `OnlineJudge/achievement/metrics.py` 全程用 -`timezone.localtime(...)`,而 `settings.TIME_ZONE = "Asia/Shanghai"`,所以 -2022-04 到 OJ2 上线之间那 10 万多条提交累积出的 `user_stat.metrics` 是**东八区口径**。 -判别性核对(只在新旧口径算出不同值的用户里看存量更像哪边): - -| 指标 | 两口径不同 | 存量==UTC | 存量==东八区 | -|---|---|---|---| -| `midnight_submissions` | 1259 | 132 | 1123 | -| `early_bird_submissions` | 909 | 1 | 905 | -| `active_days` | 90 | 0 | 90 | -| `max_ac_in_one_day` | 17 | 0 | 17 | -| `max_ac_streak_days` | 40 | 0 | 40 | - -**所以修 OJ2 的时区不是「换口径」,是「把 OJ2 弄丢的口径补回来」。** 别以为改动会让 -存量数据失配 —— 失配的是 OJ2 上线后那两周,修完反而对齐了。 - -**那两周留下的实际后果**(截至 2026-09-14 备份,1508 条 OJ2 期提交、约 1500 个已结算用户): - -- **日期键没被污染**:`_active_dates` / `_ac_per_day` 一个都没偏 —— 上课时间的提交 - 在 UTC 下日期和北京是同一天。所以 5 个日期口径的成就(活跃天数、单日最多 AC、 - 连续天数)一条都没错。 -- **只有小时键被污染**:145 人 `midnight_submissions` 虚高、45 人 `early_bird_submissions` - 虚高。因为 UTC 的「凌晨 0–5 点」正好是北京的上午 9–13 点,而学生恰恰在上课时间提交。 -- **结果是 60 条误发**:47 个「夜猫子」+ 13 个「早起的鸟儿」,涉及 50 人,全是 OJ2 - 时期的新账号(`backfilled = false`)。 -- **0 条漏发**,而且是结构性的:OJ2 窗口那 1508 条提交里,真正落在北京 0–5 点和 - 5–7 点的**都是 0 条** —— 真熬夜、真早起的人都在 Django 时代活跃过了,他们的成就 - 是当时按东八区正确发的。这个 bug 只会多给,不会少给。 - -**改数据时最大的坑:不能只删 `user_achievement`。** -`unlockAchievements()` 的判定是**纯阈值比较**(`metrics[metric] >= threshold`),不是 -「这次有没有跨过阈值」。只删行、不修 `user_stat.metrics` 的话,学生**下一次提交就把 -同一个成就原样再发一次**。必须「按东八区重算小时指标」和「对账发放」一起做。 - -现成的工具是一次性对账脚本 `apps/api/src/scripts/fix-achievement-hours.ts` -(`bun run --filter '@oj2/api' fix:achievement-hours`,默认 dry-run,`--apply` 才写): -重算两个小时指标 → 撤回不达标的 → 补发达标却没发的 → 同步 `achievement.unlock_count` -→ 校正 `achievement_unlocked_count` 与「奖杯收藏家」连锁。实测幂等,2026-09-14 那批 -跑出来是「修正 148 行 · 撤回 60 条 · 补发 0 条 · 连锁 0 条」。 - -⚠️ **顺序:先部署时区修复,再跑这个脚本。** 反过来的话,旧代码还在按 UTC 累加, -跑完马上又被写脏、成就又发回来。 - -**下次再动日历口径,照这套方法核实**:从生产备份里捞出 `submission` / `user_stat` / -`user_achievement` / `achievement` 四张表回放一遍,先用与时间无关的指标 -(`submission_count` / `accepted_count`)校准重放器(实测逐人 0 差异 / 4 人差异), -再比受影响的指标。别靠推理 —— 这次推理就得出过相反的结论。 +时区常量 `TIME_ZONE` / `TIME_ZONE_OFFSET_MINUTES` 在 `packages/contract/src/time.ts`, +前后端共用一份,按**固定偏移**算(大陆 1991 年起没有夏令时)。旧栈的口径本来就是东八区, +重写时丢过一次、2026-09 才收回来 —— 期间「今日提交」在北京时间 0:00–8:00 是空的, +两个小时口径的成就整体偏 8 小时,事后已用一次性脚本对账订正(账平了,脚本已删)。 +**再动日历口径之前先读 `docs/timezone.md`**,那里有实测数据和核实方法; +Dockerfile 的 `TZ` 和数据库连接的 `TimeZone` 是**刻意不设**的,别「顺手补上」。 ## 数据库 Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它长得像 Django 建的表 (表名、bigint/int4 混用),`schema.ts` 顶部记了哪些地方是手工修的。 +**schema 现在归 OJ2 独占**,结构变更走 migration 正常演进。 **外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION: - **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表 - (problem_tags)、题单/教程/成就的组成部分、一对一附属(user_profile)与可重算的 - 缓存(user_stat)。 -- **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` - 的绝大多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。 + (problem_tags)、题单/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存 + (user_stat)。 +- **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` 的绝大 + 多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。 **加新子表时必须回来想一遍该走哪一档**,别默认新外键会自己连坐 —— drizzle 不写 `.onDelete()` 就是 NO ACTION,而 0010 只改了当时存在的那批。 -**schema 现在归 OJ2 独占。** 旧后端已下线,「改 schema 要考虑回滚」这条约束不再存在, -结构变更走下面的 migration 正常演进即可。 - ### 改 schema 走 drizzle migration -`bun run db:generate`(造迁移文件)→ `bun run db:migrate`(按 `drizzle.__drizzle_migrations` -增量执行),就是 Django `makemigrations` / `migrate` 的等价物。索引/结构变更走这条, -不要再手写 SQL 往 `docs/specs/` 里塞。 +`bun run db:generate`(造迁移文件)→ `bun run db:migrate`(按 +`drizzle.__drizzle_migrations` 增量执行),就是 Django `makemigrations` / `migrate` 的 +等价物。索引/结构变更走这条,不要再手写 SQL 往 `docs/` 里塞。 -**部署时自动执行。** `docker/deploy.sh` 在「构建镜像」之后、「起栈」之前会跑 -`oj2-api migrate`,失败就中止部署(旧容器原样还在跑)。CI 走的也是 deploy.sh, -所以不需要给 GitHub 配数据库凭据,也不用把生产库对外开放。 +- **执行器是自己的**(`db/migrate.ts`,一条迁移一个事务),不是 drizzle 那个, + `db:migrate` 和线上 `oj2-api migrate` 是同一条代码路径。 +- **部署时自动执行**:`docker/deploy.sh` 在构建镜像之后、起栈之前跑,失败就中止部署。 +- 迁移文件**不内嵌进二进制**,随镜像装在 `/usr/local/share/oj2/migrations` + (见 `runtime.ts` 的 `migrationsDir`),所以新增迁移不用改任何代码。 +- **破坏性迁移默认拦截**(`DROP TABLE` / `DROP COLUMN` / `ALTER COLUMN ... TYPE` / + `TRUNCATE`),退出 4,要显式放行:`OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`。 +- **空库能自举**,直接从 `0000` 建起,新环境不需要先灌 schema dump。 -迁移文件**不内嵌进二进制**,随镜像装在 `/usr/local/share/oj2/migrations` -(见 `runtime.ts` 的 `migrationsDir`、Dockerfile 里那两条 COPY)。这样 drizzle 的 -`migrate()` 能原样用——它靠 `meta/_journal.json` 自动发现迁移,**新增迁移不用改任何 -代码**。内嵌就得为每条迁移手写一行 import,那是迟早会漏的账。 - -**破坏性迁移默认拦截。** 含 `DROP TABLE` / `DROP COLUMN` / `DROP SCHEMA` / -`ALTER COLUMN ... TYPE` / `TRUNCATE` 的迁移会让部署停在迁移这步并退出 4, -需要确认备份后显式放行: - -```bash -OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh -``` - -`DROP INDEX` / `DROP CONSTRAINT` 不算——它们不掉数据,拦了只会让人习惯性带上放行开关。 -**空库自举时这道闸不生效**:没有数据可丢,0002 那串 `DROP ... IF EXISTS` 全是空转, -拦下来只会逼每个新环境都带一次放行开关,把它训练成习惯动作。 - -**放行的三条路,别记错:** - -1. 服务器上手工部署:`OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`。 -2. CI(`.github/workflows/deploy.yml`):**必须先手工触发**并在 - `workflow_dispatch` 上勾 `allow_destructive`。push 触发拿不到这个 input,值恒为空 - —— 也就是说**自动部署永远不会执行破坏性迁移**,只会停在闸门上把工作流判红。 - 这是有意的:那种改动得有人先确认备份。 -3. 先单跑迁移把结构推到位,再 push 代码:迁移一旦记进 - `drizzle.__drizzle_migrations` 就不会再跑,后续自动部署里它已不是 pending, - 自然不触发闸门。多环境共库时(机房 + 服务器)推荐这条。 - -**空库能自举了。** `oj2-api migrate` 指向一个空库时直接从 `0000` 建起: - -```bash -DATABASE_URL=postgres://... oj2-api migrate -# 空库,从 0000 开始自举。 -# 待执行 15 条迁移,开始。 -# ✓ 0000_crazy_gateway -# ✓ 0001_add_submission_public_create_time_idx -# ✓ 0002_drop_django_leftovers -# … -# ✓ 0014_drop_django_migrations -``` - -`0000_crazy_gateway.sql` 原本是 `drizzle-kit pull` 的产物、整份被 `/* */` 包着、可执行 -语句 0 条,所以以前新库只能先手工 `psql -f docs/specs/schema.sql`。现在它的内容由那份 -生产 dump 机械转换而来(去掉 psql 专有指令、去掉 7 张 Django 遗留表及其索引外键, -其余原样保留)。**实测**:空库自举出来的结构,和「灌 schema.sql + 打基线 + 跑迁移」 -这条老路子跑出来的结构,`pg_dump --schema-only` 逐字节一致(734 行,零差异)。 - -改 0000 对生产库没有影响 —— migrator 只比 `created_at`、**从不校验 hash** -(`pg-core/dialect.js` 里就一句 `Number(lastDbMigration.created_at) < migration.folderMillis`), -而生产库那行 `baseline-0000-faked` 早把它挡在门外了。 - -⚠️ **0000 的注释里不要出现 statement-breakpoint 那个分隔标记的字面量。** -`readMigrationFiles` 是纯文本切分,不管它在不在注释里,照切不误 —— 注释被从中间切开, -后半截当成 SQL 发出去,报的是 `syntax error at or near "。"` 这种和真实原因毫不相干的错。 - -**给一个已经存在的库做基线**:drizzle 没有 `--fake-initial`,`migrate` 见到空的 -`__drizzle_migrations`、库里却已经有表,会拒绝执行并 exit 3(裸跑 `drizzle-kit migrate` -的话则是从 `0000` 撞上已存在的表、整个事务回滚,**而且 exit 1 却一个错误都不打印**)。 -对已有数据的库第一次跑之前,先手插一行把 `0000` 标记成已执行: - -```sql -CREATE SCHEMA IF NOT EXISTS drizzle; -CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations ( - id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint); -INSERT INTO drizzle.__drizzle_migrations (hash, created_at) - VALUES ('baseline-0000-faked', 1786070652521); -- = meta/_journal.json 里 0000 的 when -``` - -migrator 只比 `created_at`,不校验 hash,所以 hash 随便填。 - -**已知的三个坑**(`meta/0000_snapshot.json` 是 `pull` 出来的,没法无损还原 Django 建的 -schema,下面三处已经修过了,别让它们回潮): - -- ~~**快照里的 Django 序列**~~:已随 `0002_drop_django_leftovers` 删表一并解决, - `tablesFilter` 也移除了。(历史原因:`tablesFilter` 只过滤表、不过滤它们的序列, - 于是 `generate` 会吐出 5 条 `DROP SEQUENCE`。) -- **bigint 上限精度**:`pull` 生成的 `maxValue: 9223372036854775807` 是 JS number 字面量, - round-trip 成 `...776000`,每次 generate 都会多出 10 条 `ALTER COLUMN ... SET MAXVALUE`。 - 已改成字符串。 -- **表达式索引的 opclass**:`problem_tag_name_ci_unique` 在快照里带 `opclass`,但 drizzle - 自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。 - -**还有一个写代码时要绕开的**: - -- **`.op()` 会吞掉索引方向**:真正的根因不是 `.desc()`,是 opclass。drizzle-kit 的 - `CreatePgIndexConvertor` 里那个三元一旦走进 opclass 分支就回不到方向分支: - `${it.opclass ? ` ${it.opclass}` : it.asc ? "" : " DESC"}`。而 `drizzle-kit pull` - 给**每一列**都挂了 `.op(...)`,所以本仓库里"写了 `.desc()` 却生成不出 DESC"每次都会重演。 - - **要方向就别写 `.op()`。** 不写没有任何代价——`int4_ops` / `timestamptz_ops` 本来就是 - 这些类型的默认 opclass,写了等于没写。实测(drizzle-kit 0.31.10,探针索引跑过 generate): - - | schema.ts | 生成的 SQL | - |---|---| - | `.desc().nullsFirst().op("timestamptz_ops")` | `"create_time" timestamptz_ops` ← 方向丢了 | - | `.desc().nullsFirst()` | `"create_time" DESC NULLS FIRST` ✅ | - | `.desc()` | `"create_time" DESC NULLS LAST` ✅ | - - 所以**多列混合方向的索引可以正常 generate**,不必手写。 - - 假 diff 的机制也要理解对:带 `.op()` 时快照记的是 `asc: false`,SQL 建出来却是 ASC, - **分歧在快照和真实库之间**,不在快照和 schema.ts 之间——所以再跑 generate 是干净的, - 要等到下次 pull 才炸出来。这是当初难定位的原因。 - -### 迁移执行器是自己的,不是 drizzle 那个 - -`db/migrate.ts` 不调用 drizzle 的 `migrate()`,自己按 journal 逐条执行。换掉它是因为 -`pg-core/dialect.js` 里那个实现有两条硬伤: - -1. **所有待执行的迁移共用一个事务**,第 3 条失败会把第 1、2 条一起回滚。现在是**一条一个 - 事务**,语义和 Django `migrate` 一致,失败时也说得清库停在哪儿。 -2. 正因为全在事务里,`CREATE INDEX CONCURRENTLY` 一律跑不了,没有开关。 - -记账行的写法和 drizzle 完全一致(`hash` = 整个文件的 sha256,`created_at` = journal 的 -`when`),而 migrator 只比 `created_at`、不校验 hash,所以两套执行器可以互换,不会看不懂 -对方写的记录。 - -**`CREATE INDEX CONCURRENTLY` 现在能跑了。** 在迁移文件**第一行**写上标记: - -```sql --- oj2:no-transaction -CREATE INDEX CONCURRENTLY "xxx_idx" ON "submission" USING btree ("language"); -``` - -这条迁移就走裸执行(简单查询协议,不包事务)。代价是**没有回滚**:中途失败时前面的语句 -已经生效,而且 CONCURRENTLY 失败会在库里留下一个 INVALID 索引,要先 -`DROP INDEX` 再重来(`select indexrelid::regclass from pg_index where not indisvalid` -能找出来)。所以**这种迁移一个文件只放一条语句**。 - -要不要用是另一回事:参考量级是 12.3 万行的部分索引,普通 `CREATE INDEX` 只锁 74ms, -一般不用纠结,CONCURRENTLY 留给真扛不住锁写窗口的场合。 - -退出码:2 = 配置/文件问题,3 = 基线不对,4 = 撞上破坏性迁移,5 = 某条迁移执行失败。 +`CREATE INDEX CONCURRENTLY` 怎么写、给已有库打基线的 SQL、`.op()` 会吞掉索引方向这类 +drizzle-kit 的坑,全在 `docs/database.md`。 ## 部署 三套 compose 在 `docker/`:`dev`(本机)、`debian`(服务器)、`school`(机房)。 -**机房那套没有 postgres,连的是服务器的库。** 两个站点共用一个数据库, -但各有各的 Redis 和判题沙箱 —— 所以上线那天**两边必须一起切**。 +**机房那套没有 postgres,连的是服务器的库。** 两个站点共用一个数据库,但各有各的 Redis +和判题沙箱 —— 所以涉及两边的变更要一起做。 -`compose.debian.yml` 有两种形态,靠 env 切换: +`compose.debian.yml` 靠 env 切形态:设 `DATA_DIR` / `DB_HOST` / `REDIS_HOST` 就是接现有的库 +(线上就是这个),留空并加 `--profile local-data` 就是自带 postgres / redis。 -- **只换前后端**(上线用这个):设 `DATA_DIR` / `DB_HOST` / `REDIS_HOST`, - 沿用旧栈已经在跑的 postgres 和 redis,只起 api / worker / web / judge。 -- **自带数据**(本机、演练):不设那几个变量,起栈时加 `--profile local-data`。 -- **并行试跑**(上线前先挂 `oj2.xuyue.cc` 跑几天):在「只换前后端」基础上再加 - `WEB_PORT`(8080 被旧 backend 占着)和 `JUDGE_STATE_DIR`(两个判题机不能共用运行目录)。 - 这种形态下旧栈一个容器都不用停,正式切换退化成改一行 NPM 上游。 +⚠️ **`DATA_DIR` 默认值 `../data` 是 `OJ2/data`,不是部署目录的 `data/`。** 沿用旧数据却忘了 +设它,会静默起一套空数据(空库、没测试点、图片 404),而且**不报错** —— 这是整个部署里 +唯一会静默走歪的地方,`deploy.sh` 为它专门设了一道自检。 -⚠️ `DATA_DIR` 默认值 `../data` 是 **`OJ2/data`**,不是部署目录的 `data/`。 -沿用旧数据却忘了设它,会静默起一套空数据(空库、没测试点、图片 404), -而且**不报错** —— 这是切换当天唯一会静默走歪的地方。 - -细节和演练结果都在 `docs/specs/phase5-cutover-runbook.md`。 +上线两条路(push 触发 CI / 手工 `docker/deploy.sh`)、部署后的验证清单、NPM 反代那两个 +不能关的开关、备份恢复的两个坑,都在 `docs/deploy.md`。 diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index 9eab09b..f250bfd 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -57,8 +57,8 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap 跨端的(登录、资料、标签、验证码)在 `shared/api.ts`。 Shared logic lives in `shared/`: -- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `screenMode` (problem split-screen layout), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room) -- `composables/` — `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `breakpoints`, `maxkb` +- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `screenMode` (problem split-screen layout), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (流程图弹窗的 mermaid 源码) +- `composables/` — `pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions,111 以下走降级分支), `hiddenStudents` (统计面板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity` - `layout/` — `default.vue` and `admin.vue` layout wrappers - `api.ts` — shared API calls (auth, profile, tags, captcha) @@ -158,17 +158,19 @@ Naive 的日期选择器按浏览器本地时区渲染、没有 `timezone` 属 ### Environment Variables -Variables prefixed with `PUBLIC_` are injected at build time. Env files: `.env`, `.env.staging`, `.env.test`. +Variables prefixed with `PUBLIC_` are injected at build time,声明在 `src/env.d.ts`。 +Env files: `.env`(本机)、`.env.production`(服务器)、`.env.staging` / `.env.test`(机房)。 | Variable | Purpose | |---|---| -| `PUBLIC_OJ_URL` | Backend REST API base URL | -| `PUBLIC_WS_URL` | WebSocket server URL | -| `PUBLIC_ENV` | Environment name (dev/staging/production) | -| `PUBLIC_CODE_URL` | Code execution service | -| `PUBLIC_JUDGE0_URL` | Judge0 API | -| `PUBLIC_MAXKB_URL` | Knowledge base service | -| `PUBLIC_ICONIFY_URL` | Iconify icon CDN | +| `PUBLIC_ENV` | 环境角标:`test` → 「测试版」,`dev` → 「开发版」,其余不显示 | +| `PUBLIC_CODE_URL` | 代码分享服务(提交详情、题目页的「分享」) | +| `PUBLIC_JUDGE0_URL` | Judge0 API(`utils/judge.ts` 的在线运行) | +| `PUBLIC_MAXKB_URL` | 知识库问答挂件 | +| `PUBLIC_ICONIFY_URL` | 自建 Iconify 图标源,不设则走公共 CDN | + +后端地址**不在这里**:`utils/api.ts` 写死 `baseURL: "/api"`,dev 由 `vite.config.ts` 的 +proxy 转给 3000,线上由 Caddy 同源伺服。(原来的 `PUBLIC_OJ_URL` / `PUBLIC_WS_URL` 早已不存在。) ### Routing diff --git a/docker/deploy.sh b/docker/deploy.sh index 2feb89c..0de37c5 100755 --- a/docker/deploy.sh +++ b/docker/deploy.sh @@ -27,7 +27,7 @@ # 源码也要重编 160s。CI 那条路(--prebuilt)反过来必须传产物,它自己的 rsync 在 # .github/workflows/deploy.yml 里,别照抄这条。 # -# 前提:docker/.env 已经填好(内容见 docs/specs/phase5-cutover-runbook.md 第三节)。 +# 前提:docker/.env 已经填好(照 docker/.env.example 拷一份再填,拓扑见 docs/deploy.md)。 # `sh docker/deploy.sh` 会用 dash 跑(Debian 的 /bin/sh 就是 dash),而下面那行 # 的 pipefail 是 bash 专有的,一上来就报 `Illegal option -o pipefail`。 diff --git a/docs/ast-rules.md b/docs/ast-rules.md new file mode 100644 index 0000000..05fca65 --- /dev/null +++ b/docs/ast-rules.md @@ -0,0 +1,51 @@ +# AST 代码规则 + +规矩在 `CLAUDE.md`「AST 代码规则」一节。这里是加语言、加 target 时要一起看的细节。 + +## 为什么会有 `check:ast` + +契约的 `AST_NODE_TARGETS_BY_LANGUAGE` 是**唯一**一张表,一个 target 一条 +`{ label, node }`:`label` 给后台下拉和题目页,`node` 给判题机比 tree-sitter 节点类型。 +运算符表 `AST_OPERATOR_TARGETS_BY_LANGUAGE` 一份两用(它的值既是文案又是要比的 token)。 +判题机侧没有第二张表,解析统一走契约的 `astTargetNodeType()`,所以**加 target 而漏配节点 +类型在结构上不可能**。 + +但**配错**仍然可能,而且完全静默:节点类型对不上就是一个都收不到,于是「必须使用 X」永远 +失败、「不能使用 X」永远通过,两头不报错,只有学生受着。 + +```bash +bun run --filter '@oj2/api' check:ast # 每个 target 的 node 在语法里是否真实存在 +``` + +**升级 `tree-sitter-*` 依赖之后一定要跑一次** —— 语法改节点名是常事,后果全静默。 +加这个检查那天,56 个 target 里就抓出一个:`f_string` 一直配的是 `format_string`, +而这个版本的 tree-sitter-python 根本没有这种节点(f-string 是 `string` 里带 +`interpolation`),所以「不能使用 f-string」从上线起就没生效过。 + +它只验节点类型**存在**,不验语义对不对(把 `while_loop` 配成 `for_statement` 这种两个都 +存在,机器看不出来),语义那层还是得实跑。 + +## 只有三种语言真的会跑 + +判题机只认 `AST_SUPPORTED_LANGUAGES`(C / C++ / Python3)。别的语言配了规则一条都不会跑, +所以后台不给它们开 tab,题目页也不把它们的规则展示成「要求」—— +**看得见却不检查**比没有更糟。 + +## C++ 不是「C 加几条」那么简单 + +C++ 的语法表是「C 的全集 + C++ 独有的几条」,因为 tree-sitter-cpp 继承 tree-sitter-c, +C 那 14 个 target 在 C++ 树里逐个实测通用。但**调用形态两者不同**,加语言时必须一起看: + +- `a.push_back()` 和 `p->push_back()` 在 C++ 都是 `call_expression` + `field_expression`, + 不是 Python 的 `attribute`; +- `std::sort(...)` 的 function 是 `qualified_identifier` 而不是 `identifier`,所以 + `functionCalls` 对 C++ 额外比一次 `::` 末段 —— 否则学生写了 `using namespace std` 与否 + 会得到不同的判定结果。 + +## 规则的语义校验为什么不在 zod 上 + +在 `astRulesError()`,不在 `astRulesSchema` 的 refine 上:那个 schema 同时用于**读**后台 +题目详情,在读路径上抛错会让历史脏数据把整个题目详情打不开(同 `docs/contract.md` 那套教训)。 + +同理,保存前先 `pickAstRules()` 剔除够不着的分组再校验,否则早年配过 C++ 规则的题会把老师 +锁死 —— tab 里看不到那组规则,保存却被拦下。 diff --git a/docs/contract.md b/docs/contract.md new file mode 100644 index 0000000..6a3f81f --- /dev/null +++ b/docs/contract.md @@ -0,0 +1,47 @@ +# 契约:闸门设在写入侧的来龙去脉 + +规矩在 `CLAUDE.md`「出参不 `parse`,用 `satisfies`」一节,前端那侧 +(`utils/contract.ts` 为什么只挂三处)在 `apps/web/CLAUDE.md`。这里是证据。 + +## 读出侧校验自己造出来的四次故障 + +后端出参原来有 136 处 `xxxSchema.parse({...})`,全部撤成 `satisfies`。撤的时候当场炸出 +两个一直存在的线上 500: + +- `adminProblemSchema.lastUpdateTime` 写的是 `z.string()`,但 `problem.last_update_time` + 是全库唯一可空的列(961 道题里 470 道是 NULL)——**后台打开任何一道没编辑过的老题都是 500**; +- `embeddedSubmissionSchema` 从 `submissionDetailSchema` 继承了 `problemDisplayId` 却没 + omit,而路由只填了同义的 `problem` ——**凡是收到过站内信的人,消息页都打不开** + (列表为空时才碰巧不炸,所以一直没人报)。 + +历史上还有两次同类: + +- `exerciseSchema` 按题型收紧后,一行脏数据让整条练习列表 500; +- `info` 写成 `union([完整形状, z.object({})])` 后,对不上的一律落进空对象那支且 + parse **成功**,管理员详情页的测试点表格静默消失。全量核出 9163/124192 条中招, + RE 8480/8480 全中 —— 沙箱在非正常退出的测试点上写 `output_md5: null`, + 而契约写的是 `z.string()`。 + +四次都是「读出侧校验」自己造出来的故障,不是它拦住的故障。出参是后端自己刚拼出来的 +字面量,TS 已经在编译期校验过;再 parse 一遍拿不到任何新信息,唯一可能失败的输入是 +**库里的历史数据**,而失败的代价是 500。 + +## 闸门的三处形态 + +1. **入参 `safeParse`**(58 处,全部保留)—— 请求体进来的那一刻校验,对不上回 400。 +2. **`db/schema.ts` 的 `.$type<>()`** —— 枚举型的列(`submission.result` / `.language`、 + `problem.difficulty` / `.languages`、`achievement.rarity`、`exercise.type`…)和几个形状 + 确定的 JSONB(`problem.template` / `.astRules` / `.sqlConfig` / `.sqlDisplay`、 + `acm_contest_rank.submission_info`)直接在列上收窄,只影响 TS、不产生任何 SQL。 + 这些断言**逐列拿生产备份核过**(12.4 万条提交的 `result` 全在 `-2..6,10`、961 道题的 + `languages` 全是合法数组、10050 条榜单条目形状全对)。**加这类断言前先照样核一遍,别凭直觉。** +3. **语义校验函数** —— `astRulesError()`、`services/exercise.ts` 的 `exerciseDataError`。 + +**JSONB 原文(`submission.info` / `statistic_info` / `exercise.data`)仍然一律放行**, +读出侧不收窄:它们的形状真相在判题机那边。 + +query 里的筛选值要和收窄过的列比较时走 `routes/helpers.ts` 的 `asFilterValue()` —— +那是纯类型交接,**不加校验**:在那儿拦一道会把「筛出空列表」变成「筛条件被忽略、返回全部」。 + +唯一还留着 `parse` 的地方是 `judge/events.ts` 的 `parseSubmissionEvent` —— 那是从 Redis +收回来的报文,真边界,且失败返回 `null` 而不是 500。 diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..2b830dd --- /dev/null +++ b/docs/database.md @@ -0,0 +1,135 @@ +# 数据库与迁移 + +`CLAUDE.md` 里只留了日常要记住的那几条,这里是细节:迁移执行器为什么是自己的、 +空库怎么自举、给已有库打基线、以及 drizzle-kit 的几个坑。 + +## 迁移执行器是自己的,不是 drizzle 那个 + +`db/migrate.ts` 不调用 drizzle 的 `migrate()`,自己按 `meta/_journal.json` 逐条执行。 +换掉它是因为 `pg-core/dialect.js` 里那个实现有两条硬伤: + +1. **所有待执行的迁移共用一个事务**,第 3 条失败会把第 1、2 条一起回滚。现在是**一条一个 + 事务**,语义和 Django `migrate` 一致,失败时也说得清库停在哪儿。 +2. 正因为全在事务里,`CREATE INDEX CONCURRENTLY` 一律跑不了,没有开关。 + +记账行的写法和 drizzle 完全一致(`hash` = 整个文件的 sha256,`created_at` = journal 的 +`when`),而 migrator 只比 `created_at`、不校验 hash,所以两套执行器可以互换, +不会看不懂对方写的记录。 + +**`bun run db:migrate` 走的就是这个执行器**(`bun src/main.ts migrate`),和线上 +`oj2-api migrate` 完全同一条代码路径。`drizzle-kit migrate` 只在 `db:generate` +的对面存在,别再去调它 —— 对着已打基线的库裸跑会从 `0000` 撞上已存在的表、整个事务 +回滚,**而且 exit 1 却一个错误都不打印**。 + +退出码:2 = 配置/文件问题,3 = 基线不对,4 = 撞上破坏性迁移,5 = 某条迁移执行失败。 + +### `CREATE INDEX CONCURRENTLY` + +在迁移文件**第一行**写上标记,这条迁移就走裸执行(简单查询协议,不包事务): + +```sql +-- oj2:no-transaction +CREATE INDEX CONCURRENTLY "xxx_idx" ON "submission" USING btree ("language"); +``` + +代价是**没有回滚**:中途失败时前面的语句已经生效,而且 CONCURRENTLY 失败会在库里留下 +一个 INVALID 索引,要先 `DROP INDEX` 再重来 +(`select indexrelid::regclass from pg_index where not indisvalid` 能找出来)。 +所以**这种迁移一个文件只放一条语句**。 + +要不要用是另一回事:参考量级是 12.3 万行的部分索引,普通 `CREATE INDEX` 只锁 74ms, +一般不用纠结,CONCURRENTLY 留给真扛不住锁写窗口的场合。 + +### 破坏性迁移的三条放行路 + +含 `DROP TABLE` / `DROP COLUMN` / `DROP SCHEMA` / `ALTER COLUMN ... TYPE` / `TRUNCATE` +的迁移会让部署停在迁移这步并退出 4。`DROP INDEX` / `DROP CONSTRAINT` 不算 —— 它们不 +掉数据,拦了只会让人习惯性带上放行开关。 + +1. 服务器上手工部署:`OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`。 +2. CI(`.github/workflows/deploy.yml`):**必须先手工触发**并在 `workflow_dispatch` 上勾 + `allow_destructive`。push 触发拿不到这个 input,值恒为空 —— 也就是说**自动部署永远 + 不会执行破坏性迁移**,只会停在闸门上把工作流判红。这是有意的:那种改动得有人先确认备份。 +3. 先单跑迁移把结构推到位,再 push 代码:迁移一旦记进 `drizzle.__drizzle_migrations` + 就不会再跑,后续自动部署里它已不是 pending,自然不触发闸门。多环境共库时 + (机房 + 服务器)推荐这条。 + +**空库自举时这道闸不生效**:没有数据可丢,`0002` 那串 `DROP ... IF EXISTS` 全是空转, +拦下来只会逼每个新环境都带一次放行开关,把它训练成习惯动作。 + +## 空库能自举 + +`oj2-api migrate`(或本机 `bun run db:migrate`)指向一个空库时直接从 `0000` 建起: + +``` +空库,从 0000 开始自举。 +待执行 16 条迁移,开始。 + ✓ 0000_crazy_gateway + ✓ 0001_add_submission_public_create_time_idx + … + ✓ 0015_submission_filter_indexes +``` + +`0000_crazy_gateway.sql` 原本是 `drizzle-kit pull` 的产物、整份被 `/* */` 包着、可执行 +语句 0 条,所以以前新库只能先手工灌一份 schema dump。现在它的内容由生产 dump 机械转换 +而来(去掉 psql 专有指令、去掉 7 张 Django 遗留表及其索引外键,其余原样保留)。 + +**实测**(2026-09-16 复测,16 条迁移):空库自举出来的结构,和「灌 schema dump + 打基线 ++ 跑迁移」这条老路子跑出来的结构,`pg_dump --schema-only --no-owner --no-privileges` +逐行一致,1981 行零差异。本机开发库(`bun run db:up`)走的就是自举这条路。 + +改 0000 对生产库没有影响 —— migrator 只比 `created_at`、**从不校验 hash**, +而生产库那行 `baseline-0000-faked` 早把它挡在门外了。 + +⚠️ **0000 的注释里不要出现 statement-breakpoint 那个分隔标记的字面量。** +`readMigrationFiles` 是纯文本切分,不管它在不在注释里,照切不误 —— 注释被从中间切开, +后半截当成 SQL 发出去,报的是 `syntax error at or near "。"` 这种和真实原因毫不相干的错。 + +## 给一个已经存在的库做基线 + +drizzle 没有 `--fake-initial`。`migrate` 见到空的 `__drizzle_migrations`、库里却已经有表, +会拒绝执行并 exit 3。对已有数据的库第一次跑之前,先手插一行把 `0000` 标记成已执行: + +```sql +CREATE SCHEMA IF NOT EXISTS drizzle; +CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations ( + id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint); +INSERT INTO drizzle.__drizzle_migrations (hash, created_at) + VALUES ('baseline-0000-faked', 1786070652521); -- = meta/_journal.json 里 0000 的 when +``` + +migrator 只比 `created_at`,不校验 hash,所以 hash 随便填。 + +## drizzle-kit 的坑 + +`meta/0000_snapshot.json` 是 `pull` 出来的,没法无损还原 Django 建的 schema。 +下面两处已经修过了,**别让它们回潮**: + +- **bigint 上限精度**:`pull` 生成的 `maxValue: 9223372036854775807` 是 JS number 字面量, + round-trip 成 `...776000`,每次 generate 都会多出 10 条 `ALTER COLUMN ... SET MAXVALUE`。 + 已改成字符串。 +- **表达式索引的 opclass**:`problem_tag_name_ci_unique` 在快照里带 `opclass`,但 drizzle + 自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。 + +(第三处「快照里的 Django 序列」已随 `0002_drop_django_leftovers` 删表一并解决, +`tablesFilter` 也移除了。) + +**还有一个写代码时要绕开的 —— `.op()` 会吞掉索引方向。** 根因不是 `.desc()`,是 opclass: +drizzle-kit 的 `CreatePgIndexConvertor` 里那个三元一旦走进 opclass 分支就回不到方向分支 +(`${it.opclass ? ` ${it.opclass}` : it.asc ? "" : " DESC"}`),而 `drizzle-kit pull` +给**每一列**都挂了 `.op(...)`,所以本仓库里「写了 `.desc()` 却生成不出 DESC」每次都会重演。 + +**要方向就别写 `.op()`。** 不写没有任何代价 —— `int4_ops` / `timestamptz_ops` 本来就是 +这些类型的默认 opclass。实测(drizzle-kit 0.31.10,探针索引跑过 generate): + +| schema.ts | 生成的 SQL | +|---|---| +| `.desc().nullsFirst().op("timestamptz_ops")` | `"create_time" timestamptz_ops` ← 方向丢了 | +| `.desc().nullsFirst()` | `"create_time" DESC NULLS FIRST` ✅ | +| `.desc()` | `"create_time" DESC NULLS LAST` ✅ | + +所以**多列混合方向的索引可以正常 generate**,不必手写。 + +假 diff 的机制也要理解对:带 `.op()` 时快照记的是 `asc: false`,SQL 建出来却是 ASC, +**分歧在快照和真实库之间**,不在快照和 schema.ts 之间 —— 所以再跑 generate 是干净的, +要等到下次 pull 才炸出来。这是当初难定位的原因。 diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..303e001 --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,133 @@ +# 部署 + +现行部署形态与操作。**具体变量怎么填看 `docker/.env.example`**,那份注释是权威; +这里只写它装不下的东西:两个站点的拓扑、上线的两条路、出事时的退路。 + +## 拓扑 + +| | 服务器(`oj.xuyue.cc`) | 机房 | +|---|---|---| +| compose | `docker/compose.debian.yml` | `docker/compose.school.yml` | +| env | `docker/.env` | `docker/.env.school` | +| 库 | 本机 postgres(旧栈起的,原地没动) | **没有,连服务器那台的 5445** | +| Redis / 判题沙箱 | 各自一套 | 各自一套 | +| 对外 | NPM 反代 → `WEB_PORT` | http 直连 IP,端口 81 | + +**两个站点共用一个数据库**,所以结构变更对两边同时生效;但判题队列(BullMQ 在本地 +Redis)和 WebSocket 推送(本地 pub/sub)是每站独立的 —— 学生在哪边提交就在哪边判、 +推送也只推得到连在本站的人。这和旧栈 Dramatiq + Channels 的拓扑一致,不是回归。 + +机房那套 `COOKIE_SECURE=false`:走 http 直连 IP,带 `Secure` 的 Cookie 浏览器不回传, +表现是「登录成功又立刻变未登录」。 + +## 三种数据形态 + +`compose.debian.yml` 靠 env 切换,判据是 `DB_HOST`: + +- **外接数据**(线上就是这个):设 `DATA_DIR` / `DB_HOST` / `REDIS_HOST`, + 沿用已经在跑的 postgres 和 redis,本栈只起 api / worker / web / judge。 +- **自带数据**(本机、演练):那几个变量留空,起栈时加 `--profile local-data`。 +- 试跑形态额外要 `WEB_PORT` 和 `JUDGE_STATE_DIR`(两个判题机不能共用运行目录)。 + +⚠️ **`DATA_DIR` 默认值 `../data` 是 `OJ2/data`,不是部署目录的 `data/`。** +沿用旧数据却忘了设它,会静默挂上一堆空目录:空库、没测试点、题面图片 404, +**而且不报错**。这是整个部署里唯一会静默走歪的地方,`deploy.sh` 专门为它设了一道自检。 + +## 上线 + +### 服务器:push 就部署 + +`.github/workflows/deploy.yml` —— 在 runner 上编好产物、rsync 到服务器、在服务器上 +跑 `docker/deploy.sh --prebuilt`。触发的是 push 到 **github** 这个 remote +(`origin` 是 `git.xuyue.cc`,平时那次 push 不触发): + +```bash +git push github main +``` + +产物在 runner 上编是因为服务器性能差(首次构建约 5 分钟,光前端就 160s)。 +**服务器自己编的能力没有砍掉**:不带 `--prebuilt` 就是原来的行为,只要有 docker +就能手动部署,不依赖 CI。 + +### 手工部署(机房、或 CI 不可用时) + +```bash +cd /root/OJDeploy/OJ2 +docker/deploy.sh # 自检 → 构建 → 迁移 → 起栈 → 冒烟 +docker/deploy.sh --check # 只自检,只读,不动任何容器 +docker/deploy.sh --no-build # 只改了 env / compose 时跳过构建 +``` + +代码怎么上到服务器不归它管(rsync 命令在脚本头部注释里)。 + +脚本起栈前有一串自检,**每一条都是真撞到过的**:compose 版本 ≥ 2.20、`DATA_DIR` +有没有生效、库指向和形态是否自洽、判题机运行目录有没有和旧栈分开、外接的 +postgres / redis 是否活着。起完再跑四条冒烟,**题目数是 0 也中止** —— 那意味着连错库了。 + +### 迁移在起栈之前跑 + +`deploy.sh` 在「构建镜像」之后、「起栈」之前跑 `oj2-api migrate`,失败就中止部署 +(旧容器原样还在跑)。所以不需要给 GitHub 配数据库凭据,也不用把生产库对外开放。 + +破坏性迁移(`DROP TABLE` / `DROP COLUMN` / `ALTER COLUMN ... TYPE` / `TRUNCATE`) +会让部署停在这一步并退出 4,放行的三条路见 `docs/database.md`。 + +## 部署后验证 + +命令能测的(服务器 `WEB_PORT`,机房 81): + +```bash +BASE=http://localhost:8080 +curl -s -o /dev/null -w '首页 %{http_code}\n' $BASE/ +curl -s -o /dev/null -w '站点配置 %{http_code}\n' $BASE/api/site +curl -s -o /dev/null -w '题目列表 %{http_code}\n' $BASE/api/problems +curl -s -o /dev/null -w '未登录进后台 %{http_code}\n' $BASE/api/admin/dashboard # 期望 401 +``` + +两个失败模式的症状别搞混: + +- **题目列表 `"total":0`** → 连错库了(`DB_HOST` 没设,或误加了 `--profile local-data` + 起了个自带的空 postgres)。立刻停下来查。 +- **库是对的,但判题全错、题面图片 404** → `DATA_DIR` 指错了。 + +命令测不到、必须手点的:登录 → 提交一道题看结果**实时刷出来**(这一步同时验证 +WebSocket)→ 后台判题机列表在线 → 后台题目列表翻页 → 带图片的题面能显示。 +机房额外确认一条:登录之后刷新还是登录态。 + +## NPM 反代 + +一次性配好的,只有改了 `WEB_PORT` 才要回去动端口。另外两项别关: + +| 项 | 值 | 关了会怎样 | +|---|---|---| +| **Websockets Support** | 开 | 页面一切正常,唯独学生盯着的「判题中…」永远不动 | +| `client_max_body_size` | `200M` | 后台上传测试用例压缩包失败(和 Caddyfile 的 `200MiB` 对齐) | +| SSL | 签证书 | `COOKIE_SECURE=true` 依赖 https | + +## 备份与灾难恢复 + +`docker/backup-db.sh` 走 `pg_dumpall` 全量(所有库 + 所有角色),带校验和保留策略。 +恢复时有两条只在现场才暴露的坑: + +1. **`pg_dumpall` 的备份会覆盖数据库口令。** 里面带 `ALTER ROLE onlinejudge ... PASSWORD`, + 恢复完 compose 里的 `POSTGRES_PASSWORD` 就对不上了,报的是 + `password authentication failed`,看起来和恢复毫不相干。恢复后要么把 + `POSTGRES_PASSWORD` 改成备份当时的口令,要么手动 `ALTER ROLE`。 +2. **恢复前必须先停应用。** 应用连着库时 dump 里的 `DROP DATABASE` 会失败 + (`database "onlinejudge" is being accessed by other users`),接下来就是满屏主键冲突。 + 先停 oj-api / oj-worker,再恢复。 + +> **旧栈已经不可逆地下线**(`0002_drop_django_leftovers` 删掉了 Django 的框架表并已在 +> 生产库执行完毕),所以「停新栈起旧栈」不再是退路,**唯一退路是从数据库备份恢复**。 + +## 镜像体积 + +| 镜像 | 体积 | | +|---|---|---| +| `oj2-api` | 487MB | 其中 `clang-format`(apt)269MB、二进制 112MB、基底 79MB、`ruff` 28MB | +| `oj2-web` | 75MB | | + +一半以上是 clang-format 拖进来的 LLVM(`libLLVM.so` 一个就 124MB)。设计文档当初写的 +「降至数十 MB」没做到,就是漏算了它。想再瘦只有一条路:换成 PyPI 那个静态链接的 +clang-format 独立二进制,能砍掉约 265MB。没做 —— 镜像是各站点本地构建的、不走镜像仓库, +磁盘不是瓶颈。 diff --git a/docs/plans/2026-08-06-phase0-endpoint-inventory.md b/docs/plans/2026-08-06-phase0-endpoint-inventory.md deleted file mode 100644 index e4d8514..0000000 --- a/docs/plans/2026-08-06-phase0-endpoint-inventory.md +++ /dev/null @@ -1,483 +0,0 @@ -# 阶段 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:**127 个(oj 77 / admin 50)**,其中 **17 个**已由人工标注 `# DEPRECATED: 前端未调用`。任何提取脚本的输出必须与这两个数字吻合,不吻合就是脚本有 bug。 - - > **这两个数字不是提取脚本自己产出的**,否则自检就退化成"脚本必须复现自己的 bug"——本计划初稿写的 122 / 74 / 48 / 16 正是这么来的,脚本漏抓了 `tutorial/urls/tutorial.py` 与 `utils/urls.py` 两个文件共 5 个端点,ground truth 跟着一起错。 - > - > 独立核验方式(不经过任何提取脚本): - > - > ```bash - > cd /home/xuyue/Projects/OJ/OnlineJudge - > cat */urls/*.py utils/urls.py | grep -c "path(" # → 127 - > ``` - > - > oj/admin 的拆分靠与 `OnlineJudge/oj/urls.py` 的 include 清单逐条对齐核验:该文件共 26 条 `include(...)`,挂载前缀 `api/` 的归 oj、`api/admin/` 的归 admin,把每条 include 指向的文件的 `path(` 计数按前缀分别累加 → oj 77、admin 50。注意其中两条不符合"`/urls/{oj,admin}.py`"的命名惯例:`tutorial.urls.tutorial`(目录里但文件名不叫 oj)与 `utils.urls`(模块文件,没有 `urls/` 目录)。**提取器必须以 `oj/urls.py` 为唯一入口,不得按文件名白名单猜。** - -- 前端调用路径基线:**148 条 `method + path` / 104 条不同路径**(含模板字符串,`${...}` 归一化为 `:param`)。计划初稿写的 78 是只数字面量、不含模板串和泛型 `get(...)` 的旧口径,已作废。 -- 反向对账基线:前端调用路径全部能在后端端点全集里找到对应,**orphan 应为 0**。非 0 说明提取器又漏了 urls 文件,或前端有调用死路径的代码。 -- 工作目录统一为 `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` 时仍用原文。 -4. **不要按文件名白名单(`oj.py` / `admin.py`)扫 `/urls/` 目录。** 初版这么写,静默漏掉 5 个端点(其中 4 个前端在用):`tutorial/urls/tutorial.py` 文件名不在白名单里,`utils/urls.py` 根本没有 `urls/` 目录、被 `statSync` 的 catch 直接吞掉。改为解析 `OnlineJudge/oj/urls.py` 的 26 条 `include(...)`,`side` 与路径前缀直接取挂载前缀,`app` 取 Python 模块名首段(不能用目录层数推,`utils.urls` 只有两段)。 - -- [ ] **Step 1: 确认脚本输出与 ground truth 一致** - -```bash -cd /home/xuyue/Projects/OJ/OJ2/docs/spikes -bun run extract-endpoints.ts /home/xuyue/Projects/OJ/OnlineJudge -``` - -预期输出,三个数字必须完全一致: -``` -挂载点 26 个(来自 oj/urls.py 的 include) -后端端点合计 127 (oj 77 / admin 50) -其中已标 DEPRECATED: 17 -→ 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`,否则无法与后端的 `` 之类对账。 - -- [ ] **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 -``` - -预期:`前端调用点 154 处,去重后 148 条`(148 是 `method + path` 去重;只按 path 去重是 104 条)。若明显低于此数,说明正则漏了写法,检查 `src/utils/http.ts` 里 http 客户端的实际调用形式再修 —— 泛型 `get("x")` 是重灾区,不吃泛型会漏掉三分之一。 - -- [ ] **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`;后端有 `` 之类占位符,前端归一化成了 `:param`。所以要各自降到一个可比的 key。 - -- [ ] **Step 1: 写对账脚本** - -> 下面是初稿。**以仓库里的 `docs/spikes/reconcile.ts` 为准**,它比初稿多两处必要修正:`key()` 不能剥掉 `admin/` 段(初稿的 `(admin\/)?` 分组会把全部 admin 端点误判成 REVIEW),以及新增了反向对账(前端调用了但后端查无此端点)。 - -```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 -``` - -预期:`KEEP 104 / CUT 17 / REVIEW 6 合计 127`,且**不出现** `⚠ 前端调用无对应后端端点` 这行反向对账告警。REVIEW 数量若超过 40,说明 `key()` 归一化不够,多半是后端 `pattern` 里还有没处理的占位符写法 —— 先抽查几个 REVIEW 行确认是真需人工判还是归一化没做对。反向告警若非 0,先查提取器是不是又漏了 urls 文件,再考虑是不是前端留了死调用。 - -- [ ] **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)、删除死代码(改为清单决策,见全局约束)、验证 jieba(Task 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`,归一化规则单点定义。 diff --git a/docs/plans/2026-08-06-phase1-skeleton.md b/docs/plans/2026-08-06-phase1-skeleton.md deleted file mode 100644 index 6140eae..0000000 --- a/docs/plans/2026-08-06-phase1-skeleton.md +++ /dev/null @@ -1,675 +0,0 @@ -# 阶段 1:monorepo 骨架 —— 实施计划 - -> **给执行者:** 用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans` 逐任务实施。步骤用 `- [ ]` 复选框跟踪。 - -**目标:** 建起 `OJ2` 的 Bun workspaces 骨架,`bun dev` 能起来,并从本地 PostgreSQL 真实读出一道题、在浏览器里显示出来。 - -**架构:** 三个 workspace —— `apps/api`(Bun + Hono + Drizzle)、`apps/web`(由 ojnext 原样拷入,仅换 API 层)、`packages/contract`(Zod schema,前后端唯一真相源)。数据库结构由 `drizzle-kit pull` 从本地 PostgreSQL introspect 生成,剪掉 Django 框架表。本阶段只打通"读一道题"这一条最薄的链路,不碰认证、不碰判题。 - -**技术栈:** Bun 1.3.11、Hono、Drizzle ORM 0.45.2 / drizzle-kit 0.31.10、Zod、Vue 3 + Vite(沿用 ojnext)。 - -## 全局约束 - -- **不修改 `/home/xuyue/Projects/OJ/OnlineJudge/` 和 `/home/xuyue/Projects/OJ/ojnext/` 任何文件。** 两个旧仓库全程冻结,回滚路径依赖于此。`apps/web` 是**拷贝**,不是移动,不带 git 历史。 -- **不写测试。** 项目既定策略(根 `CLAUDE.md`:Do not write new tests)。本计划用"跑起来看输出"替代测试环节。 -- **PostgreSQL 固定 16。** 生产是 16.10,本地 compose 用 `postgres:16-alpine`(实测 16.14)。**不要换 17/18** —— 开发环境用了生产不支持的特性,要到切换那天才炸。 -- **前端必须兼容旧版 Chrome(< 94)。** 学校机房电脑的浏览器版本低,ojnext 里的 `mermaid-legacy` 等 fallback 依赖是为此存在的,**搬运时一个都不能删**,`vite.config.ts` 的 build target 也不能提高。 -- 本地依赖服务:`docker compose -f docker/compose.dev.yml up -d`,PostgreSQL 在 **5433**、Redis 在 **6380**(端口特意错开本机默认)。 -- 数据库连接串:`postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge` -- 本地库结构已灌好:**34 张表**(27 业务 + 7 Django 框架),与 `docs/specs/schema.sql` 差集为 0。 -- 端点清单已定案:新后端需实现 **110 个**端点,见 `docs/specs/endpoint-inventory.md`。本阶段只实现其中 1 个。 -- Docker 命令若报 permission denied,说明当前 shell 不在 `docker` 组,用 `newgrp docker <<'EOF' … EOF` 包一层;重启终端后即可直接用。 - ---- - -## 文件结构 - -| 文件 | 职责 | -|---|---| -| `package.json` | 根 workspace 定义 + 顶层脚本 | -| `bunfig.toml` | Bun 配置 | -| `tsconfig.base.json` | 三个 workspace 共享的 TS 编译选项 | -| `packages/contract/package.json` / `src/index.ts` | Zod schema 出口,前后端共同依赖 | -| `packages/contract/src/problem.ts` | 题目相关的 Zod schema 与类型 | -| `apps/api/package.json` / `src/index.ts` | Hono 应用入口 | -| `apps/api/src/db/schema.ts` | `drizzle-kit pull` 生成后剪枝的表定义 | -| `apps/api/src/db/index.ts` | Drizzle 客户端单例 | -| `apps/api/src/routes/problem.ts` | 题目路由 | -| `apps/api/drizzle.config.ts` | drizzle-kit 配置 | -| `apps/web/**` | 由 ojnext 拷入,仅改 API 层 | -| `docs/specs/sample-data.sql` | 从生产导出的题目样本(不含用户数据) | - ---- - -## Task 1: monorepo 骨架与共享契约包 - -**Files:** -- Create: `package.json`、`bunfig.toml`、`tsconfig.base.json` -- Create: `packages/contract/package.json`、`packages/contract/tsconfig.json`、`packages/contract/src/index.ts`、`packages/contract/src/problem.ts` - -**Interfaces:** -- Produces: workspace 名 `@oj2/contract`,导出 `problemSummarySchema`、`ProblemSummary`(后续任务的前后端都从这里 import) - -- [ ] **Step 1: 写根 `package.json`** - -```json -{ - "name": "oj2", - "private": true, - "type": "module", - "workspaces": ["apps/*", "packages/*"], - "scripts": { - "dev": "bun run --filter '*' dev", - "dev:api": "bun run --filter '@oj2/api' dev", - "dev:web": "bun run --filter '@oj2/web' dev", - "db:up": "docker compose -f docker/compose.dev.yml up -d", - "db:down": "docker compose -f docker/compose.dev.yml down" - }, - "devDependencies": { - "typescript": "^5.7.0", - "@types/bun": "latest" - } -} -``` - -- [ ] **Step 2: 写 `bunfig.toml`** - -```toml -[install] -# workspace 内部依赖走本地链接,不去 registry 找 -linker = "hoisted" -``` - -- [ ] **Step 3: 写 `tsconfig.base.json`** - -```json -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "lib": ["ESNext", "DOM"], - "strict": true, - "noUncheckedIndexedAccess": true, - "skipLibCheck": true, - "esModuleInterop": true, - "resolveJsonModule": true, - "types": ["bun"] - } -} -``` - -- [ ] **Step 4: 写 `packages/contract/package.json`** - -```json -{ - "name": "@oj2/contract", - "version": "0.0.0", - "private": true, - "type": "module", - "main": "./src/index.ts", - "types": "./src/index.ts", - "exports": { ".": "./src/index.ts" }, - "dependencies": { - "zod": "^4.0.0" - } -} -``` - -- [ ] **Step 5: 写 `packages/contract/tsconfig.json`** - -```json -{ - "extends": "../../tsconfig.base.json", - "include": ["src"] -} -``` - -- [ ] **Step 6: 写 `packages/contract/src/problem.ts`** - -字段照着本地库 `problem` 表的真实列取。只放本阶段用得到的几个,不要把整张表铺开 —— YAGNI。 - -```typescript -import { z } from "zod" - -/** 题目列表项。字段取自 problem 表,只含列表页需要的列。 */ -export const problemSummarySchema = z.object({ - id: z.number().int(), - _id: z.string(), // 展示用编号,与自增 id 不同 - title: z.string(), - difficulty: z.string(), - submissionNumber: z.number().int(), - acceptedNumber: z.number().int(), -}) - -export type ProblemSummary = z.infer -``` - -- [ ] **Step 7: 写 `packages/contract/src/index.ts`** - -```typescript -export * from "./problem" -``` - -- [ ] **Step 8: 安装并确认 workspace 被识别** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -bun install -``` - -预期:输出里能看到 workspace 解析,`node_modules/@oj2/contract` 是指向 `packages/contract` 的符号链接。核实: - -```bash -ls -l node_modules/@oj2/ -``` - -预期看到 `contract -> ../../packages/contract`。 - -- [ ] **Step 9: 提交** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -git add package.json bunfig.toml tsconfig.base.json packages/ bun.lock -git commit -m "feat(阶段1): monorepo 骨架与 @oj2/contract 契约包" -``` - ---- - -## Task 2: 从本地库生成 Drizzle schema 并剪枝 - -**Files:** -- Create: `apps/api/package.json`、`apps/api/tsconfig.json`、`apps/api/drizzle.config.ts` -- Create: `apps/api/src/db/schema.ts`(由 drizzle-kit 生成后手工剪枝) -- Create: `apps/api/src/db/index.ts` - -**Interfaces:** -- Consumes: 本地 PostgreSQL(`postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge`) -- Produces: `apps/api/src/db/schema.ts` 导出各表定义;`apps/api/src/db/index.ts` 导出 `db` 客户端单例 - -**先决条件**:`docker compose -f docker/compose.dev.yml ps` 显示两个服务都是 healthy。不是的话先 `bun run db:up` 并等健康检查通过。 - -- [ ] **Step 1: 写 `apps/api/package.json`** - -```json -{ - "name": "@oj2/api", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "dev": "bun --watch src/index.ts", - "db:pull": "drizzle-kit pull" - }, - "dependencies": { - "@oj2/contract": "workspace:*", - "drizzle-orm": "^0.45.2", - "hono": "^4.0.0", - "postgres": "^3.4.0", - "zod": "^4.0.0" - }, - "devDependencies": { - "drizzle-kit": "^0.31.10" - } -} -``` - -- [ ] **Step 2: 写 `apps/api/tsconfig.json`** - -```json -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "drizzle.config.ts"] -} -``` - -- [ ] **Step 3: 写 `apps/api/drizzle.config.ts`** - -```typescript -import { defineConfig } from "drizzle-kit" - -export default defineConfig({ - dialect: "postgresql", - schema: "./src/db/schema.ts", - out: "./src/db", - dbCredentials: { - url: process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge", - }, - // Django 框架表不进新后端,introspect 时直接排除 - tablesFilter: ["!django_*", "!auth_*"], -}) -``` - -- [ ] **Step 4: 安装依赖并 introspect** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -bun install -cd apps/api -bun run db:pull -``` - -- [ ] **Step 5: 核对生成结果** - -```bash -cd /home/xuyue/Projects/OJ/OJ2/apps/api -echo "生成的表数: $(grep -c 'pgTable(' src/db/schema.ts)" -grep -oE 'export const \w+ = pgTable\("\w+"' src/db/schema.ts | sed 's/.*pgTable("//' | tr -d '"' | sort | grep -E '^(django_|auth_)' && echo "❌ 仍有框架表" || echo "✅ 无框架表" -``` - -预期: -- 生成的表数 **27**(34 减去 7 张 Django 框架表:`auth_group`、`auth_group_permissions`、`auth_permission`、`django_content_type`、`django_dramatiq_task`、`django_migrations`、`django_session`) -- 输出 `✅ 无框架表` - -若 `tablesFilter` 没生效、27 对不上,改为生成后手工删掉那 7 个 `pgTable` 定义,并在 `schema.ts` 顶部注释写明删了哪些、为什么。 - -- [ ] **Step 6: 确认 `user` 表的两个特殊列被正确生成** - -```bash -grep -nE 'rawPassword|raw_password|sessionKeys|session_keys' src/db/schema.ts -``` - -预期两个都在。`raw_password` 是**有意保留**的明文密码列(教师查学生密码的运维需求,见设计文档 7.1.1),不要因为"看起来不安全"就删掉。`session_keys` 是死字段,但本阶段不动它 —— 删除属于后续阶段的清理工作。 - -- [ ] **Step 7: 写 `apps/api/src/db/index.ts`** - -```typescript -import { drizzle } from "drizzle-orm/postgres-js" -import postgres from "postgres" - -import * as schema from "./schema" - -const url = process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge" - -const client = postgres(url) - -export const db = drizzle(client, { schema }) -export { schema } -``` - -- [ ] **Step 8: 提交** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -git add apps/api bun.lock -git commit -m "feat(阶段1): drizzle schema 从本地库生成并剪掉 Django 框架表" -``` - ---- - -## Task 3: 导入题目样本数据 - -**Files:** -- Create: `docs/specs/sample-data.sql` - -**Interfaces:** -- Produces: 本地库里有若干条真实题目记录,供 Task 4 的接口读取 - -**只导题目和标签,不导用户。** 理由:题目内容(富文本、LaTeX、中文、特殊字符)正是最容易撑爆新后端序列化的东西,必须用真实数据;而学生账号属于个人信息,没有理由离开服务器 —— 本阶段也用不到用户数据。 - -`problem` 表有 `created_by_id` 外键指向 `user`,所以要先造一个占位用户,再导题目。 - -- [ ] **Step 1: 在服务器上导出题目样本** - -登录生产服务器执行(容器名以实际 `docker-compose.yml` 为准): - -```bash -docker exec oj-postgres psql -U onlinejudge -d onlinejudge -c \ - "\copy (SELECT * FROM problem ORDER BY id LIMIT 20) TO STDOUT WITH CSV HEADER" > problems.csv -docker exec oj-postgres psql -U onlinejudge -d onlinejudge -c \ - "\copy (SELECT * FROM problem_tag) TO STDOUT WITH CSV HEADER" > tags.csv -``` - -- [ ] **Step 2: 传回本机** - -```bash -scp <服务器>:~/problems.csv <服务器>:~/tags.csv /tmp/ -``` - -- [ ] **Step 3: 确认没有夹带用户数据** - -```bash -head -1 /tmp/problems.csv -grep -icE 'pbkdf2_sha256|@[a-z]+\.(com|cn|net)' /tmp/problems.csv -``` - -预期:表头是 problem 表的列名;第二条计数为 **0**。非 0 说明导错了表,删掉重来。 - -- [ ] **Step 4: 造占位用户并导入** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -docker cp /tmp/problems.csv oj2-postgres:/tmp/problems.csv -docker cp /tmp/tags.csv oj2-postgres:/tmp/tags.csv -docker exec -i oj2-postgres psql -U onlinejudge -d onlinejudge <<'SQL' --- 占位用户,仅为满足 problem.created_by_id 外键;密码是无意义的固定串 -INSERT INTO "user" (id, password, username, admin_type, problem_permission, open_api, is_disabled, session_keys, raw_password) -VALUES (1, 'unusable', 'devadmin', 'Super Admin', 'All', false, false, '[]'::jsonb, 'devonly') -ON CONFLICT (id) DO NOTHING; - -\copy problem_tag FROM '/tmp/tags.csv' WITH CSV HEADER -\copy problem FROM '/tmp/problems.csv' WITH CSV HEADER -SQL -``` - -- [ ] **Step 5: 核对** - -```bash -docker exec oj2-postgres psql -U onlinejudge -d onlinejudge -tAc \ - "SELECT count(*) || ' 道题, ' || count(DISTINCT difficulty) || ' 种难度' FROM problem" -docker exec oj2-postgres psql -U onlinejudge -d onlinejudge -tAc \ - "SELECT _id || ' | ' || left(title, 30) FROM problem ORDER BY id LIMIT 3" -``` - -预期:能看到 20 道题、若干种难度,以及 3 条真实的题目编号和标题。 - -- [ ] **Step 6: 把导入脚本存档** - -把 Step 4 里的 SQL(不含 CSV 数据本身)写进 `docs/specs/sample-data.sql`,顶部注释说明 CSV 从哪来、为什么不导用户数据。**CSV 文件本身不入库** —— 题目内容是学校的教学资产,没必要进 git。在 `.gitignore` 加一行 `*.csv`。 - -- [ ] **Step 7: 提交** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -git add docs/specs/sample-data.sql .gitignore -git commit -m "chore(阶段1): 题目样本导入脚本(不含用户数据)" -``` - ---- - -## Task 4: Hono 应用与题目列表接口 - -**Files:** -- Create: `apps/api/src/index.ts`、`apps/api/src/routes/problem.ts` - -**Interfaces:** -- Consumes: `db` 与 `schema`(Task 2)、`problemSummarySchema`(Task 1)、样本数据(Task 3) -- Produces: `GET http://localhost:3000/api/problems` 返回题目列表,形如 `{ "data": ProblemSummary[] }` - -**响应格式说明**:阶段 0 已定案 API 重新设计,所以**不要**复刻旧后端的 `{error, data}` 格式。本阶段先用最朴素的 `{ data }`,完整契约留到阶段 3 铺开时定。 - -- [ ] **Step 1: 写 `apps/api/src/routes/problem.ts`** - -```typescript -import { Hono } from "hono" -import { desc } from "drizzle-orm" - -import { problemSummarySchema } from "@oj2/contract" - -import { db, schema } from "../db" - -export const problemRoutes = new Hono() - -problemRoutes.get("/problems", async (c) => { - const rows = await db - .select({ - id: schema.problem.id, - _id: schema.problem.id_, - title: schema.problem.title, - difficulty: schema.problem.difficulty, - submissionNumber: schema.problem.submissionNumber, - acceptedNumber: schema.problem.acceptedNumber, - }) - .from(schema.problem) - .orderBy(desc(schema.problem.id)) - .limit(20) - - // 用契约校验,schema 与实际数据对不上会在这里立刻炸,而不是传到前端才发现 - const data = rows.map((r) => problemSummarySchema.parse(r)) - return c.json({ data }) -}) -``` - -> **注意列名**:`drizzle-kit pull` 会把 `problem._id` 这类下划线开头的列生成成什么标识符,取决于生成结果。**先看 `src/db/schema.ts` 里 `problem` 表的实际字段名**,再照着写上面的 `select`。`submission_number` / `accepted_number` 同理,drizzle 通常转成 camelCase,但以生成结果为准,不要照抄本段。 - -- [ ] **Step 2: 写 `apps/api/src/index.ts`** - -```typescript -import { Hono } from "hono" - -import { problemRoutes } from "./routes/problem" - -const app = new Hono() - -app.get("/health", (c) => c.json({ ok: true })) -app.route("/api", problemRoutes) - -export default { - port: 3000, - fetch: app.fetch, -} -``` - -- [ ] **Step 3: 起服务** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -bun run dev:api -``` - -- [ ] **Step 4: 验证(另开一个终端)** - -```bash -curl -s http://localhost:3000/health -curl -s http://localhost:3000/api/problems | head -c 600 -``` - -预期: -- `/health` 返回 `{"ok":true}` -- `/api/problems` 返回真实题目,能看到中文标题和真实的 `_id` 编号 - -若 Zod 校验报错,说明 `problemSummarySchema` 的字段类型与库里实际类型不符(常见:数字列被 postgres 驱动返回成字符串)。修 schema 或在 select 里转型,**不要**把校验去掉 —— 它在这里炸正是它的价值。 - -- [ ] **Step 5: 提交** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -git add apps/api/src -git commit -m "feat(阶段1): Hono 应用与题目列表接口,读通本地真实数据" -``` - ---- - -## Task 5: 把 ojnext 搬进 apps/web - -**Files:** -- Create: `apps/web/**`(由 `/home/xuyue/Projects/OJ/ojnext` 拷贝) -- Modify: `apps/web/package.json`(改名、接入 workspace) - -**Interfaces:** -- Consumes: `@oj2/contract` -- Produces: workspace `@oj2/web`,`bun run dev:web` 能起 Vite 开发服务器 - -**这是搬运,不是重写。** 37k 行 Vue、217 个文件原样拷过来,本阶段**一行业务代码都不改**。API 层的替换留到阶段 3。 - -- [ ] **Step 1: 拷贝(排除 node_modules、.git、构建产物)** - -```bash -cd /home/xuyue/Projects/OJ -mkdir -p OJ2/apps/web -rsync -a --exclude=node_modules --exclude=.git --exclude=dist --exclude=package-lock.json \ - ojnext/ OJ2/apps/web/ -``` - -`package-lock.json` 排除掉,因为 workspace 统一由根目录的 `bun.lock` 管。 - -- [ ] **Step 2: 确认旧仓库未被动过** - -```bash -cd /home/xuyue/Projects/OJ/ojnext && git status --porcelain | wc -l -``` - -预期:`0`。非 0 说明 rsync 方向写反了,立刻 `git checkout .` 恢复。 - -- [ ] **Step 3: 改 `apps/web/package.json` 的包名** - -把 `"name": "oj-next"` 改成 `"name": "@oj2/web"`,并加上契约包依赖: - -```json - "dependencies": { - "@oj2/contract": "workspace:*", -``` - -其余 38 个依赖和 11 个 devDependencies **一个都不要动**,尤其是 `mermaid-legacy` 之类为兼容旧版 Chrome 存在的 fallback 包。 - -- [ ] **Step 4: 装依赖** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -bun install -``` - -从 npm 换到 bun 解析同一批依赖,可能出现版本漂移。装完看有没有报错或大量警告。 - -- [ ] **Step 5: 确认能构建** - -```bash -cd /home/xuyue/Projects/OJ/OJ2/apps/web -bun run build -``` - -预期:构建成功,产出 `dist/`。这一步是搬运是否成功的**唯一硬指标** —— 38 个依赖在 bun 下能否正常解析、Vite 能否跑通,都在这里见分晓。 - -失败的话不要动业务代码,先看是不是依赖解析问题;实在不行退回用 npm 管 `apps/web`(Bun workspaces 允许某个包单独用别的包管理器,代价是失去统一 lockfile)。 - -- [ ] **Step 6: 确认旧版 Chrome 兼容没被破坏** - -```bash -cd /home/xuyue/Projects/OJ/OJ2/apps/web -grep -nE '"target"|build:\s*\{' vite.config.ts | head -grep -c 'mermaid-legacy' package.json -``` - -预期:`vite.config.ts` 的 build target 与 ojnext 原文件完全一致(可 `diff` 对照 `/home/xuyue/Projects/OJ/ojnext/vite.config.ts`,应无差异);`mermaid-legacy` 计数 ≥ 1。 - -- [ ] **Step 7: 起开发服务器看一眼** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -bun run dev:web -``` - -浏览器打开 `http://localhost:5173`。此时 API 还指向旧后端(`vite.config.ts` 里 `/api` 代理到 `PUBLIC_OJ_URL`),页面可能报接口错误 —— **这是预期的**,本阶段不接线。只要页面框架能渲染出来就算过。 - -- [ ] **Step 8: 提交** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -git add apps/web bun.lock -git commit -m "feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码" -``` - ---- - -## Task 6: 端到端串通 - -**Files:** -- Modify: `apps/web/vite.config.ts`(新增一条指向新 API 的代理) -- Create: `apps/web/src/oj/dev-problems.vue`(临时验证页,阶段 3 会删) -- Modify: `apps/web/src/routes.ts`(挂一条临时路由) - -**Interfaces:** -- Consumes: `GET /api2/problems`(Task 4 的接口,经 Vite 代理)、`ProblemSummary`(Task 1 的类型) - -**这一步的唯一目的是证明链路通了**:本地 PostgreSQL → Drizzle → Hono → 契约校验 → Vite 代理 → Vue 页面。用 `/api2` 前缀而不是 `/api`,是为了不影响现有页面继续指向旧后端。 - -- [ ] **Step 1: 在 `apps/web/vite.config.ts` 的 proxy 里加一条** - -在现有的 `"/api"`、`"/public"`、`"/ws"` 之外新增: - -```typescript - "/api2": { - target: "http://localhost:3000", - changeOrigin: true, - rewrite: (p: string) => p.replace(/^\/api2/, "/api"), - }, -``` - -- [ ] **Step 2: 写临时验证页 `apps/web/src/oj/dev-problems.vue`** - -```vue - - - -``` - -- [ ] **Step 3: 在 `apps/web/src/routes.ts` 挂一条路由** - -照该文件已有的路由写法,加一条 `path: "/dev-problems"` 指向上面这个组件。**照抄文件里现有条目的风格**,不要自创写法。 - -- [ ] **Step 4: 两个服务一起起** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -bun run db:up # 若依赖服务没在跑 -bun run dev # 同时起 api 和 web -``` - -- [ ] **Step 5: 验证** - -浏览器打开 `http://localhost:5173/dev-problems`。 - -预期:页面列出 20 道**真实题目**,中文标题正常显示,编号和通过数都是库里的真实值。 - -这就是阶段 1 的出口标准。看到题目列表出来,说明本地 PostgreSQL → Drizzle → Hono → Zod 契约 → Vite 代理 → Vue 组件这条链路全线打通,且类型从数据库一路贯通到了前端组件(`ProblemSummary` 在 `.vue` 里有完整类型提示)。 - -- [ ] **Step 6: 提交** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 -git add apps/web/vite.config.ts apps/web/src/oj/dev-problems.vue apps/web/src/routes.ts -git commit -m "feat(阶段1): 端到端串通,前端显示本地库真实题目" -``` - ---- - -## 阶段 1 完成标准 - -五项全部满足才算完成: - -- [ ] `bun install` 后 `node_modules/@oj2/contract` 是指向 `packages/contract` 的符号链接 -- [ ] `apps/api/src/db/schema.ts` 有 27 张表,无 `django_*` / `auth_*` -- [ ] `curl http://localhost:3000/api/problems` 返回真实题目数据 -- [ ] `cd apps/web && bun run build` 构建成功,且 `vite.config.ts` 与 ojnext 原文件无差异 -- [ ] 浏览器 `http://localhost:5173/dev-problems` 显示 20 道真实题目 - ---- - -## 自查记录 - -**规格覆盖:** 设计文档第 11 节阶段 1 列的四项 —— 建仓与 Bun workspaces(Task 1)、`drizzle-kit pull` 并剪掉 `django_*`(Task 2)、拷贝 ojnext 进 `apps/web`(Task 5)、出口标准"能从真实库读出一道题"(Task 4 + Task 6)—— 均已覆盖。额外补了 Task 3(样本数据),因为本地库只有结构没有数据,不导样本无法验证出口标准。 - -**与设计文档的偏离:** -1. 出口标准原文是"能从真实库读出一道题",当时假设本机无数据库。现在改为**本地 PostgreSQL 16 + 生产导出的题目样本**,比原设想更强(能在浏览器里端到端看到)。 -2. 不写测试 —— 遵循项目既定策略,用"跑起来看输出"替代。 - -**类型一致性:** `ProblemSummary` 在 Task 1 定义、Task 4 用于后端校验、Task 6 用于前端类型标注,三处同源。`db` / `schema` 在 Task 2 定义、Task 4 消费。Task 4 Step 1 已显式提醒:`select` 的字段名必须以 `drizzle-kit pull` 的实际生成结果为准,不得照抄计划正文。 - -**已知风险:** Task 5 Step 5 的 `bun run build` 是本阶段唯一可能大面积失败的地方 —— ojnext 的 38 个依赖从 npm 换到 bun 解析可能漂移。计划里给了退路(该包单独用 npm 管)。 diff --git a/docs/plans/2026-08-06-phase2-judge-vertical.md b/docs/plans/2026-08-06-phase2-judge-vertical.md deleted file mode 100644 index cf6e89d..0000000 --- a/docs/plans/2026-08-06-phase2-judge-vertical.md +++ /dev/null @@ -1,45 +0,0 @@ -# Phase 2:判题竖线 - -## 出口标准 - -一名学生可以在 OJ2 前端登录、读取公开题、提交代码,并通过 WebSocket 看到真实 JudgeServer 返回的判题结果。 - -## 已实现链路 - -```text -Vue → Hono → PostgreSQL submission → BullMQ/Redis → worker - → QDU JudgeServer → PostgreSQL 事务写回 → Redis pub/sub - → Bun WebSocket → Vue SubmissionMonitor -``` - -- 认证使用 Redis 中的 32 字节随机会话令牌和 `HttpOnly`、`SameSite=Lax` Cookie。 -- 每个受保护请求都重新读取用户表;账号禁用会立即让 HTTP/WS 鉴权失效。 -- 兼容 Django `pbkdf2_sha256`,验证走异步 `node:crypto.pbkdf2`;成功登录后透明改存 Bun `argon2id`。 -- 公开题详情隐藏测试点、答案、AST 规则,只返回模板中的学生可编辑区。 -- BullMQ worker 负责模板拼接、JudgeServer HTTP 调用、C/Python AST 检查、结果聚合和统计事务。 -- WebSocket 在订阅时重放数据库现状,覆盖“判题先完成、浏览器后连上”的竞态;轮询仍作为前端保底。 -- JudgeServer 心跳保留镜像要求的旧 `{ error, data }` 信封,其余新接口使用 `{ data }` / `{ error: { code, message } }`。 - -## 本地启动 - -```bash -docker compose -f docker/compose.dev.yml up -d -bun run seed:dev -bun run dev -``` - -默认开发账号是 `student / student123`,可用 `OJ2_DEV_USERNAME` 和 `OJ2_DEV_PASSWORD` 覆盖。真实测试点放在 `data/test_case/`,该目录只读挂载给 JudgeServer 且不进入 Git。 - -## 验收记录 - -- API 与共享契约 TypeScript 静态检查通过。 -- Vue 生产构建通过,保留 Chrome 90 兼容构建。 -- Django PBKDF2 正确/错误密码兼容检查通过,AST C/Python WASM 加载与规则判定通过。 -- 使用生产样本题 `1004` 的真实测试点完成 AC 和 WA;题目计数、用户提交数和首次 AC 状态在同一事务中更新。 -- WebSocket 实测收到 `pending → judging → finished`,并验证完成后再订阅仍会重放最终结果。 -- 真实浏览器完成登录、读题、编辑、提交并显示“答案正确”。 -- Compose 配置有效,PostgreSQL、Redis 和 JudgeServer 健康检查均通过。 - -## Phase 3 边界 - -本阶段仅切换登录、本人资料、非比赛公开题详情、普通提交与本人提交详情。比赛、题目统计/点评、登录速报、成就、代码格式化等端点继续留给 Phase 3;前端对应实时配置连接暂不启动。 diff --git a/docs/plans/2026-08-28-collab-help-request.md b/docs/plans/2026-08-28-collab-help-request.md deleted file mode 100644 index 33c11a4..0000000 --- a/docs/plans/2026-08-28-collab-help-request.md +++ /dev/null @@ -1,2009 +0,0 @@ -# 课堂求助与协作编辑 实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 学生就某道题发起求助,老师从全局列表接单并进入该学生的编辑器协作改代码;传输从 y-webrtc P2P 换成后端 WebSocket 通道,房间归属与权限由服务端判定。 - -**Architecture:** 新增 `/ws/collab` 通道,登录即连、全局常驻。服务端在内存里维护求助表与房间表,只按房间转发 Yjs 二进制帧、不解析代码内容。房间以**学生**为键 —— 学生是房间归属者,这与「学生的代码是内容源」是同一件事。CRDT 与光标仍用 yjs + y-codemirror.next,只是把 y-webrtc 这层传输换掉。 - -**Tech Stack:** 后端 Bun + Hono + Drizzle(`ws.data.kind` 区分通道,Bun 原生 pub/sub);前端 Vue 3 + Pinia + Naive UI + CodeMirror 6 + yjs / y-codemirror.next。 - -**Spec:** `docs/specs/2026-08-28-collab-help-request-design.md` - -## Global Constraints - -- **不写测试。** 项目约定(`CLAUDE.md`):验证靠实跑。每个任务的收尾是本文写明的手动验证步骤,不是测试用例。 -- **不新增数据库表、不写 drizzle 迁移。** 求助请求只在内存。 -- **单二进制约束**:`apps/api` 用 `bun build --compile` 打包,运行时不能依赖 `node_modules` 里的文件路径解析(`require.resolve` / `__dirname` / `Bun.resolveSync`)。新增代码只用普通 import。 -- **前端要兼容 Chrome < 94**(机房电脑)。不要引入新的语法/API 依赖,不动 vite 的构建 target。 -- **服务端角色常量**:`TEACHER_ROLES = ["Teacher Admin", "Super Admin"]`(`apps/api/src/routes/helpers.ts:74`)。服务端判角色一律读库里的 `adminType`,**不认前端的演示模式**(`demoMode` 是纯 UI 概念)。 -- **老师端永远不插入初始内容** —— 见 Task 7,这是硬规则不是默认值。 -- **直接在 `main` 上提交**,不开特性分支。 -- 提交信息用中文,与仓库现有风格一致。 - ---- - -## 文件结构 - -**后端新增** - -| 文件 | 职责 | -|---|---| -| `apps/api/src/collab/state.ts` | 内存状态:求助表、房间表、在线老师集合。纯数据结构与查询,不碰 socket | -| `apps/api/src/collab/handler.ts` | collab 通道的消息处理、鉴权、广播、二进制转发 | - -**后端修改** - -| 文件 | 改动 | -|---|---| -| `apps/api/src/websocket.ts` | `SubmissionSocketData` 加 collab 所需字段;`kind` 加 `"collab"`;限流分档;open/close/message 分派到 collab handler | -| `apps/api/src/index.ts` | upgrade 分支加 `/ws/collab` | - -**前端新增** - -| 文件 | 职责 | -|---|---| -| `apps/web/src/shared/store/collab.ts` | pinia store:WS 连接、求助列表(老师)、自身求助状态(学生)、当前房间。全局单例 | -| `apps/web/src/shared/composables/collabDoc.ts` | Y.Doc + yCollab 绑定,把 Yjs 帧接到 collab store 的 WS 上 | -| `apps/web/src/shared/components/HelpRequestList.vue` | 顶栏红点 + 下拉列表,含同题聚合 | -| `apps/web/src/shared/components/CollabModal.vue` | 老师端协作模态框 | - -**前端修改** - -| 文件 | 改动 | -|---|---| -| `apps/web/src/shared/composables/websocket.ts` | `BaseWebSocket` 支持二进制收发 | -| `apps/web/src/App.vue` | 挂载全局 collab 连接 | -| `apps/web/src/shared/components/Header.vue` | 嵌入 `HelpRequestList` | -| `apps/web/src/oj/problem/components/Form.vue` | 「开启同步」→「求助 / 取消求助」 | -| `apps/web/src/oj/problem/components/ProblemEditor.vue` | 去掉 syncStatus provide/inject,改读 store | -| `apps/web/src/shared/components/SyncCodeEditor.vue` | 改用 collabDoc | -| `apps/web/src/oj/problem/components/ContestEditor.vue` | 删掉空的 `provideSyncStatus()` | - -**前端删除**:`shared/composables/sync.ts`、`oj/composables/syncStatus.ts` - -两点说明: - -- spec 里这个 composable 叫 `shared/composables/collab.ts`,本计划改名为 `collabDoc.ts` - —— 与 `shared/store/collab.ts` 同名会让 import 路径极易看混。功能不变。 -- 前端配了 `unplugin-auto-import`(`vite.config.ts:101`),**vue / vue-router / pinia - 的 API 全部自动导入**。新文件里不要写 `import { ref, computed, watch } from "vue"` - 或 `import { defineStore } from "pinia"`,现有的 store 与组件都没写。 - ---- -## Task 1: BaseWebSocket 支持二进制帧 - -现有客户端 `onmessage` 无条件 `JSON.parse(event.data)`(websocket.ts:158),`send()` 无条件 -`JSON.stringify`(websocket.ts:325)—— 收到 Yjs 二进制帧会直接落进 catch 打一条「解析消息失败」。 -先给基类开口子,后面的 collab 客户端才能复用它的重连、心跳、`force_logout` 处理。 - -**Files:** -- Modify: `apps/web/src/shared/composables/websocket.ts` - -**Interfaces:** -- Consumes: 无 -- Produces: `BaseWebSocket` 新增 `protected onBinary(data: ArrayBuffer): void`(默认空实现,子类覆盖)与 `sendRaw(data: ArrayBuffer | Uint8Array): boolean` - -- [ ] **Step 1: 连接时声明二进制格式** - -在 `connect()` 里 `new WebSocket(this.url)` 之后、绑定事件之前加一行。找到创建 socket 的那句, -紧跟着加: - -```ts -ws.binaryType = "arraybuffer" -``` - -不加的话浏览器默认给 `Blob`,读取要走异步 `.arrayBuffer()`,Yjs 的消息顺序会乱。 - -- [ ] **Step 2: onmessage 里把二进制岔开** - -把 websocket.ts:155 的 `ws.onmessage` 改成先判类型。原来的整段 JSON 逻辑保持不动,只在最前面插入: - -```ts - ws.onmessage = (event) => { - if (ws !== this.ws) return - - // Yjs 这类二进制帧不是 JSON,交给子类。基类的 pong / force_logout 都是文本帧, - // 不会走到这条路径上 - if (typeof event.data !== "string") { - this.onBinary(event.data as ArrayBuffer) - return - } - - try { - const data = JSON.parse(event.data) as T - // ...以下原样保留... -``` - -- [ ] **Step 3: 加 onBinary 钩子与 sendRaw** - -在 `send(data: any)`(websocket.ts:325)旁边加两个成员: - -```ts - /** - * 二进制帧钩子。基类不认二进制,默认丢弃;collab 这类通道在子类里覆盖。 - * 和 onMessage 对称,不要在这里做 JSON 解析。 - */ - protected onBinary(_data: ArrayBuffer) {} - - /** - * 不做 JSON 序列化的发送。Yjs 的 update / awareness 本身就是 Uint8Array, - * 走 send() 会被 JSON.stringify 成一个 {"0":12,"1":3,...} 的对象。 - */ - sendRaw(data: ArrayBuffer | Uint8Array) { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(data) - return true - } - return false - } -``` - -- [ ] **Step 4: 验证没有回归** - -```bash -bun run dev -``` - -浏览器打开 `http://localhost:5173`,登录后: -1. 打开 devtools Network → WS,确认 `/ws/config` 与 `/ws/submissions` 照常连上、心跳 pong 正常 -2. 提交一份代码,确认判题状态照常实时刷新(走 `/ws/submissions`) -3. Console 里不应出现 `[WebSocket] 解析消息失败` - -- [ ] **Step 5: 提交** - -```bash -git add apps/web/src/shared/composables/websocket.ts -git commit -m "feat(web): BaseWebSocket 支持二进制帧收发 - -onmessage 原来无条件 JSON.parse,收到 Yjs 二进制帧会落进 catch。 -加 onBinary 钩子与 sendRaw,让 collab 通道能复用基类的重连与心跳。" -``` - ---- - -## Task 2: 后端 collab 通道 —— 求助表与控制面 - -只做控制面:学生发起/撤销求助,老师收到列表。房间与二进制转发在 Task 3。 - -**Files:** -- Create: `apps/api/src/collab/state.ts` -- Create: `apps/api/src/collab/handler.ts` -- Modify: `apps/api/src/websocket.ts` -- Modify: `apps/api/src/index.ts` - -**Interfaces:** -- Consumes: `SubmissionSocketData`(websocket.ts:52)、`TEACHER_ROLES`(routes/helpers.ts:74)、`touchSession`(auth/session.ts:161) -- Produces: - - `state.ts`: `HelpRequest`、`CollabSocket` 类型;`addRequest`、`removeRequest`、`getRequest`、`listRequests`、`queueAheadOf`、`addTeacher`、`removeTeacher`、`hasTeacherOnline`、`teacherSockets` - - `handler.ts`: `handleCollabOpen(ws)`、`handleCollabClose(ws)`、`handleCollabMessage(ws, raw)`、`handleCollabBinary(ws, data)`(Task 3 才填实现)、`broadcastRequests()` - -- [ ] **Step 1: 写 state.ts** - -Create `apps/api/src/collab/state.ts`: - -```ts -/** - * 课堂求助的内存状态。 - * - * 不落库是有意的:求助是课堂上的即时行为,学生关掉页面这条请求就该消失。 - * 服务端只有一个 serve 进程(main.ts 单二进制 + 子命令,compose 里 oj-api 一个容器), - * 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。 - */ - -export type CollabSocket = Bun.ServerWebSocket - -export interface HelpRequest { - studentId: number - studentName: string - className: string | null - /** 题目的展示号(problem._id 列,前端一路用的都是它),不是自增主键 */ - problemId: string - problemTitle: string - createdAt: number - status: "pending" | "active" - teacherId?: number - teacherName?: string - socket: CollabSocket -} - -/** 求助表,以学生为键 —— 一个学生同时只有一个求助 */ -const requests = new Map() - -/** 在线老师的连接。用于推列表,也用于判断 no_teacher */ -const teachers = new Set() - -export function addRequest(request: HelpRequest) { - requests.set(request.studentId, request) -} - -export function getRequest(studentId: number) { - return requests.get(studentId) -} - -export function removeRequest(studentId: number) { - return requests.delete(studentId) -} - -export function hasRequest(studentId: number) { - return requests.has(studentId) -} - -/** 按发起时间正序。老师端按等待时长排序展示,不强制先来先到 */ -export function listRequests() { - return Array.from(requests.values()).sort((a, b) => a.createdAt - b.createdAt) -} - -/** 比自己早创建、且仍在排队的请求数 */ -export function queueAheadOf(studentId: number) { - const self = requests.get(studentId) - if (!self) return 0 - let ahead = 0 - for (const request of requests.values()) { - if (request.status === "pending" && request.createdAt < self.createdAt) ahead += 1 - } - return ahead -} - -export function addTeacher(ws: CollabSocket) { - teachers.add(ws) -} - -export function removeTeacher(ws: CollabSocket) { - teachers.delete(ws) -} - -export function hasTeacherOnline() { - return teachers.size > 0 -} - -export function teacherSockets() { - return teachers -} - -/** 仅供进程退出或测试用,正常路径不该调 */ -export function resetCollabState() { - requests.clear() - teachers.clear() -} -``` - -- [ ] **Step 2: 扩展 SubmissionSocketData** - -Modify `apps/api/src/websocket.ts:52`,给接口加三个字段(`kind` 加一个取值): - -```ts -export interface SubmissionSocketData { - userId: number - /** 同一个 Bun.serve 只能挂一个 websocket handler,用它区分通道 */ - kind: "submissions" | "config" | "collab" - /** 握手时那张会话的 token,留着定期确认它还没被登出 / 过期,见 sweepSessions */ - token: string - /** 令牌桶,open 时初始化,见 allowMessage */ - rate?: { tokens: number; updatedAt: number } - /** 以下两项只有 kind === "collab" 时才填,握手时从会话里读 */ - username?: string - adminType?: string - /** 当前所在协作房间的房主(学生)id,见 collab/handler.ts */ - roomOwnerId?: number -} -``` - -- [ ] **Step 3: 限流分档** - -collab 的二进制帧是每敲一个字一条,现有的 20 突发 / 每秒 2 个(websocket.ts:69-70)几秒就会 -`ws.close(1008)` 把人踢掉。在 `RATE_REFILL_PER_SECOND` 下面加一组常量: - -```ts -/** - * collab 通道的二进制帧(Yjs update / awareness)单独一档。 - * - * 它不查库、不解析,纯内存按房间转发,成本和文本控制帧完全不是一个量级; - * 而连续快速输入大约 5-10 帧/秒,用严格档几秒钟就会把正在协作的人踢下线。 - */ -const COLLAB_BINARY_BURST = 200 -const COLLAB_BINARY_REFILL_PER_SECOND = 100 -``` - -把 `allowMessage` 改成接受档位参数: - -```ts -function allowMessage( - ws: Bun.ServerWebSocket, - burst = RATE_BURST, - refillPerSecond = RATE_REFILL_PER_SECOND, -) { - const now = Date.now() - const rate = (ws.data.rate ??= { tokens: burst, updatedAt: now }) - const refill = ((now - rate.updatedAt) / 1000) * refillPerSecond - rate.tokens = Math.min(burst, rate.tokens + refill) - rate.updatedAt = now - if (rate.tokens < 1) return false - rate.tokens -= 1 - return true -} -``` - -注意:令牌桶是**每条连接一个**,而 collab 连接上文本帧很稀疏(求助、接单各一条), -所以两档共用同一个桶不会互相饿死 —— 二进制帧把桶按宽松档填,文本帧按严格档取, -实际效果是 collab 连接整体走宽松档。这是可以接受的:这条连接上的文本帧同样不查库 -(`accept` 会查,但它一次会话只发一次)。 - -- [ ] **Step 4: 在 handler 里分派 collab** - -Modify `apps/api/src/websocket.ts` 的 `submissionWebSocketHandler()`。在 `open`、`message`、 -`close` 三处加 collab 分支(放在各自函数最前面,collab 不订阅 submission/config 的 topic): - -```ts - open(ws) { - liveSockets.add(ws) - ws.data.rate = { tokens: RATE_BURST, updatedAt: Date.now() } - if (ws.data.kind === "collab") { - handleCollabOpen(ws) - return - } - if (ws.data.kind === "config") { - ws.subscribe(configTopic) - return - } - ws.subscribe(userSubmissionTopic(ws.data.userId)) - ws.subscribe(userEventTopic(ws.data.userId)) - }, - message(ws, message) { - if (ws.data.kind === "collab") { - if (typeof message !== "string") { - if (!allowMessage(ws, COLLAB_BINARY_BURST, COLLAB_BINARY_REFILL_PER_SECOND)) { - ws.close(1008, "Too many messages") - return - } - handleCollabBinary(ws, message) - return - } - if (!allowMessage(ws)) { - ws.close(1008, "Too many messages") - return - } - handleCollabMessage(ws, message).catch((error) => { - console.error("Failed to handle collab message", error) - ws.send(JSON.stringify({ type: "error", message: "Internal error" })) - }) - return - } - // ...以下原有逻辑原样保留... - }, - close(ws) { - liveSockets.delete(ws) - if (ws.data.kind === "collab") { - handleCollabClose(ws) - return - } - // ...以下原有逻辑原样保留... - }, -``` - -文件顶部加 import: - -```ts -import { - handleCollabBinary, - handleCollabClose, - handleCollabMessage, - handleCollabOpen, -} from "./collab/handler" -``` - -- [ ] **Step 5: 写 handler.ts 的控制面** - -Create `apps/api/src/collab/handler.ts`: - -```ts -import { and, eq, isNull } from "drizzle-orm" - -import { touchSession } from "../auth/session" -import { db, schema } from "../db" -import { - addRequest, - addTeacher, - getRequest, - hasTeacherOnline, - listRequests, - queueAheadOf, - removeRequest, - removeTeacher, - teacherSockets, - type CollabSocket, - type HelpRequest, -} from "./state" - -const TEACHER_ROLES = ["Teacher Admin", "Super Admin"] - -function isTeacher(ws: CollabSocket) { - return TEACHER_ROLES.includes(ws.data.adminType ?? "") -} - -/** 推给老师的列表条目。不含 socket,也不含任何代码内容 */ -function serializeRequest(request: HelpRequest) { - return { - studentId: request.studentId, - studentName: request.studentName, - className: request.className, - problemId: request.problemId, - problemTitle: request.problemTitle, - createdAt: request.createdAt, - status: request.status, - teacherName: request.teacherName ?? null, - } -} - -export function broadcastRequests() { - const payload = JSON.stringify({ - type: "requests", - list: listRequests().map(serializeRequest), - }) - for (const ws of teacherSockets()) ws.send(payload) -} - -function sendHelpStatus( - ws: CollabSocket, - status: "pending" | "active" | "cancelled" | "no_teacher", - extra: Record = {}, -) { - ws.send(JSON.stringify({ type: "help_status", status, ...extra })) -} - -export function handleCollabOpen(ws: CollabSocket) { - if (isTeacher(ws)) { - addTeacher(ws) - // 新上线的老师要立刻看到当前队列,不能等下一次变更 - ws.send( - JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }), - ) - } -} - -export function handleCollabClose(ws: CollabSocket) { - if (isTeacher(ws)) removeTeacher(ws) - // 房间与请求的清理在 Task 3 补 -} - -export async function handleCollabMessage(ws: CollabSocket, raw: string) { - let message: { type?: unknown; problemId?: unknown; studentId?: unknown } - try { - message = JSON.parse(raw) as typeof message - } catch { - ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })) - return - } - - // 心跳不查库,和 /ws/submissions 的处理一致 - if (message.type === "ping") { - ws.send(JSON.stringify({ type: "pong", timestamp: (message as any).timestamp })) - return - } - - // 握手时校验过一次不算数 —— 这条连接能挂几个小时 - if (!(await touchSession(ws.data.token))) { - ws.close(1008, "Session expired") - return - } - - switch (message.type) { - case "help_request": - await handleHelpRequest(ws, message.problemId) - return - case "help_cancel": - handleHelpCancel(ws) - return - default: - ws.send(JSON.stringify({ type: "error", message: "Invalid message" })) - } -} - -async function handleHelpRequest(ws: CollabSocket, problemId: unknown) { - if (typeof problemId !== "string" || !problemId) { - ws.send(JSON.stringify({ type: "error", message: "Invalid problemId" })) - return - } - if (isTeacher(ws)) { - ws.send(JSON.stringify({ type: "error", message: "教师不能发起求助" })) - return - } - if (!hasTeacherOnline()) { - sendHelpStatus(ws, "no_teacher") - return - } - - // 只认非比赛题:contest_id 为空的那条。比赛题不提供求助 - const [problem] = await db - .select({ title: schema.problem.title }) - .from(schema.problem) - .where( - and( - eq(schema.problem.displayId, problemId), - isNull(schema.problem.contestId), - ), - ) - .limit(1) - if (!problem) { - ws.send(JSON.stringify({ type: "error", message: "题目不存在或不支持求助" })) - return - } - - const existing = getRequest(ws.data.userId) - // 已经在协作中就不重复登记,否则会把正在进行的房间挤掉 - if (existing?.status === "active") return - - addRequest({ - studentId: ws.data.userId, - studentName: ws.data.username ?? "", - className: null, - problemId, - problemTitle: problem.title, - createdAt: Date.now(), - status: "pending", - socket: ws, - }) - sendHelpStatus(ws, "pending", { queueAhead: queueAheadOf(ws.data.userId) }) - broadcastRequests() -} - -function handleHelpCancel(ws: CollabSocket) { - const request = getRequest(ws.data.userId) - if (!request || request.status === "active") return - removeRequest(ws.data.userId) - broadcastRequests() -} -``` - -`className` 先填 `null`:它在 `user` 表上(schema.ts:663),握手时一并读出来更省事, -下一步就补。 - -- [ ] **Step 6: 握手时带上 username / adminType / className** - -`getRequestSessionUser`(auth/session.ts:142)返回的 user 已含 `adminType`(session.ts:106)。 -Modify `apps/api/src/index.ts:103` 那段 upgrade 分支: - -```ts - if ( - url.pathname === "/ws/submissions" || - url.pathname === "/ws/config" || - url.pathname === "/ws/collab" - ) { - if (!isAllowedWebSocketOrigin(request.headers.get("origin"), url)) { - return new Response("Forbidden", { status: 403 }) - } - const user = await getRequestSessionUser(request) - if (!user) return new Response("Unauthorized", { status: 401 }) - const kind = - url.pathname === "/ws/config" - ? "config" - : url.pathname === "/ws/collab" - ? "collab" - : "submissions" - if ( - bunServer.upgrade(request, { - data: { - userId: user.id, - kind, - token: readRequestSessionToken(request), - username: user.username, - adminType: user.adminType, - }, - }) - ) { - return undefined - } - return new Response("WebSocket upgrade failed", { status: 400 }) - } -``` - -`className` 不在会话里,在 `handleHelpRequest` 里和题目一起查出来。把上一步 state 写入 -改成: - -```ts - const [student] = await db - .select({ className: schema.user.className }) - .from(schema.user) - .where(eq(schema.user.id, ws.data.userId)) - .limit(1) - - addRequest({ - studentId: ws.data.userId, - studentName: ws.data.username ?? "", - className: student?.className ?? null, - // ...其余不变 - }) -``` - -- [ ] **Step 7: 验证控制面** - -```bash -cd /home/xuyue/Projects/OJ/OJ2 && bun run dev -``` - -开两个浏览器 profile。**A 用教师账号登录,B 用学生账号登录**(`.env` 里的 -`OJ2_DEV_USERNAME=student`)。两边 devtools Console 各跑: - -```js -const ws = new WebSocket(`ws://localhost:5173/ws/collab`) -ws.onmessage = (e) => console.log("recv", e.data) -``` - -然后: -1. **A(教师)**连上后应立刻收到一条 `{"type":"requests","list":[]}` -2. **B(学生)**发 `ws.send(JSON.stringify({type:"help_request",problemId:"1"}))` - (`problemId` 换成一道真实存在的非比赛题的展示号) - - B 收到 `{"type":"help_status","status":"pending","queueAhead":0}` - - A 收到 `requests`,list 里有一条,含 `studentName`、`problemTitle`、`className` -3. **B** 发 `{"type":"help_cancel"}` → A 收到空 list -4. **B** 发 `{"type":"help_request",problemId:"不存在的号"}` → 收到 `题目不存在或不支持求助` -5. **A(教师)**发 `help_request` → 收到 `教师不能发起求助` -6. 关掉 A 的连接,B 再发 `help_request` → 收到 `{"status":"no_teacher"}` -7. B 连着不动 60 秒以上,确认没有被限流踢掉(`allowMessage` 改动没写坏严格档) - -- [ ] **Step 8: 提交** - -```bash -git add apps/api/src/collab apps/api/src/websocket.ts apps/api/src/index.ts -git commit -m "feat(api): 新增 /ws/collab 通道与课堂求助控制面 - -学生发起/撤销求助,在线教师收到全量列表。求助只在内存,不落库。 -二进制帧的限流单独一档,避免协作输入把连接踢掉。" -``` - ---- -## Task 3: 后端房间与二进制转发 - -**Files:** -- Modify: `apps/api/src/collab/state.ts` -- Modify: `apps/api/src/collab/handler.ts` - -**Interfaces:** -- Consumes: Task 2 的全部导出 -- Produces: - - `state.ts`: `Room` 类型;`openRoom`、`getRoom`、`closeRoom`、`roomOf` - - `handler.ts`: `handleCollabBinary` 的实现;`accept` / `reject` / `leave` 三个消息的处理 - -- [ ] **Step 1: state.ts 加房间表** - -在 `apps/api/src/collab/state.ts` 末尾(`resetCollabState` 之前)加: - -```ts -export interface Room { - /** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */ - studentId: number - teacherId: number - studentSocket: CollabSocket - teacherSocket: CollabSocket - problemId: string -} - -const rooms = new Map() - -export function openRoom(room: Room) { - rooms.set(room.studentId, room) -} - -export function getRoom(studentId: number) { - return rooms.get(studentId) -} - -export function closeRoom(studentId: number) { - return rooms.delete(studentId) -} - -/** 这条连接当前所在的房间。ws.data.roomOwnerId 是房主(学生)的 id */ -export function roomOf(ws: CollabSocket) { - const ownerId = ws.data.roomOwnerId - return ownerId === undefined ? undefined : rooms.get(ownerId) -} -``` - -把 `resetCollabState` 补上 `rooms.clear()`。 - -- [ ] **Step 2: handler.ts 加 accept / reject / leave** - -在 `handleCollabMessage` 的 switch 里补三个分支: - -```ts - case "accept": - await handleAccept(ws, message.studentId) - return - case "reject": - handleReject(ws, message.studentId) - return - case "leave": - handleLeave(ws) - return -``` - -然后加这四个函数: - -```ts -async function handleAccept(ws: CollabSocket, studentId: unknown) { - if (!isTeacher(ws)) { - ws.send(JSON.stringify({ type: "error", message: "无权限" })) - return - } - if (typeof studentId !== "number") { - ws.send(JSON.stringify({ type: "error", message: "Invalid studentId" })) - return - } - - // 握手时的 adminType 是那一刻的快照,接单前按库里的真实身份复核一次。 - // 注意读的是库,不是前端传的任何东西 —— 前端的演示模式在这里没有意义 - const [teacher] = await db - .select({ adminType: schema.user.adminType }) - .from(schema.user) - .where(and(eq(schema.user.id, ws.data.userId), eq(schema.user.isDisabled, false))) - .limit(1) - if (!teacher || !TEACHER_ROLES.includes(teacher.adminType)) { - ws.close(1008, "Permission revoked") - return - } - - // 老师同时只能在一个房间 - if (roomOf(ws)) { - ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" })) - return - } - - const request = getRequest(studentId) - if (!request || request.status === "active") { - // 被别人接走了或者学生已经撤销 —— 回一份最新列表让老师端自己纠正 - ws.send( - JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }), - ) - return - } - - request.status = "active" - request.teacherId = ws.data.userId - request.teacherName = ws.data.username ?? "" - - ws.data.roomOwnerId = studentId - request.socket.data.roomOwnerId = studentId - openRoom({ - studentId, - teacherId: ws.data.userId, - studentSocket: request.socket, - teacherSocket: ws, - problemId: request.problemId, - }) - - const openFrame = (peerName: string, peerRole: "student" | "teacher") => - JSON.stringify({ - type: "room_open", - peer: { name: peerName, role: peerRole }, - problemId: request.problemId, - }) - request.socket.send(openFrame(request.teacherName, "teacher")) - ws.send(openFrame(request.studentName, "student")) - sendHelpStatus(request.socket, "active", { teacherName: request.teacherName }) - broadcastRequests() -} - -function handleReject(ws: CollabSocket, studentId: unknown) { - if (!isTeacher(ws) || typeof studentId !== "number") return - const request = getRequest(studentId) - // 已经在协作中的不能靠 reject 掐掉,那是 leave 的事 - if (!request || request.status === "active") return - removeRequest(studentId) - sendHelpStatus(request.socket, "cancelled") - broadcastRequests() -} - -/** 主动退出房间。老师点关闭、学生点结束都走这里 */ -function handleLeave(ws: CollabSocket) { - const room = roomOf(ws) - if (!room) return - teardownRoom(room, "done") -} - -/** - * 拆房间。reason 决定两端看到什么: - * done —— 有人主动结束,双方都收到,请求一并清除 - * peer_offline —— 有人断线,见 handleCollabClose - */ -function teardownRoom(room: Room, reason: "done" | "peer_offline") { - closeRoom(room.studentId) - room.studentSocket.data.roomOwnerId = undefined - room.teacherSocket.data.roomOwnerId = undefined - const frame = JSON.stringify({ type: "room_closed", reason }) - room.studentSocket.send(frame) - room.teacherSocket.send(frame) - if (reason === "done") removeRequest(room.studentId) - broadcastRequests() -} -``` - -`state.ts` 的新导出要加进顶部 import:`openRoom`、`closeRoom`、`roomOf`、`type Room`。 -(`getRoom` 本任务用不到,别顺手加进去。) - -- [ ] **Step 3: 断线处理** - -把 Task 2 里那个占位的 `handleCollabClose` 换成完整实现: - -```ts -export function handleCollabClose(ws: CollabSocket) { - if (isTeacher(ws)) removeTeacher(ws) - - const room = roomOf(ws) - if (room) { - closeRoom(room.studentId) - room.studentSocket.data.roomOwnerId = undefined - room.teacherSocket.data.roomOwnerId = undefined - const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket - peer.send(JSON.stringify({ type: "room_closed", reason: "peer_offline" })) - - if (ws === room.teacherSocket) { - // 老师掉线:请求退回排队,学生不必重新点 —— 可能只是网络抖了一下 - const request = getRequest(room.studentId) - if (request) { - request.status = "pending" - request.teacherId = undefined - request.teacherName = undefined - sendHelpStatus(request.socket, "pending", { - queueAhead: queueAheadOf(room.studentId), - }) - } - } else { - // 学生掉线:请求随人走 - removeRequest(room.studentId) - } - } else if (!isTeacher(ws)) { - // 还在排队时关掉页面,请求也该消失 - removeRequest(ws.data.userId) - } - - broadcastRequests() -} -``` - -- [ ] **Step 4: 二进制转发** - -```ts -/** - * Yjs 的 update / awareness 帧。服务端不解析、不留存,只转发给房间里的另一个人。 - * - * 「服务端不知道代码内容」是有意的:这个通道要做的事只有认证和分房间, - * 权限由 accept 时的库查询决定,与帧里装的是什么无关。 - */ -export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array) { - const room = roomOf(ws) - if (!room) return - const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket - peer.send(data) -} -``` - -- [ ] **Step 5: 验证房间与转发** - -`bun run dev`,仍用两个 profile 的 Console(教师 A / 学生 B): - -```js -const ws = new WebSocket(`ws://localhost:5173/ws/collab`) -ws.binaryType = "arraybuffer" -ws.onmessage = (e) => - console.log("recv", typeof e.data === "string" ? e.data : new Uint8Array(e.data)) -``` - -1. B 发 `help_request` → A 收到列表 -2. A 发 `{"type":"accept","studentId":}` - - 双方各收到一条 `room_open`,`peer.name` 分别是对方的用户名 - - B 另收到 `{"type":"help_status","status":"active","teacherName":"..."}` - - A 再收到 `requests`,那条的 `status` 变成 `active` -3. A 发二进制:`ws.send(new Uint8Array([1,2,3]))` → **B** 收到 `Uint8Array(3) [1,2,3]`;反向同样 -4. A 再发一次 `accept`(换个学生)→ 收到 `请先退出当前协作` -5. A 发 `{"type":"leave"}` → 双方收到 `{"reason":"done"}`,A 的列表变空 -6. 重新 accept 后**关掉 A 的标签页** → B 收到 `{"reason":"peer_offline"}`, - 再开一个教师连接,列表里那条应回到 `pending` -7. 重新 accept 后**关掉 B 的标签页** → A 收到 `peer_offline`,且列表里那条消失 -8. 未在房间里时发二进制 → 无任何转发、不报错 -9. 用学生账号发 `{"type":"accept","studentId":1}` → 收到 `无权限` -10. 连续快速发 100 条二进制帧,确认连接没被 1008 踢掉(限流宽松档生效) - -- [ ] **Step 6: 提交** - -```bash -git add apps/api/src/collab -git commit -m "feat(api): collab 房间管理与 Yjs 帧转发 - -accept 时按库里的 adminType 复核身份,房间以学生为键。 -服务端只按房间转发二进制帧,不解析内容。 -老师掉线请求退回排队,学生掉线请求随人清除。" -``` - ---- -## Task 4: 前端 collab store 与 WS 客户端 - -**Files:** -- Modify: `apps/web/src/shared/composables/websocket.ts`(加 `CollabWebSocket` 类) -- Create: `apps/web/src/shared/store/collab.ts` -- Modify: `apps/web/src/App.vue` - -**Interfaces:** -- Consumes: Task 1 的 `onBinary` / `sendRaw`;Task 2、3 的服务端协议 -- Produces: `useCollabStore()`,导出 - `connect()`、`disconnect()`、`requestHelp(problemId: string)`、`cancelHelp()`、 - `accept(studentId: number)`、`reject(studentId: number)`、`leave()`、 - `sendBinary(data: Uint8Array)`、`setBinaryHandler(fn: ((data: ArrayBuffer) => void) | null)`, - 以及只读状态 `requests`、`helpStatus`、`queueAhead`、`teacherName`、`room` - -- [ ] **Step 1: 加 CollabWebSocket 类** - -在 `apps/web/src/shared/composables/websocket.ts` 末尾(`useConfigWebSocket` 之后)加: - -```ts -export interface CollabRequestItem { - studentId: number - studentName: string - className: string | null - problemId: string - problemTitle: string - createdAt: number - status: "pending" | "active" - teacherName: string | null -} - -export interface CollabMessage extends WebSocketMessage { - type: - | "requests" - | "help_status" - | "room_open" - | "room_closed" - | "error" -} - -/** - * 课堂求助 / 协作通道。和另外两条的区别是它**双向**且**收发二进制** —— - * 控制面是 JSON,Yjs 的 update / awareness 走 sendRaw 与 onBinary。 - */ -export class CollabWebSocket extends BaseWebSocket { - private binaryHandler: ((data: ArrayBuffer) => void) | null = null - - constructor() { - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:" - super({ url: `${protocol}//${window.location.host}/ws/collab` }) - } - - setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) { - this.binaryHandler = handler - } - - protected override onBinary(data: ArrayBuffer) { - this.binaryHandler?.(data) - } -} -``` - -- [ ] **Step 2: 写 store** - -Create `apps/web/src/shared/store/collab.ts`: - -```ts -import { - CollabWebSocket, - type CollabMessage, - type CollabRequestItem, -} from "shared/composables/websocket" -import { useUserStore } from "shared/store/user" - -export type HelpStatus = "idle" | "pending" | "active" - -export interface RoomInfo { - peerName: string - peerRole: "student" | "teacher" - problemId: string -} - -/** - * 课堂求助的全局状态。 - * - * 连接是**全局常驻**的,不跟着题目页起落 —— 老师可能正在后台改题时收到求助, - * 学生也需要在等待期间一直挂着。所以这里不用 onUnmounted,由 App.vue 按登录态开关。 - */ -export const useCollabStore = defineStore("collab", () => { - const userStore = useUserStore() - - const ws = new CollabWebSocket() - - /** 老师端:待处理列表 */ - const requests = ref([]) - /** 学生端:自己的求助状态 */ - const helpStatus = ref("idle") - const queueAhead = ref(0) - const teacherName = ref("") - /** 双方:当前房间。null 表示不在协作中 */ - const room = ref(null) - /** 一次性提示,由组件消费后清空 */ - const notice = ref("") - - const pendingCount = computed( - () => requests.value.filter((it) => it.status === "pending").length, - ) - - /** 按题目聚合,同题多人时老师能一眼看出该停下来全班讲 */ - const groupedRequests = computed(() => { - const groups = new Map() - for (const item of requests.value) { - const group = groups.get(item.problemId) - if (group) group.items.push(item) - else - groups.set(item.problemId, { - problemId: item.problemId, - problemTitle: item.problemTitle, - items: [item], - }) - } - // 人多的题排前面;人数相同按最久等待排 - return Array.from(groups.values()).sort( - (a, b) => - b.items.length - a.items.length || - a.items[0].createdAt - b.items[0].createdAt, - ) - }) - - const handleMessage = (data: CollabMessage) => { - switch (data.type) { - case "requests": - requests.value = (data.list ?? []) as CollabRequestItem[] - return - case "help_status": - if (data.status === "pending") { - helpStatus.value = "pending" - queueAhead.value = Number(data.queueAhead ?? 0) - } else if (data.status === "active") { - helpStatus.value = "active" - teacherName.value = String(data.teacherName ?? "") - } else if (data.status === "cancelled") { - helpStatus.value = "idle" - notice.value = "老师已取消你的求助" - } else if (data.status === "no_teacher") { - helpStatus.value = "idle" - notice.value = "当前没有老师在线" - } - return - case "room_open": - room.value = { - peerName: String(data.peer?.name ?? ""), - peerRole: data.peer?.role === "teacher" ? "teacher" : "student", - problemId: String(data.problemId ?? ""), - } - return - case "room_closed": - room.value = null - // 老师掉线时服务端会另发一条 help_status:pending,这里不抢着改学生状态 - if (data.reason === "done") helpStatus.value = "idle" - notice.value = - data.reason === "peer_offline" ? "对方已断开连接" : "协作已结束" - return - case "error": - notice.value = String(data.message ?? "") - return - } - } - - ws.addHandler(handleMessage) - - function connect() { - ws.connect() - } - - function disconnect() { - ws.disconnect() - requests.value = [] - helpStatus.value = "idle" - room.value = null - } - - function requestHelp(problemId: string) { - ws.send({ type: "help_request", problemId }) - } - - function cancelHelp() { - ws.send({ type: "help_cancel" }) - helpStatus.value = "idle" - } - - function accept(studentId: number) { - ws.send({ type: "accept", studentId }) - } - - function reject(studentId: number) { - ws.send({ type: "reject", studentId }) - } - - function leave() { - ws.send({ type: "leave" }) - } - - function sendBinary(data: Uint8Array) { - ws.sendRaw(data) - } - - function setBinaryHandler(handler: ((data: ArrayBuffer) => void) | null) { - ws.setBinaryHandler(handler) - } - - function consumeNotice() { - const value = notice.value - notice.value = "" - return value - } - - return { - requests, - pendingCount, - groupedRequests, - helpStatus, - queueAhead, - teacherName, - room, - notice, - isTeacher: computed(() => userStore.isTeacherOrAbove), - connect, - disconnect, - requestHelp, - cancelHelp, - accept, - reject, - leave, - sendBinary, - setBinaryHandler, - consumeNotice, - } -}) -``` - -- [ ] **Step 3: App.vue 挂全局连接** - -在 `apps/web/src/App.vue` 的 `useConfigUpdate()` / `useMaxKB()` 附近加: - -```ts -import { useCollabStore } from "shared/store/collab" - -const collabStore = useCollabStore() - -// 课堂求助通道。和 /ws/config 一样是全局常驻的:老师可能正在后台改题时 -// 收到求助,学生也要在排队期间一直挂着,所以不放在题目页里起落 -watch( - () => userStore.isAuthed, - (isAuthed) => { - if (isAuthed) collabStore.connect() - else collabStore.disconnect() - }, - { immediate: true }, -) -``` - -- [ ] **Step 4: 验证** - -`bun run dev`,两个 profile 登录后: - -1. devtools Network → WS 里能看到 `/ws/collab` 连上,且**任何页面**(首页、后台、题目页)都在 -2. 教师端 Console:`useCollabStore` 不好直接取,改在 Vue devtools 里看 pinia 的 `collab` store - —— `requests` 初始为空数组 -3. 学生端 Console 手动发一条求助(借用上一个任务的裸 WS 方式即可),教师端 store 的 - `requests` 应实时出现一条,`pendingCount` 变 1 -4. 学生登出 → 连接断开;重新登录 → 自动重连 -5. 停掉 api(`Ctrl-C`)再起来 → 前端应自动重连(`BaseWebSocket` 默认无限重连) - -- [ ] **Step 5: 提交** - -```bash -git add apps/web/src/shared/composables/websocket.ts apps/web/src/shared/store/collab.ts apps/web/src/App.vue -git commit -m "feat(web): 课堂求助 store 与全局 collab 连接 - -连接全局常驻,不跟题目页起落 —— 老师在任何页面都要能收到求助。 -列表按题目聚合,同题多人时能一眼看出该全班讲。" -``` - ---- - -## Task 5: 学生端求助按钮 - -**Files:** -- Modify: `apps/web/src/oj/problem/components/Form.vue` -- Modify: `apps/web/src/oj/problem/components/ProblemEditor.vue` - -**Interfaces:** -- Consumes: Task 4 的 `useCollabStore()` -- Produces: 学生能从题目页发起与撤销求助;`Form.vue` 不再 emit `toggleSync`,也不再 `injectSyncStatus` - -- [ ] **Step 1: Form.vue 换掉同步按钮** - -删掉这些 import 与状态: - -```ts -import { injectSyncStatus } from "oj/composables/syncStatus" -import { SYNC_MESSAGES } from "shared/composables/sync" -// ... -const syncStatus = injectSyncStatus() -const syncEnabled = ref(false) -``` - -以及 `emit` 里的 `toggleSync`、`toggleSync()` 函数、`defineExpose({ resetSyncStatus })`、 -`Props` 里的 `isConnected`。 - -换成: - -```ts -import { useCollabStore } from "shared/store/collab" - -const collabStore = useCollabStore() - -// 可见条件沿用原来的 showSyncFeature,再加上「不是教师」—— -// 教师端的入口在顶栏,不在题目页 -const showHelpButton = computed( - () => - isDesktop.value && - userStore.isAuthed && - !userStore.isTeacherOrAbove && - codeStore.code.language !== "Flowchart" && - !isContestMode.value, -) - -const helpButtonText = computed(() => { - if (collabStore.helpStatus === "active") return "老师正在帮你" - if (collabStore.helpStatus === "pending") return "取消求助" - return "求助" -}) - -const toggleHelp = () => { - if (collabStore.helpStatus === "pending") collabStore.cancelHelp() - else if (collabStore.helpStatus === "idle") - collabStore.requestHelp(problem.value!._id) -} - -// 服务端的一次性提示(没有老师在线、老师取消了求助) -watch( - () => collabStore.notice, - (text) => { - if (text) message.info(collabStore.consumeNotice()) - }, -) -``` - -模板里把原来 `