Compare commits

...
4 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5 5eee13fe81 chore: 删掉两个跑完的一次性订正脚本,recount 留着
Deploy / deploy (push) Has been cancelled
fix-achievement-hours 订正的是时区丢失那两周误发的「夜猫子」「早起的鸟儿」,
2026-09-14 已跑完(修正 148 行 · 撤回 60 条),根因修在代码里之后不会再产生;
backfill-problemsets 补的三笔题单历史欠账也已结清,进度记账现在在判题这一路。
两个子命令、两条 npm 脚本一并去掉,services/problemset.ts 的 badgeHolderDiff
只服务于补发脚本,删完 0 处引用,一起清掉。

recount 留着 —— 反范式计数列会被重判、删提交带偏,是会复发的漂移,
补一条 npm 脚本(原来只有子命令)。

docs/timezone.md 里那节改成「账已平、脚本已删」,但保住那条教训:
unlockAchievements 是纯阈值比较,只删 user_achievement 不修 metrics 的话,
学生下次提交就把同一个成就原样再发一次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:03:57 -06:00
xuyueandClaude Opus 5 6fdc3c588e chore(开发环境): 本机库改成靠迁移自举,db:migrate 换成线上同一个执行器
compose.dev.yml 不再把 schema dump 挂成 initdb:0000 就是完整的建表迁移,
起完库 bun run db:migrate 从 0000 自举即可。实测两条路子跑出来的结构一致 ——
空库自举 16 条迁移 vs 灌 dump + 打基线 + 跑迁移,pg_dump --schema-only
逐行零差异(1981 行)。docs/specs/schema.sql 与 sample-data.sql 随之删掉,
前者的内容已经在 0000_crazy_gateway.sql 里。

db:migrate 原来是 drizzle-kit migrate,和线上 oj2-api migrate 不是同一个
执行器:drizzle 那个所有迁移共用一个事务、跑不了 CREATE INDEX CONCURRENTLY,
对已打基线的库还会从 0000 撞表回滚且 exit 1 不打印任何错误。现在两边都走
db/migrate.ts。DATABASE_URL 从根目录 .env 读(--env-file),仓库里不留连接串。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 08:03:57 -06:00
xuyueandClaude Opus 5 a8408c0bb5 docs: 文档整理,CLAUDE.md 瘦身一半,删掉重写期已完成的 22 份阶段产物
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 <noreply@anthropic.com>
2026-09-16 08:03:26 -06:00
xuyueandClaude Opus 5 3559ae4d6f chore(前端): 浏览器基线从 Chrome < 94 提到 105,删掉 mermaid@9 那套 fallback
机房只有部分电脑还是 Chrome 105,其余更新,按最低那档定基线。

- legacy 插件留着:vite 8 的默认 build.target 是 chrome111,比机房高。
  modernTargets 不写,用插件自带的 chrome>=105 基线,正好是这一档。
- polyfill 清单按 105 重新探测,63 → 50 项,仍然写死:自动探测要对每个产物
  跑 Babel 扫描,构建 3s → 12s。写死后产出的 polyfills chunk 与自动探测同尺寸。
- 删 mermaid-legacy(mermaid@9)、useMermaid 里按 UA 分叉的 v9 回调式 render、
  为它存在的 cytoscape UMD→ESM 别名 —— 105 直接用 mermaid 11。
- View Transitions 要 111,darkTransition 的降级分支保留,注释改成 105。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 07:43:21 -06:00
51 changed files with 772 additions and 10845 deletions
+113 -365
View File
@@ -1,25 +1,27 @@
# CLAUDE.md # CLAUDE.md
OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScript,前后端同仓。 OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScript,前后端同仓。
上一代在 `../OnlineJudge/`Django)和 `../ojnext/`Vue SPA**仍然完全冻结、 上一代在 `../OnlineJudge/`Django)和 `../ojnext/`Vue SPA
一行都不改**。
> **旧栈已不可逆地下线** `0002_drop_django_leftovers` 删掉了 Django 的框架表 > **旧栈已不可逆地下线**`0002_drop_django_leftovers` 删掉了 Django 的框架表并已在生产库
> `django_session` 等),且已在生产库执行完毕。所以「停新栈起旧栈」「把 NPM 上游 > 执行完毕,漏网的一张空 `django_migrations` 由 `0014` 补删)。所以「停新栈起旧栈」已经
> 改回 8080」都已失效**唯一退路是从数据库备份恢复** —— 切换手册里的「回滚保证」 > 不是退路**唯一退路是从数据库备份恢复**
> 那节只剩历史价值。
> >
> 生产库上 0002 有一张没删干净(0 行的 `django_migrations`,来源已无法复原), > **旧仓库仍然零改动**,没有例外 —— 包括修 bug、包括不影响外部接口的内部小修。
> 由 `0014_drop_django_migrations` 补删,前因后果写在那个迁移文件的注释里。 > 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、是否
> > 重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户「未处理,按当前政策不动旧仓库」,
> **旧仓库仍然零改动**,没有例外——包括修 bug、包括不影响外部接口的内部小修 > 不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」,不再是回滚保证
> 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、
> 是否重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策
> 不动旧仓库",不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」,
> 不再是回滚保证。
设计文档`docs/specs/2026-08-06-bun-backend-rewrite-design.md` 细节文档`CLAUDE.md` 只留日常要记住的,展开都在这几份里):
切换手册:`docs/specs/phase5-cutover-runbook.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/api/` | 后端。Hono + Drizzle + BullMQ,编译成单二进制 |
| `apps/web/` | 前端。从 ojnext 原样搬来的 Vue 3 SPA | | `apps/web/` | 前端。从 ojnext 原样搬来的 Vue 3 SPA |
| `packages/contract/` | 前后端共用的 Zod 契约 | | `packages/contract/` | 前后端共用的 Zod 契约 |
| `docker/` | Dockerfile + 三套 composedev / debian / school | | `docker/` | Dockerfile + 三套 composedev / debian / school+ 部署与运维脚本 |
| `docs/specs/` | 设计、端点清单、各阶段评审报告与演练报告 | | `docs/` | 上面那几份专题文档 + `specs/` 里的设计文档 |
## 本机环境 ## 本机环境
**Docker 可用,全套依赖都能在本机跑起来**PostgreSQL、Redis、判题沙箱), **Docker 可用,全套依赖都能在本机跑起来**PostgreSQL、Redis、判题沙箱),
镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用"本机跑不起来后端" 镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用本机跑不起来后端」的旧假设。
的旧假设。
```bash ```bash
bun install bun install
bun run db:up # 起 postgres(5433) / redis(6380) / 判题沙箱(8081) bun run db:up # 起 postgres(5433) / redis(6380) / 判题沙箱(8081)
bun run db:migrate # 空库会从 0000 自举出全部结构
bun run dev # api(3000) + worker + web(5173) 一起起 bun run dev # api(3000) + worker + web(5173) 一起起
``` ```
@@ -51,15 +53,15 @@ bun run dev # api(3000) + worker + web(5173) 一起起
```bash ```bash
bun run --filter '@oj2/api' typecheck # 后端类型检查 bun run --filter '@oj2/api' typecheck # 后端类型检查
bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路由跑一下 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 type-check # 前端类型检查
cd apps/web && bun run build # 前端构建 cd apps/web && bun run build # 前端构建
``` ```
⚠️ **前端类型检查只能走 `bun run type-check` 这个脚本。** 两条看起来等价的路子 ⚠️ **前端类型检查只能走 `bun run type-check` 这个脚本。** 两条看起来等价的路子都会**静默
都会**静默通过**`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个 通过**`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个 tsconfig 是 `files: []` +
tsconfig 是 `files: []` + references 的壳,真正的配置在 `tsconfig.app.json`), references 的壳,真正的配置在 `tsconfig.app.json`),`vite build` 根本不做类型检查。
`vite build` 根本不做类型检查。改完 .vue / .ts 别拿构建当验证。 改完 .vue / .ts 别拿构建当验证。
**不要写测试** —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。 **不要写测试** —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。
本机 Docker 全套都能起,实跑的成本比想象中低。 本机 Docker 全套都能起,实跑的成本比想象中低。
@@ -68,17 +70,16 @@ tsconfig 是 `files: []` + references 的壳,真正的配置在 `tsconfig.app.
### 单二进制是有代价的 ### 单二进制是有代价的
`apps/api` 编译成 `bun build --compile` 的单二进制,所以**运行时不能依赖 `apps/api` 编译成 `bun build --compile` 的单二进制,所以**运行时不能依赖 node_modules**。
node_modules**。任何 `require.resolve` / `Bun.resolveSync` / `__dirname` 去找文件的 任何 `require.resolve` / `Bun.resolveSync` / `__dirname` 去找文件的写法,本地都正常、编译后
写法,本地都正常、编译后都会炸,而且**只在离开仓库目录后才炸**(在仓库里跑时它顺着 都会炸,而且**只在离开仓库目录后才炸**(在仓库里跑时它顺着 cwd 摸到了 node_modules
cwd 摸到了 node_modules假装没事)。 假装没事)。
资源要用 `with { type: "file" }` 内嵌。`.node` 原生模块还要额外注意:这个写法 资源要用 `with { type: "file" }` 内嵌。`.node` 原生模块还要额外注意:这个写法只有打包器认、
只有打包器认、`bun run` 不认,所以必须按形态分叉 —— 见 `apps/api/src/vendor/jieba.ts` `bun run` 不认,所以必须按形态分叉 —— 见 `apps/api/src/vendor/jieba.ts` 的注释,
的注释,那里把坑写全了。 那里把坑写全了。
**改完这类代码,dev 和编译两种形态都要跑一遍。** 我吃过亏:只验了编译产物, **改完这类代码,dev 和编译两种形态都要跑一遍。** 我吃过亏:只验了编译产物,dev 直接起不来。
dev 直接起不来。
### 路径解析看 `runtime.ts` ### 路径解析看 `runtime.ts`
@@ -87,17 +88,15 @@ dev 直接起不来。
### SQL 判题会 spawn「自己」 ### SQL 判题会 spawn「自己」
`judge/sql/index.ts` 起的子进程是二进制自身 + `sql-child` 子命令(因为编译后磁盘上 `judge/sql/index.ts` 起的子进程是二进制自身 + `sql-child` 子命令(因为编译后磁盘上没有
没有 child.ts 可以 spawn)。所以**入口必须有 argv 分发**,否则「起自己」变成 child.ts 可以 spawn)。所以**入口必须有 argv 分发**,否则「起自己」变成「把整个程序再跑
「把整个程序再跑一遍」→ 指数级 fork。这不是假想,开发时炸过一次开发机。 一遍」→ 指数级 fork。这不是假想,开发时炸过一次开发机。`OJ2_SQL_CHILD` 那道递归闸不要删。
`OJ2_SQL_CHILD` 那道递归闸不要删。
### 加路由要防遮蔽 ### 加路由要防遮蔽
**Hono 按注册顺序匹配,不是静态优先**(实测确认过,别凭直觉)。`/problems/:id` **Hono 按注册顺序匹配,不是静态优先**(实测确认过,别凭直觉)。`/problems/:id` 注册在
注册在 `/problems/random` 前面的话,后者永远进不去 —— 而且不报错、不警告, `/problems/random` 前面的话,后者永远进不去 —— 而且不报错、不警告,只是静默走进前一条的
只是静默走进前一条的 handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉, handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉,一直到评审才发现。
一直到评审才发现。
加完路由跑 `bun run --filter '@oj2/api' check:routes` 加完路由跑 `bun run --filter '@oj2/api' check:routes`
@@ -109,372 +108,121 @@ dev 直接起不来。
### 出参不 `parse`,用 `satisfies` ### 出参不 `parse`,用 `satisfies`
**后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。** **后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。** 出参是后端自己刚
出参是后端自己刚拼出来的字面量,TS 已经在编译期校验过;再 `parse` 一遍拿不到任何新 拼出来的字面量,TS 已经在编译期校验过;再 parse 一遍拿不到任何新信息,唯一可能失败的输入是
信息,唯一可能失败的输入是**库里的历史数据**,而失败的代价是 500。这一层原来有 136 处, **库里的历史数据**,而失败的代价是 500 —— 这条规矩是被四次这样的线上故障换来的。
已经全部撤掉,撤的时候当场炸出两个一直存在的线上 500:
- `adminProblemSchema.lastUpdateTime` 写的是 `z.string()`,但 `problem.last_update_time` **闸设在写入侧**:入参 `safeParse`58 处)、`db/schema.ts``.$type<>()` 列收窄、
是全库唯一可空的列(961 道题里 470 道是 NULL)——**后台打开任何一道没编辑过的老题都是 500**; 语义校验函数(`astRulesError()` / `exerciseDataError`)。JSONB 原文
- `embeddedSubmissionSchema` `submissionDetailSchema` 继承了 `problemDisplayId` 却没 `submission.info` / `statistic_info` / `exercise.data`)一律放行,它们的形状真相在判题机
omit,而路由只填了同义的 `problem`——**凡是收到过站内信的人,消息页都打不开**(列表为空 那边。query 的筛选值走 `routes/helpers.ts``asFilterValue()`,那是纯类型交接、不加校验。
时才碰巧不炸,所以一直没人报)。
两个都是「读出侧校验」自己造出来的故障,不是它拦住的故障。历史上还有两次同类: 四次故障的细节、`.$type<>()` 断言该怎么核,见 `docs/contract.md`
`exerciseSchema` 按题型收紧后一行脏数据让整条练习列表 500;`info` 写成 前端为什么只在三处挂运行时闸门,见 `apps/web/CLAUDE.md`
`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。
### AST 代码规则:一张表,外加一个机器检查 ### AST 代码规则:一张表,外加一个机器检查
契约的 `AST_NODE_TARGETS_BY_LANGUAGE` 是**唯一**一张表,一个 target 一条 契约的 `AST_NODE_TARGETS_BY_LANGUAGE` 是**唯一**一张表`label` 给界面、`node` 给判题机),
`{ label, node }``label` 给后台下拉和题目页,`node` 给判题机比 tree-sitter 节点类型。 判题机侧没有第二张表,所以加 target 漏配节点类型在结构上不可能。但**配错**仍然可能,
运算符表 `AST_OPERATOR_TARGETS_BY_LANGUAGE` 一份两用(它的值既是文案又是要比的 token) 而且完全静默 —— 节点类型对不上就是「必须使用 X」永远失败、「不能使用 X」永远通过
判题机侧没有第二张表,解析统一走契约的 `astTargetNodeType()`
> 这里原来是两张表:契约那张 target → 中文名,`judge/ast.ts` 的 `mappings` 是
> target → 节点类型,靠一句「两边必须同增同减」的注释维持。**加 target 而漏配节点类型
> 现在在结构上不可能了**,那条注释也就不必再守。
但**配错**仍然可能,而且完全静默:节点类型对不上就是一个都收不到,于是「必须使用 X」
永远失败、「不能使用 X」永远通过,两头不报错,只有学生受着。所以有:
```bash ```bash
bun run --filter '@oj2/api' check:ast # 每个 target 的 node 在语法里是否真实存在 bun run --filter '@oj2/api' check:ast # 升级 tree-sitter-* 之后一定要跑
``` ```
**升级 `tree-sitter-*` 依赖之后一定要跑一次** —— 语法改节点名是常事,后果全静默。 判题机只认 C / C++ / Python3`AST_SUPPORTED_LANGUAGES`),别的语言配了规则一条都不会跑,
加这个检查那天,56 个 target 里就抓出一个:`f_string` 一直配的是 `format_string` 所以后台不给它们开 tab —— **看得见却不检查**比没有更糟。C++ 的调用形态和 C 不一样、
而这个版本的 tree-sitter-python 根本没有这种节点(f-string 是 `string` 里带 规则的语义校验为什么不挂在 zod 上,见 `docs/ast-rules.md`
`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 里看不到那组规则,保存却被拦下。
### 比赛只有 ACM 模式 ### 比赛只有 ACM 模式
没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要"顺手补回来" 没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要顺手补回来
### 前端要兼容老 Chrome ### 前端基线是 Chrome 1052026-09-16 从 < 94 上调)
机房电脑 Chrome < 94。`mermaid-legacy` 等 fallback 依赖和 vite 的构建 target 机房**部分**电脑 Chrome 105,其余更新 —— 按最低那档定基线。
不能动,`vite.config.ts` 里有注释说明。
- **`@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 要 111105 没有**`darkTransition.ts` 的降级分支是真在用的。
### 时间只有一个锚点:`apps/api/src/time.ts` ### 时间只有一个锚点:`apps/api/src/time.ts`
**凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,一律走那个模块。** **凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,一律走那个模块。** 不要写
不要写 `new Date(x).getHours()``setHours(0,0,0,0)``getFullYear()` `new Date(x).getHours()``setHours(0,0,0,0)``getFullYear()``new Date(y, m, d)` 这类跟
`new Date(y, m, d)` 这类跟**进程时区**走的代码 —— 容器是 UTC、开发机是本机时区, **进程时区**走的代码 —— 容器是 UTC、开发机是本机时区,两边答案不同而且不报错。
两边答案不同而且不报错。SQL 里要按日历切,用 `localTime(列)`(生成 SQL 里要按日历切,用 `localTime(列)`(生成 `列 at time zone 'Asia/Shanghai'`),
`列 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 上又是另一个答案。
**分层:存 UTC 时刻 → 后端判定按东八区 → 出参 ISO UTC → 前端按东八区渲染。** **分层:存 UTC 时刻 → 后端判定按东八区 → 出参 ISO UTC → 前端按东八区渲染。**
- **存**35 个时间列全是 `timestamptz``without time zone` 0 个、`date` 0 个), - **存**35 个时间列全是 `timestamptz`,写侧一律 `new Date().toISOString()`
写侧一律 `new Date().toISOString()`。库里永远是绝对时刻,换时区不用动数据 - **判定**:日历语义走 `time.ts`SQL 用 `localTime()`
- **判定**日历语义(哪一天/几点/哪一年)走 `time.ts`SQL 用 `localTime()` - **出参**`db/index.ts` 给 OID 1184 挂了 parser,读出来的时刻统一成 ISO 8601 UTC
- **出参**`db/index.ts` 给 OID 1184 挂了 parser**所有读出来的时刻统一成 **微秒必须保留**(截成毫秒会让翻页每页丢一条、班级 AC 排名少 1)。
ISO 8601 UTC**`2026-09-14T12:00:00.000Z`,库里带微秒的保留成 `…00.123456Z`)。 - **渲染**:前端 `parseTime()` / `zonedParts()` 按同一个固定偏移取东八区部件
别在这里退回去 —— 原来 drizzle 把 1184 的 parser (见 `apps/web/CLAUDE.md`)。
换成了恒等函数,读出来是 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,改动出参格式不需要动前端。
### 存量成就的口径是东八区,别再退回 UTC 时区常量 `TIME_ZONE` / `TIME_ZONE_OFFSET_MINUTES``packages/contract/src/time.ts`
前后端共用一份,按**固定偏移**算(大陆 1991 年起没有夏令时)。旧栈的口径本来就是东八区,
2026-09-14 用生产备份 `db_backup_2026_09_08_19_22_11.sql` 实测过,结论和直觉相反 重写时丢过一次、2026-09 才收回来 —— 期间「今日提交」在北京时间 0:00–8:00 是空的
记在这里省得下次重新推。下面「那两周留下的实际后果」和脚本的跑数,又用 两个小时口径的成就整体偏 8 小时,事后已用一次性脚本对账订正(账平了,脚本已删)。
`db_backup_2026_09_14_18_17_37.sql`(最新提交到北京时间 9-14 17:06)逐项复核过,一致。) **再动日历口径之前先读 `docs/timezone.md`**,那里有实测数据和核实方法;
Dockerfile 的 `TZ` 和数据库连接的 `TimeZone` 是**刻意不设**的,别「顺手补上」。
**历史指标本来就是北京时间。** 旧栈 `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 人差异),
再比受影响的指标。别靠推理 —— 这次推理就得出过相反的结论。
## 数据库 ## 数据库
Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它长得像 Django 建的表 Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它长得像 Django 建的表
(表名、bigint/int4 混用),`schema.ts` 顶部记了哪些地方是手工修的。 (表名、bigint/int4 混用),`schema.ts` 顶部记了哪些地方是手工修的。
**schema 现在归 OJ2 独占**,结构变更走 migration 正常演进。
**外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION **外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION
- **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表 - **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表
problem_tags)、题单/教程/成就的组成部分、一对一附属(user_profile)与可重算的 problem_tags)、题单/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存
缓存user_stat)。 user_stat)。
- **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` - **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` 的绝大
的绝大多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。 多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。
**加新子表时必须回来想一遍该走哪一档**,别默认新外键会自己连坐 —— drizzle 不写 **加新子表时必须回来想一遍该走哪一档**,别默认新外键会自己连坐 —— drizzle 不写
`.onDelete()` 就是 NO ACTION,而 0010 只改了当时存在的那批。 `.onDelete()` 就是 NO ACTION,而 0010 只改了当时存在的那批。
**schema 现在归 OJ2 独占。** 旧后端已下线,「改 schema 要考虑回滚」这条约束不再存在,
结构变更走下面的 migration 正常演进即可。
### 改 schema 走 drizzle migration ### 改 schema 走 drizzle migration
`bun run db:generate`(造迁移文件)→ `bun run db:migrate`(按 `drizzle.__drizzle_migrations` `bun run db:generate`(造迁移文件)→ `bun run db:migrate`(按
增量执行),就是 Django `makemigrations` / `migrate`等价物。索引/结构变更走这条, `drizzle.__drizzle_migrations` 增量执行),就是 Django `makemigrations` / `migrate`
不要再手写 SQL 往 `docs/specs/` 里塞。 等价物。索引/结构变更走这条,不要再手写 SQL 往 `docs/` 里塞。
**部署时自动执行。** `docker/deploy.sh` 在「构建镜像」之后、「起栈」之前会跑 - **执行器是自己的**`db/migrate.ts`,一条迁移一个事务),不是 drizzle 那个,
`oj2-api migrate`,失败就中止部署(旧容器原样还在跑)。CI 走的也是 deploy.sh `db:migrate` 和线上 `oj2-api migrate` 是同一条代码路径。
所以不需要给 GitHub 配数据库凭据,也不用把生产库对外开放 - **部署时自动执行**`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` `CREATE INDEX CONCURRENTLY` 怎么写、给已有库打基线的 SQL、`.op()` 会吞掉索引方向这类
(见 `runtime.ts``migrationsDir`、Dockerfile 里那两条 COPY)。这样 drizzle 的 drizzle-kit 的坑,全在 `docs/database.md`
`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 = 某条迁移执行失败。
## 部署 ## 部署
三套 compose 在 `docker/``dev`(本机)、`debian`(服务器)、`school`(机房)。 三套 compose 在 `docker/``dev`(本机)、`debian`(服务器)、`school`(机房)。
**机房那套没有 postgres,连的是服务器的库。** 两个站点共用一个数据库, **机房那套没有 postgres,连的是服务器的库。** 两个站点共用一个数据库,但各有各的 Redis
但各有各的 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` ⚠️ **`DATA_DIR` 默认值 `../data` `OJ2/data`,不是部署目录的 `data/`。** 沿用旧数据却忘了
沿用旧栈已经在跑的 postgres 和 redis,只起 api / worker / web / judge。 设它,会静默起一套空数据(空库、没测试点、图片 404),而且**不报错** —— 这是整个部署里
- **自带数据**(本机、演练):不设那几个变量,起栈时加 `--profile local-data` 唯一会静默走歪的地方,`deploy.sh` 为它专门设了一道自检
- **并行试跑**(上线前先挂 `oj2.xuyue.cc` 跑几天):在「只换前后端」基础上再加
`WEB_PORT`8080 被旧 backend 占着)和 `JUDGE_STATE_DIR`(两个判题机不能共用运行目录)。
这种形态下旧栈一个容器都不用停,正式切换退化成改一行 NPM 上游。
⚠️ `DATA_DIR` 默认值 `../data`**`OJ2/data`**,不是部署目录的 `data/` 上线两条路(push 触发 CI / 手工 `docker/deploy.sh`)、部署后的验证清单、NPM 反代那两个
沿用旧数据却忘了设它,会静默起一套空数据(空库、没测试点、图片 404), 不能关的开关、备份恢复的两个坑,都在 `docs/deploy.md`
而且**不报错** —— 这是切换当天唯一会静默走歪的地方。
细节和演练结果都在 `docs/specs/phase5-cutover-runbook.md`
+2 -3
View File
@@ -11,14 +11,13 @@
"worker": "bun src/main.ts worker", "worker": "bun src/main.ts worker",
"build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api", "build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api",
"seed:dev": "bun src/scripts/seed-dev.ts", "seed:dev": "bun src/scripts/seed-dev.ts",
"backfill:problemsets": "bun src/main.ts backfill-problemsets", "recount": "bun --env-file=../../.env src/main.ts recount",
"fix:achievement-hours": "bun src/main.ts fix-achievement-hours",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"check:routes": "bun src/scripts/check-route-shadowing.ts", "check:routes": "bun src/scripts/check-route-shadowing.ts",
"check:ast": "bun src/scripts/check-ast-targets.ts", "check:ast": "bun src/scripts/check-ast-targets.ts",
"db:pull": "drizzle-kit pull", "db:pull": "drizzle-kit pull",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate" "db:migrate": "bun --env-file=../../.env src/main.ts migrate"
}, },
"dependencies": { "dependencies": {
"@node-rs/jieba": "^2.0.2", "@node-rs/jieba": "^2.0.2",
+4 -19
View File
@@ -12,9 +12,7 @@
* oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用 * oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调 * oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调 * oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
* oj2-api backfill-problemsets # 把题单进度与奖章订正到与规则一致,默认只读预演 * oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演
* oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演
* oj2-api fix-achievement-hours # 订正「夜猫子」「早起的鸟儿」的历史误发,默认只读预演
* *
* 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用 * 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用
* Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。 * Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
@@ -36,25 +34,12 @@ switch (command) {
await runMigrations() await runMigrations()
break break
} }
// 一次性的数据订正,跟着二进制走而不是留成源码脚本 —— 生产镜像里没有 bun 也没有源码 // 数据订正,跟着二进制走而不是留成源码脚本 —— 生产镜像里没有 bun 也没有源码
case "backfill-problemsets": { // 反范式计数列被重判等操作带偏之后拿它对账,默认只读预演,--apply 才写。
const { backfillProblemSets } = await import("./scripts/backfill-problemsets")
const args = process.argv.slice(3)
process.exit(await backfillProblemSets({
apply: args.includes("--apply"),
allowRevoke: args.includes("--allow-revoke"),
}))
}
// 同上,一次性的数据订正。反范式计数列被重判等操作带偏之后拿它对账。
case "recount": { case "recount": {
const { recount } = await import("./scripts/recount") const { recount } = await import("./scripts/recount")
process.exit(await recount({ apply: process.argv.slice(3).includes("--apply") })) process.exit(await recount({ apply: process.argv.slice(3).includes("--apply") }))
} }
// 同上。OJ2 上线时区丢了两周,两个小时口径的成就按 UTC 误发了一批,拿它订正。
case "fix-achievement-hours": {
const { fixAchievementHours } = await import("./scripts/fix-achievement-hours")
process.exit(await fixAchievementHours({ apply: process.argv.slice(3).includes("--apply") }))
}
case "sql-child": { case "sql-child": {
const { runSqlChild } = await import("./judge/sql/child") const { runSqlChild } = await import("./judge/sql/child")
await runSqlChild() await runSqlChild()
@@ -75,6 +60,6 @@ switch (command) {
} }
} }
default: default:
console.error(`未知子命令:${command}\n可用:serve | worker | migrate | backfill-problemsets | recount | fix-achievement-hours | healthcheck | sql-child`) console.error(`未知子命令:${command}\n可用:serve | worker | migrate | recount | healthcheck | sql-child`)
process.exit(2) process.exit(2)
} }
@@ -1,275 +0,0 @@
import { and, eq, inArray, isNull, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { JudgeStatus } from "../judge/status"
import { objectValue } from "../routes/helpers"
import { badgeHolderDiff, computeProgress, recalculateBadge, resyncProgress } from "../services/problemset"
/**
* 把题单的进度和奖章订正到与当前规则一致。三笔历史欠账,一趟结清:
*
* 1. **进度漏记**。判题这一路记账(services/problemset.ts 的 recordSolvedProblem)是后来才有的,
* 在那之前靠前端 AC 之后回调,只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度,
* 网络一抖就静默丢失。这里按实际 AC 记录补回来 —— 移植自旧栈的管理命令
* `problemset/management/commands/fix_problemset_progress.py`。
* 2. **奖章漏发**。奖章原本只在学生做出一道题那一刻发,进度从别的路径变了就没人回头判过达标。
* 生产快照里 53 条应发未发、涉及 30 名学生 —— 其中 23 条正是上面那个管理命令留下的:
* 它补进度,而旧栈的信号只挂在 ProblemSetProblem 和 ProblemSetBadge 上、不挂 Progress。
* 3. **算法改过**。分母只算必做题(选做不再卡完成)、空题单不再算完成、total_score 跟着分值走。
* 已有的行要跑一遍才会按新规则重算。
*
* 三件事必须一趟做完,因为它们首尾相接:补进度 → 完成状态变 → 奖章达标面变。
* 落库走 resyncProgress,它重算进度之后会顺手重算该题单的全部奖章。
*
* 默认只读,把差异打出来;确认无误再加 --apply 落库。
* 只要预演里出现「收回」就先停下来让人看清楚,要真的收回得显式加 --allow-revoke ——
* user_badge 没有别处备份,earnedTime 删了就找不回来。
*
* 做成 main.ts 的子命令而不是独立脚本,是因为生产镜像里只有编译好的单二进制,
* 既没有 bun 也没有源码。跑法对齐 migrate:
*
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-problemsets
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-problemsets --apply
*/
const ACCEPTED = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
async function loadSet(problemsetId: number) {
const [links, progresses, badges] = await Promise.all([
db.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, problemsetId)),
db.select().from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, problemsetId)),
db.select().from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, problemsetId)),
])
return { links, progresses, badges }
}
/**
* 找出「这个题单里的题,学生其实早就 AC 了,可进度里没记」的那些格子。
*
* 口径必须和 recordSolvedProblem 一模一样(非比赛提交、ACCEPTED 或 AST_CHECK_FAILED、
* 取最早那次),否则补账工具会永远「发现」差异。题单里的题必定是非比赛题,所以
* isNull(contestId) 实际上不会过滤掉任何东西,写上是为了两边字面一致。
*/
async function recoverable(links: ProblemLink[], progresses: (typeof schema.problemsetProgress.$inferSelect)[]) {
const gaps: { userId: number; problemId: number }[] = []
for (const progress of progresses) {
const detail = objectValue(progress.progressDetail)
for (const link of links) {
if (!(String(link.problemId) in detail)) gaps.push({ userId: progress.userId, problemId: link.problemId })
}
}
if (gaps.length === 0) return new Map<string, string>()
const rows = await db.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
// 和 recoverable 上面那段同口径。不加 `::text`OID 1184 由 db/index.ts 统一转成
// ISO 8601 UTC,比 PG 文本更稳定(PG 文本的形状跟着会话时区走)。
// 注:这个值写进 progress_detail.submit_time,而那个字段没有任何读取方,
// 存量里还混着 Django 的 `...+00:00`,所以形状变化只影响 backfill 自己的差异比对。
solvedAt: sql<string>`min(${schema.submission.createTime})`,
}).from(schema.submission).where(and(
inArray(schema.submission.userId, [...new Set(gaps.map((g) => g.userId))]),
inArray(schema.submission.problemId, [...new Set(gaps.map((g) => g.problemId))]),
isNull(schema.submission.contestId),
inArray(schema.submission.result, ACCEPTED),
)).groupBy(schema.submission.userId, schema.submission.problemId)
const solved = new Map(rows.map((row) => [`${row.userId}:${row.problemId}`, row.solvedAt]))
const found = new Map<string, string>()
for (const gap of gaps) {
const key = `${gap.userId}:${gap.problemId}`
const at = solved.get(key)
if (at) found.set(key, at)
}
return found
}
export async function backfillProblemSets(options: { apply: boolean; allowRevoke: boolean }) {
const sets = await db.select({ id: schema.problemset.id, title: schema.problemset.title })
.from(schema.problemset).orderBy(schema.problemset.id)
if (sets.length === 0) {
console.log("没有任何题单,无事可做")
return 0
}
const now = new Date().toISOString()
const report = []
for (const set of sets) {
const { links, progresses, badges } = await loadSet(set.id)
const found = await recoverable(links, progresses)
const scoreByProblem = new Map(links.map((link) => [link.problemId, link.score]))
// 把补回来的格子先并进 detail,再按新规则重算 —— 奖章的差异要照着「补完账又重算过」
// 的进度看,否则预演报出来的名单和 --apply 之后的结果对不上
const next = progresses.map((row) => {
const detail = objectValue(row.progressDetail)
for (const link of links) {
const at = found.get(`${row.userId}:${link.problemId}`)
if (at) detail[String(link.problemId)] = { score: scoreByProblem.get(link.problemId) ?? 0, submit_time: at }
}
return { ...row, ...computeProgress(detail, links, row.completeTime, now) }
})
const changed = next.filter((row, i) => {
const was = progresses[i]!
return was.totalProblemsCount !== row.totalProblemsCount ||
was.completedProblemsCount !== row.completedProblemsCount ||
was.totalScore !== row.totalScore ||
was.isCompleted !== row.isCompleted ||
Math.abs(was.progressPercentage - row.progressPercentage) > 0.005 ||
was.completeTime !== row.completeTime ||
JSON.stringify(objectValue(was.progressDetail)) !== JSON.stringify(row.progressDetail)
})
const newlyCompleted = next.filter((row, i) => !progresses[i]!.isCompleted && row.isCompleted).length
const uncompleted = next.filter((row, i) => progresses[i]!.isCompleted && !row.isCompleted).length
const badgeDiffs = []
for (const badge of badges) badgeDiffs.push({ badge, ...(await badgeHolderDiff(badge, next)) })
report.push({
set, links, found, scoreByProblem,
changed: changed.length, newlyCompleted, uncompleted, badgeDiffs,
recovered: found.size,
recoveredUsers: new Set([...found.keys()].map((key) => key.split(":")[0]!)).size,
})
}
const recovered = report.reduce((n, r) => n + r.recovered, 0)
const progressRows = report.reduce((n, r) => n + r.changed, 0)
const completedGain = report.reduce((n, r) => n + r.newlyCompleted, 0)
const completedLoss = report.reduce((n, r) => n + r.uncompleted, 0)
const missing = report.reduce((n, r) => n + r.badgeDiffs.reduce((m, d) => m + d.missing.length, 0), 0)
const extra = report.reduce((n, r) => n + r.badgeDiffs.reduce((m, d) => m + d.extra.length, 0), 0)
console.log(`${sets.length} 个题单\n`)
for (const r of report) {
const lines = []
if (r.recovered) {
lines.push(` 补录:${r.recovered} 道题已 AC 但进度里没记(${r.recoveredUsers} 名学生)`)
}
if (r.changed) {
lines.push(` 进度:${r.changed} 条要重算` +
(r.newlyCompleted ? `,其中 ${r.newlyCompleted} 条未完成 → 已完成` : "") +
(r.uncompleted ? `${r.uncompleted} 条已完成 → 未完成` : ""))
}
for (const d of r.badgeDiffs) {
if (!d.missing.length && !d.extra.length) continue
lines.push(` 奖章[${d.badge.name}] ${d.badge.conditionType}/${d.badge.conditionValue}` +
`应发 ${d.eligible} / 现有 ${d.held}` +
(d.missing.length ? ` 补发 ${d.missing.length}user ${d.missing.join(", ")}` : "") +
(d.extra.length ? ` 收回 ${d.extra.length}user ${d.extra.join(", ")}` : ""))
}
if (lines.length) {
console.log(` 题单${String(r.set.id).padStart(2)} ${r.set.title}`)
for (const line of lines) console.log(line)
}
}
console.log(`\n合计:补录 ${recovered} 道题,进度 ${progressRows} 条要重算` +
`(完成 +${completedGain} / -${completedLoss}),奖章补发 ${missing} 条、收回 ${extra}`)
if (recovered === 0 && progressRows === 0 && missing === 0 && extra === 0) {
console.log("题单数据与当前规则一致,无需订正")
return 0
}
if (!options.apply) {
console.log("\n这是只读预演,什么都没写。确认无误后加 --apply 落库。")
return 0
}
if (extra > 0 && !options.allowRevoke) {
console.error(`\n预演里有 ${extra} 条奖章要被收回,而 user_badge 没有别处备份、` +
`earnedTime 删了就找不回来。\n确认要连同收回一起执行,加 --allow-revoke。`)
return 1
}
let touched = 0
for (const r of report) {
const hasBadgeDrift = r.badgeDiffs.some((d) => d.missing.length || d.extra.length)
if (!r.recovered && !r.changed && !hasBadgeDrift) continue
// 补录的格子先写进 detailresyncProgress 是照着库里的 detail 重算的
if (r.recovered) {
await db.transaction(async (tx) => {
const rows = await tx.select().from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, r.set.id))
for (const row of rows) {
const detail = objectValue(row.progressDetail)
let dirty = false
for (const link of r.links) {
const at = r.found.get(`${row.userId}:${link.problemId}`)
if (!at || String(link.problemId) in detail) continue
detail[String(link.problemId)] = { score: r.scoreByProblem.get(link.problemId) ?? 0, submit_time: at }
dirty = true
const [existing] = await tx.select({ id: schema.problemsetSubmission.id })
.from(schema.problemsetSubmission).where(and(
eq(schema.problemsetSubmission.problemsetId, r.set.id),
eq(schema.problemsetSubmission.userId, row.userId),
eq(schema.problemsetSubmission.problemId, link.problemId),
)).limit(1)
if (!existing) {
const [submission] = await tx.select({ id: schema.submission.id }).from(schema.submission)
.where(and(
eq(schema.submission.userId, row.userId),
eq(schema.submission.problemId, link.problemId),
isNull(schema.submission.contestId),
inArray(schema.submission.result, ACCEPTED),
)).orderBy(schema.submission.createTime).limit(1)
if (submission) {
await tx.insert(schema.problemsetSubmission).values({
problemsetId: r.set.id,
userId: row.userId,
submissionId: submission.id,
problemId: link.problemId,
})
}
}
}
if (dirty) {
await tx.update(schema.problemsetProgress).set({ progressDetail: detail })
.where(eq(schema.problemsetProgress.id, row.id))
}
}
})
}
// 重算进度,顺带重算这份题单的全部奖章
await resyncProgress(r.set.id)
touched += 1
}
// 没有参与者、只有奖章欠账的题单不会走上面那条,兜一遍
for (const r of report) {
for (const d of r.badgeDiffs) {
if (d.missing.length || d.extra.length) await recalculateBadge(d.badge)
}
}
console.log(`\n已订正 ${touched} 个题单,复核中……`)
let remaining = 0
for (const set of sets) {
const { links, progresses, badges } = await loadSet(set.id)
const found = await recoverable(links, progresses)
if (found.size) {
remaining += found.size
console.error(` 仍有可补录的进度:题单${set.id} ${found.size}`)
}
for (const row of progresses) {
const next = computeProgress(objectValue(row.progressDetail), links, row.completeTime, now)
if (row.isCompleted !== next.isCompleted || row.totalScore !== next.totalScore ||
row.completedProblemsCount !== next.completedProblemsCount ||
row.totalProblemsCount !== next.totalProblemsCount) {
remaining += 1
console.error(` 进度仍不一致:题单${set.id} user ${row.userId}`)
}
}
for (const badge of badges) {
const after = await badgeHolderDiff(badge)
if (after.missing.length || after.extra.length) {
remaining += after.missing.length + after.extra.length
console.error(` 奖章仍不一致:题单${set.id} [${badge.name}]`, after)
}
}
}
console.log(remaining === 0 ? "复核通过:题单数据与规则一致" : `复核未通过,仍有 ${remaining} 处差异`)
return remaining === 0 ? 0 : 1
}
@@ -1,337 +0,0 @@
import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { localTime, TIME_ZONE } from "../time"
/**
* 一次性数据对账:把「夜猫子」「早起的鸟儿」的历史发放与真实提交时间对齐。
*
* ## 背景
*
* 2026-08-26 OJ2 上线到 2026-09-14 修好时区之间,`services/achievements.ts` 用
* `new Date(createTime).getHours()` 判定,而容器是 UTC —— 于是 **UTC 的「凌晨 0–5 点」
* 其实是北京时间的上午 9–13 点**,学生在上课时间提交被记成熬夜;
* 「5–7 点」对应北京下午 1–3 点,被记成早起。
*
* 旧 Django 栈用的是 `timezone.localtime()``TIME_ZONE = "Asia/Shanghai"`),
* 所以 20222026.08 的历史数据**本来就是东八区口径、本来就是对的**。
* 出问题的只有 OJ2 那两周的增量。
*
* ## 为什么不能只删 `user_achievement`
*
* `unlockAchievements()` 的判定是**纯粹的阈值比较**`metrics[metric] >= threshold`),
* 不是「这次有没有跨过阈值」。所以只要 `user_stat.metrics.midnight_submissions`
* 还留着虚高的 13,把成就行删掉之后,学生**下一次提交就会原样再发一次**。
*
* 必须一起做:按东八区重算小时指标写回 user_stat,再对账发放。
*
* ## 两个方向
*
* - **撤回**:重算后不达标却持有 → 删行 + `achievement.unlock_count` 下调。
* - **补发**:重算后达标却没持有 → 插行(`backfilled = true`,前端显示「已获得」
* 而不编一个解锁时间)+ 计数上调。
*
* 2026-09-14 的实测结论:**只有撤回(60 条),补发 0 条** —— 原因是 OJ2 窗口内
* 1508 条提交里,真正落在北京 0–5 点和 5–7 点的**都是 0 条**(真熬夜的人都在
* Django 时代活跃过了)。补发分支留着是为了对称和以后重跑,不是为了现在有用。
*
* ## 跑法(对齐 migrate / recount:跟着二进制走,生产镜像里没有 bun 也没有源码)
*
* # 本机开发
* bun apps/api/src/main.ts fix-achievement-hours
* bun apps/api/src/main.ts fix-achievement-hours --apply
*
* # 服务器 / 机房(docker/ 目录下,或照 deploy.sh 用 -f 指定)
* docker compose -f docker/compose.debian.yml --env-file docker/.env \
* run --rm oj-api oj2-api fix-achievement-hours
* # 确认无误后再加 --apply
*
* ⚠️ **必须先部署时区修复,再跑这个脚本。** 反过来的话旧代码还在按 UTC 累加,
* 跑完马上又被写脏、成就又发回来。
*
* ⚠️ **--apply 要挑没人做题的时候跑。** 差异在事务外算、事务内写;算完到写完之间
* 要是学生交了一发并判完,那一笔的小时计数会被覆盖。落库后的复核会报「仍有 N 处差异」
* 并以 1 退出,不会静默 —— 见到了重跑一次即可。
*
* 写回时**只动两个小时键**(jsonb `||` 合并),不整体覆盖 `metrics`:整体写回的是事务外
* 读的快照,并发时会连带把 `submission_count` / `_last_ac_date` 等一起写旧,而复核只比
* 小时指标,那种覆盖是查不出来的。
*
* 幂等:干净状态下再跑,报 0 处差异并以 0 退出。
*/
/** 受时区影响的小时口径指标 —— 只有这两个 */
const HOUR_METRICS = ["midnight_submissions", "early_bird_submissions"] as const
interface Target {
id: number
name: string
metric: string
threshold: number
operator: string
}
interface Plan {
metricFixes: Array<{ id: number; userId: number; midnight: number; earlyBird: number }>
revoke: Array<{ userId: number; achievementId: number; name: string; held: number; actual: number }>
grant: Array<{ userId: number; achievementId: number; name: string; actual: number }>
cascade: Array<{ userId: number; achievementId: number; name: string; before: number; after: number }>
targets: Target[]
meta: Target | undefined
touched: number[]
}
/**
* 只读对账:算出「该改的指标 / 该撤的 / 该补的 / 连锁该撤的」。
* 落库之后再调一次就是复核。
*/
async function audit(): Promise<Plan> {
// 用 SQL 一次算完,口径和 apps/api/src/time.ts 完全一致(东八区墙上时钟的钟点)。
// 只统计非比赛提交 —— 和 updateAchievementsForSubmission 的 contestId !== null 提前返回对齐。
const hour = sql`extract(hour from ${localTime(schema.submission.createTime)})`
const recomputed = await db
.select({
userId: schema.submission.userId,
midnight: sql<number>`count(*) filter (where ${hour} < 5)`.mapWith(Number),
earlyBird: sql<number>`count(*) filter (where ${hour} >= 5 and ${hour} < 7)`.mapWith(Number),
})
.from(schema.submission)
.where(isNull(schema.submission.contestId))
.groupBy(schema.submission.userId)
const truth = new Map(recomputed.map((row) => [row.userId, row]))
const actualOf = (userId: number, metric: string) => {
const fresh = truth.get(userId)
return (metric === "midnight_submissions" ? fresh?.midnight : fresh?.earlyBird) ?? 0
}
// 哪些 user_stat 的小时指标要改
const stats = await db.select().from(schema.userStat)
const statByUser = new Map(stats.map((row) => [row.userId, row]))
const metricFixes: Plan["metricFixes"] = []
for (const row of stats) {
const fresh = truth.get(row.userId)
const corrected = { midnight_submissions: fresh?.midnight ?? 0, early_bird_submissions: fresh?.earlyBird ?? 0 }
const changed = HOUR_METRICS.filter((key) => (row.metrics as Record<string, unknown>)[key] !== corrected[key])
if (changed.length) {
metricFixes.push({ id: row.id, userId: row.userId, midnight: corrected.midnight_submissions, earlyBird: corrected.early_bird_submissions })
}
}
const targets = await db
.select({
id: schema.achievement.id,
name: schema.achievement.name,
metric: schema.achievement.metric,
threshold: schema.achievement.threshold,
operator: schema.achievement.operator,
})
.from(schema.achievement)
.where(and(eq(schema.achievement.visible, true), inArray(schema.achievement.metric, [...HOUR_METRICS])))
const targetById = new Map(targets.map((item) => [item.id, item]))
const met = (target: Target, userId: number) => {
const value = actualOf(userId, target.metric)
return target.operator === "gte" ? value >= target.threshold : value <= target.threshold
}
const grants = await db
.select({ achievementId: schema.userAchievement.achievementId, userId: schema.userAchievement.userId })
.from(schema.userAchievement)
.where(inArray(schema.userAchievement.achievementId, targets.map((item) => item.id)))
const heldByUser = new Map<number, Set<number>>()
for (const grant of grants) {
heldByUser.set(grant.userId, (heldByUser.get(grant.userId) ?? new Set()).add(grant.achievementId))
}
const revoke: Plan["revoke"] = []
for (const grant of grants) {
const target = targetById.get(grant.achievementId)
if (!target || met(target, grant.userId)) continue
revoke.push({
userId: grant.userId,
achievementId: grant.achievementId,
name: target.name,
held: Number((statByUser.get(grant.userId)?.metrics as Record<string, unknown> | undefined)?.[target.metric] ?? 0),
actual: actualOf(grant.userId, target.metric),
})
}
// 补发只针对「被成就系统结算过」的用户(有 user_stat 行)—— 从没结算过的是另一件事
// (备份里还有 31 个有提交却没有 user_stat 行的用户),不在这个脚本的职责内。
const grant: Plan["grant"] = []
for (const target of targets) {
for (const userId of truth.keys()) {
if (!statByUser.has(userId)) continue
if (heldByUser.get(userId)?.has(target.id)) continue
if (!met(target, userId)) continue
grant.push({ userId, achievementId: target.id, name: target.name, actual: actualOf(userId, target.metric) })
}
}
// 连锁:`achievement_unlocked_count` = 「已解锁的非白金成就数」,是 奖杯收藏家 的判据。
// 撤回会让它降、补发会让它升;降破了阈值的 奖杯收藏家 要一起撤。
const touched = [...new Set([...revoke.map((item) => item.userId), ...grant.map((item) => item.userId)])]
const meta = (
await db
.select({
id: schema.achievement.id,
name: schema.achievement.name,
metric: schema.achievement.metric,
threshold: schema.achievement.threshold,
operator: schema.achievement.operator,
})
.from(schema.achievement)
.where(and(eq(schema.achievement.visible, true), eq(schema.achievement.metric, "achievement_unlocked_count")))
)[0]
const cascade: Plan["cascade"] = []
if (meta && touched.length) {
const unlocked = await db
.select({
userId: schema.userAchievement.userId,
achievementId: schema.userAchievement.achievementId,
rarity: schema.achievement.rarity,
})
.from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
const held = new Map<number, Array<{ achievementId: number; rarity: string }>>()
for (const row of unlocked) {
const list = held.get(row.userId) ?? []
list.push({ achievementId: row.achievementId, rarity: row.rarity })
held.set(row.userId, list)
}
for (const userId of touched) {
const mine = held.get(userId) ?? []
const gone = new Set(revoke.filter((item) => item.userId === userId).map((item) => item.achievementId))
const coming = new Set(grant.filter((item) => item.userId === userId).map((item) => item.achievementId))
const before = mine.filter((item) => item.rarity !== "platinum").length
const after = mine.filter((item) => item.rarity !== "platinum" && !gone.has(item.achievementId)).length + coming.size
if (before === after) continue
const holdsMeta = mine.some((item) => item.achievementId === meta.id)
const ok = meta.operator === "gte" ? after >= meta.threshold : after <= meta.threshold
if (holdsMeta && !ok) cascade.push({ userId, achievementId: meta.id, name: meta.name, before, after })
}
}
return { metricFixes, revoke, grant, cascade, targets, meta, touched }
}
function report(plan: Plan) {
console.log(`① 需要修正小时指标的 user_stat 行:${plan.metricFixes.length}`)
console.log(`② 需要【撤回】的发放:${plan.revoke.length} 条,涉及 ${new Set(plan.revoke.map((r) => r.userId)).size}`)
for (const target of plan.targets) {
console.log(` - ${target.name}(阈值 ${target.operator} ${target.threshold}):${plan.revoke.filter((r) => r.achievementId === target.id).length}`)
}
console.log(`③ 需要【补发】的发放:${plan.grant.length} 条,涉及 ${new Set(plan.grant.map((r) => r.userId)).size}`)
for (const target of plan.targets) {
console.log(` - ${target.name}${plan.grant.filter((r) => r.achievementId === target.id).length}`)
}
console.log(`④ 连带需要撤销的「${plan.meta?.name ?? "(未配置)"}」:${plan.cascade.length}`)
if (plan.revoke.length <= 40) {
for (const item of plan.revoke) console.log(` [撤] user ${item.userId} ${item.name} 存量 ${item.held} → 实际 ${item.actual}`)
}
if (plan.grant.length <= 40) {
for (const item of plan.grant) console.log(` [补] user ${item.userId} ${item.name} 实际 ${item.actual}`)
}
for (const item of plan.cascade) console.log(` [撤] user ${item.userId} ${item.name} 已解锁数 ${item.before}${item.after}`)
}
export async function fixAchievementHours({ apply }: { apply: boolean }): Promise<number> {
const url = process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
const database = (() => {
try {
const parsed = new URL(url)
return `${parsed.host}${parsed.pathname}`
} catch {
return "(无法解析)"
}
})()
console.log(`目标库:${database}`)
console.log(`模式:${apply ? "【真写】" : "只读预演(不改任何数据)"}`)
console.log(`时区锚点:${TIME_ZONE}\n`)
const plan = await audit()
report(plan)
if (!plan.metricFixes.length && !plan.revoke.length && !plan.grant.length && !plan.cascade.length) {
console.log("\n复核通过:发放与真实提交时间一致,无需修改。")
return 0
}
if (!apply) {
console.log("\n(只读预演结束。确认无误后加 --apply。)")
return 0
}
const { metricFixes, revoke, grant, cascade, targets, meta, touched } = plan
await db.transaction(async (tx) => {
for (const fix of metricFixes) {
// 只合并两个小时键,其余指标以库里当下的值为准(见文件头「只动两个小时键」)
await tx
.update(schema.userStat)
.set({
metrics: sql`${schema.userStat.metrics} || jsonb_build_object('midnight_submissions', ${fix.midnight}::int, 'early_bird_submissions', ${fix.earlyBird}::int)`,
})
.where(eq(schema.userStat.id, fix.id))
}
for (const target of targets) {
const ids = revoke.filter((item) => item.achievementId === target.id).map((item) => item.userId)
if (ids.length) {
await tx
.delete(schema.userAchievement)
.where(and(eq(schema.userAchievement.achievementId, target.id), inArray(schema.userAchievement.userId, ids)))
await tx
.update(schema.achievement)
.set({ unlockCount: sql`greatest(${schema.achievement.unlockCount} - ${ids.length}, 0)` })
.where(eq(schema.achievement.id, target.id))
}
// backfilled = true:这是事后对账补的,不编一个假的解锁时间;前端因此显示「已获得」
const rows = grant
.filter((item) => item.achievementId === target.id)
.map((item) => ({ userId: item.userId, achievementId: target.id, unlockTime: new Date().toISOString(), backfilled: true, notified: false }))
if (rows.length) {
await tx.insert(schema.userAchievement).values(rows)
await tx
.update(schema.achievement)
.set({ unlockCount: sql`${schema.achievement.unlockCount} + ${rows.length}` })
.where(eq(schema.achievement.id, target.id))
}
}
for (const item of cascade) {
await tx
.delete(schema.userAchievement)
.where(and(eq(schema.userAchievement.achievementId, item.achievementId), eq(schema.userAchievement.userId, item.userId)))
await tx
.update(schema.achievement)
.set({ unlockCount: sql`greatest(${schema.achievement.unlockCount} - 1, 0)` })
.where(eq(schema.achievement.id, item.achievementId))
}
// 最后把 achievement_unlocked_count 校正成「对账之后实际持有的非白金数」
if (meta && touched.length) {
const fresh = await tx
.select({ userId: schema.userAchievement.userId, value: sql<number>`count(*)`.mapWith(Number) })
.from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(and(inArray(schema.userAchievement.userId, touched), ne(schema.achievement.rarity, "platinum")))
.groupBy(schema.userAchievement.userId)
const byUser = new Map(fresh.map((row) => [row.userId, row.value]))
for (const userId of touched) {
await tx
.update(schema.userStat)
.set({
metrics: sql`jsonb_set(${schema.userStat.metrics}, '{achievement_unlocked_count}', ${JSON.stringify(byUser.get(userId) ?? 0)}::jsonb)`,
})
.where(eq(schema.userStat.userId, userId))
}
}
})
console.log(`\n已订正指标 ${metricFixes.length} 行、撤回 ${revoke.length} 条、补发 ${grant.length} 条、连锁撤销 ${cascade.length} 条,复核中……`)
// 复核跑的是同一份 audit。还剩差异说明口径或并发写入了,必须让调用方看见非零退出码。
const after = await audit()
const left = after.metricFixes.length + after.revoke.length + after.grant.length + after.cascade.length
if (left === 0) {
console.log("复核通过:发放与真实提交时间一致。")
return 0
}
console.error(`复核未通过,仍有 ${left} 处差异(多半是算完到写完之间有新提交判完,重跑一次即可):`)
report(after)
return 1
}
+1 -1
View File
@@ -24,7 +24,7 @@ import { metaAchievements, refreshUnlockedCount, rescanAchievement } from "../se
* *
* **不管**的:acm_contest_rank(比赛榜有自己的一套罚时累计,重算要连带 submission_info * **不管**的:acm_contest_rank(比赛榜有自己的一套罚时累计,重算要连带 submission_info
* 里每题的尝试次数,口径复杂,单独一件事)、achievement.unlock_count0010 之后 * 里每题的尝试次数,口径复杂,单独一件事)、achievement.unlock_count0010 之后
* user_achievement 随成就级联,漂不了)、题单进度与奖章(走 backfill-problemsets)。 * user_achievement 随成就级联,漂不了)、题单进度与奖章(判题这一路自己记账,见 services/problemset.ts)。
* *
* 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate * 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate
* *
-20
View File
@@ -193,24 +193,6 @@ export async function resyncProgress(problemsetId: number) {
for (const badge of badges) await recalculateBadge(badge, updated) for (const badge of badges) await recalculateBadge(badge, updated)
} }
/** 按奖章算出「现在应该有谁」,只读,供补发脚本先看后写 */
export async function badgeHolderDiff(badge: BadgeRow, known?: (BadgeCheck & { userId: number })[]) {
const [progresses, holders] = await Promise.all([
known ?? db.select().from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId)),
db.select({ userId: schema.userBadge.userId }).from(schema.userBadge)
.where(eq(schema.userBadge.badgeId, badge.id)),
])
const eligible = new Set(progresses.filter((item) => eligibleForBadge(badge, item)).map((item) => item.userId))
const have = new Set(holders.map((item) => item.userId))
return {
missing: [...eligible].filter((id) => !have.has(id)),
extra: [...have].filter((id) => !eligible.has(id)),
eligible: eligible.size,
held: have.size,
}
}
/** /**
* 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的题单。 * 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的题单。
* *
@@ -222,8 +204,6 @@ export async function badgeHolderDiff(badge: BadgeRow, known?: (BadgeCheck & { u
* 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。 * 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。
* *
* 不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积。 * 不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积。
* 更要紧的是这条规则必须和补账那条(scripts/backfill-problemsets.ts)一致 ——
* 两边口径不一样的话,补账工具会永远「发现」差异。
*/ */
export async function recordSolvedProblem( export async function recordSolvedProblem(
userId: number, userId: number,
+17 -13
View File
@@ -8,8 +8,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
`ojnext``../OnlineJudge` 都已下线且**完全冻结,一行都不改**)。Vue 3 + TypeScript `ojnext``../OnlineJudge` 都已下线且**完全冻结,一行都不改**)。Vue 3 + TypeScript
ViteRolldown 内核)、Naive UI、Pinia、Vue Router。 ViteRolldown 内核)、Naive UI、Pinia、Vue Router。
**要兼容机房的老 Chrome< 94**`vite.config.ts` 的 legacy 配置与 **浏览器基线是 Chrome 105**(机房部分电脑那一档,2026-09-16 从 < 94 上调):
`mermaid-legacy` 等 fallback 依赖不能动,理由写在该文件的注释里。 `vite.config.ts``@vitejs/plugin-legacy` 和写死的 polyfill 清单不能删 —— vite 8
默认 target 是 chrome111,比机房高。`mermaid-legacy` 那条 < 94 的 fallback 已删除。
理由写在该文件的注释里,详见 `../CLAUDE.md`
## Commands ## Commands
@@ -55,8 +57,8 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
跨端的(登录、资料、标签、验证码)在 `shared/api.ts` 跨端的(登录、资料、标签、验证码)在 `shared/api.ts`
Shared logic lives in `shared/`: 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) - `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), `breakpoints`, `maxkb` - `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 Transitions111 以下走降级分支), `hiddenStudents` (统计面板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity`
- `layout/``default.vue` and `admin.vue` layout wrappers - `layout/``default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha) - `api.ts` — shared API calls (auth, profile, tags, captcha)
@@ -138,7 +140,7 @@ return contract("GET /problems/:id", problemDetailSchema, value)
时区常量在契约 `@oj2/contract``TIME_ZONE_OFFSET_MINUTES`,和后端 `time.ts` 共用。 时区常量在契约 `@oj2/contract``TIME_ZONE_OFFSET_MINUTES`,和后端 `time.ts` 共用。
实现是「平移固定偏移 + 读 `getUTC*`」,不用 `Intl` 的时区选项:东八区没有夏令时, 实现是「平移固定偏移 + 读 `getUTC*`」,不用 `Intl` 的时区选项:东八区没有夏令时,
纯算术在表格里逐格调用也不费事,老 Chrome 上结果也一致 纯算术在表格里逐格调用也不费事,和后端 `time.ts` 算得一模一样
**`n-date-picker` 要平移**`admin/contest/detail.vue``admin/problemset/edit.vue`): **`n-date-picker` 要平移**`admin/contest/detail.vue``admin/problemset/edit.vue`):
Naive 的日期选择器按浏览器本地时区渲染、没有 `timezone` 属性,所以绑定值走 Naive 的日期选择器按浏览器本地时区渲染、没有 `timezone` 属性,所以绑定值走
@@ -156,17 +158,19 @@ Naive 的日期选择器按浏览器本地时区渲染、没有 `timezone` 属
### Environment Variables ### 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 | | Variable | Purpose |
|---|---| |---|---|
| `PUBLIC_OJ_URL` | Backend REST API base URL | | `PUBLIC_ENV` | 环境角标:`test` → 「测试版」,`dev` → 「开发版」,其余不显示 |
| `PUBLIC_WS_URL` | WebSocket server URL | | `PUBLIC_CODE_URL` | 代码分享服务(提交详情、题目页的「分享」) |
| `PUBLIC_ENV` | Environment name (dev/staging/production) | | `PUBLIC_JUDGE0_URL` | Judge0 API`utils/judge.ts` 的在线运行) |
| `PUBLIC_CODE_URL` | Code execution service | | `PUBLIC_MAXKB_URL` | 知识库问答挂件 |
| `PUBLIC_JUDGE0_URL` | Judge0 API | | `PUBLIC_ICONIFY_URL` | 自建 Iconify 图标源,不设则走公共 CDN |
| `PUBLIC_MAXKB_URL` | Knowledge base service |
| `PUBLIC_ICONIFY_URL` | Iconify icon CDN | 后端地址**不在这里**`utils/api.ts` 写死 `baseURL: "/api"`dev 由 `vite.config.ts`
proxy 转给 3000,线上由 Caddy 同源伺服。(原来的 `PUBLIC_OJ_URL` / `PUBLIC_WS_URL` 早已不存在。)
### Routing ### Routing
-1
View File
@@ -43,7 +43,6 @@
"lib0": "0.2.117", "lib0": "0.2.117",
"md-editor-v3": "^6.5.6", "md-editor-v3": "^6.5.6",
"mermaid": "^11.17.2", "mermaid": "^11.17.2",
"mermaid-legacy": "npm:mermaid@^9.4.3",
"naive-ui": "^2.45.2", "naive-ui": "^2.45.2",
"nanoid": "^6.0.1", "nanoid": "^6.0.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
-1
View File
@@ -1 +0,0 @@
declare module "mermaid-legacy"
@@ -19,8 +19,8 @@ import HelpRequestList from "./HelpRequestList.vue"
* *
* 这个组件静态 import 进来的话,整套 CodeMirrorview / state / language / * 这个组件静态 import 进来的话,整套 CodeMirrorview / state / language /
* autocomplete / lang-*)就跟着 App.vue 进了入口 chunk —— 首屏白白多下 640 KB * autocomplete / lang-*)就跟着 App.vue 进了入口 chunk —— 首屏白白多下 640 KB
* gzip 后 210 KB),而下面那个 v-if 决定了学生根本不渲染它机房那批 * gzip 后 210 KB),而下面那个 v-if 决定了学生根本不渲染它 —— 机房那批老机器
* Chrome 91 的老机器解析这些字节是实打实的开销。 * 解析这些字节是实打实的开销。
* *
* 拆成异步之后首页的 JS 从 1.9 MB 降到 1.3 MBgzip 642 KB → 428 KB), * 拆成异步之后首页的 JS 从 1.9 MB 降到 1.3 MBgzip 642 KB → 428 KB),
* 老师那边只是在第一次接单时多一次 chunk 请求。 * 老师那边只是在第一次接单时多一次 chunk 请求。
@@ -22,7 +22,7 @@ export function useDarkTransition() {
function toggleDark(event: MouseEvent) { function toggleDark(event: MouseEvent) {
if (!document.startViewTransition) { if (!document.startViewTransition) {
// 机房那批 Chrome 低于 94,没有 View Transitions,直接切、不做动画。 // View Transitions 要 Chrome 111,机房那批 105 没有,直接切、不做动画。
isDark.value = !isDark.value isDark.value = !isDark.value
return return
} }
+2 -24
View File
@@ -251,26 +251,14 @@ function applyFlowchartDisplayStyle(container: HTMLElement) {
svg.insertBefore(style, svg.firstChild) svg.insertBefore(style, svg.firstChild)
} }
function getChromeVersion(): number {
const match = navigator.userAgent.match(/Chrome\/(\d+)/)
return match ? parseInt(match[1]) : Infinity
}
let mermaidPromise: Promise<any> | null = null let mermaidPromise: Promise<any> | null = null
let mermaidIsLegacy = false
function loadMermaid(): Promise<any> { function loadMermaid(): Promise<any> {
// 缓存 Promise 而不是实例:同一屏里两个组件一起挂载时,缓存实例会让两边都 // 缓存 Promise 而不是实例:同一屏里两个组件一起挂载时,缓存实例会让两边都
// 落进 if (!mermaidInstance)import 两次、initialize 两次 // 落进 if (!mermaidInstance)import 两次、initialize 两次
if (!mermaidPromise) { if (!mermaidPromise) {
mermaidPromise = (async () => { mermaidPromise = (async () => {
let instance: any const instance = (await import("mermaid")).default
if (getChromeVersion() < 94) {
instance = (await import("mermaid-legacy")).default
mermaidIsLegacy = true
} else {
instance = (await import("mermaid")).default
}
instance.initialize({ instance.initialize({
startOnLoad: false, startOnLoad: false,
securityLevel: "strict", securityLevel: "strict",
@@ -313,17 +301,7 @@ export function useMermaid() {
try { try {
const m = await loadMermaid() const m = await loadMermaid()
const id = `mermaid-${getRandomId()}` const id = `mermaid-${getRandomId()}`
// v9 (mermaid-legacy): callback-based render(id, code, cb) const { svg } = await m.render(id, mermaidCode)
// v10+: Promise-based render(id, code) → { svg }
const svg = mermaidIsLegacy
? await new Promise<string>((resolve, reject) => {
try {
m.render(id, mermaidCode, resolve)
} catch (e) {
reject(e)
}
})
: (await m.render(id, mermaidCode)).svg
if (gen !== renderGeneration) return if (gen !== renderGeneration) return
container.innerHTML = svg container.innerHTML = svg
applyFlowchartDisplayStyle(container) applyFlowchartDisplayStyle(container)
+16 -21
View File
@@ -6,21 +6,25 @@ import AutoImport from "unplugin-auto-import/vite"
import Components from "unplugin-vue-components/vite" import Components from "unplugin-vue-components/vite"
import { NaiveUiResolver } from "unplugin-vue-components/resolvers" import { NaiveUiResolver } from "unplugin-vue-components/resolvers"
// 显式保留 Chrome 90 所需的运行时兼容项,避免 plugin-legacy 对每个产物执行 Babel 扫描。 // 机房电脑的 Chrome 是 105 一档,比 plugin-legacy 的现代基线(chrome>=105)正好压线,
// 升级前端依赖后需要重新审计此列表。 // 但比 vite 8 的默认构建 targetchrome111)低。所以这个插件必须留着:它同时管两件事
// —— 把 build.target 压到 chrome105/es2020,再给现代产物补 core-js 的 API polyfill。
//
// 下面这份清单是 `modernPolyfills: true` 自动探测出来的 50 项,抄下来写死:自动探测要
// 对每个产物跑一遍 Babel 扫描,构建从 3 秒涨到 12 秒。**升级前端依赖后重新审计一次**:
// DEBUG=vite:legacy bun run build # 会打印 modern polyfills 的全集
// 少一项就是老机器上一个静默的 TypeError(这批缺的大多是 Chrome 110+ 的
// Array.prototype.toSorted / Set 运算 / 迭代器辅助)。
const polyfills = [ const polyfills = [
"es.aggregate-error.cause",
"es.array-buffer.detached", "es.array-buffer.detached",
"es.array-buffer.transfer-to-fixed-length",
"es.array-buffer.transfer", "es.array-buffer.transfer",
"es.array.at", "es.array-buffer.transfer-to-fixed-length",
"es.array.find-last-index", "es.array.includes",
"es.array.push", "es.array.push",
"es.array.to-reversed", "es.array.to-reversed",
"es.array.to-sorted", "es.array.to-sorted",
"es.array.to-spliced", "es.array.to-spliced",
"es.array.with", "es.array.with",
"es.error.cause",
"es.iterator.constructor", "es.iterator.constructor",
"es.iterator.drop", "es.iterator.drop",
"es.iterator.every", "es.iterator.every",
@@ -34,9 +38,9 @@ const polyfills = [
"es.iterator.to-array", "es.iterator.to-array",
"es.json.parse", "es.json.parse",
"es.json.stringify", "es.json.stringify",
"es.map.get-or-insert-computed",
"es.map.get-or-insert", "es.map.get-or-insert",
"es.object.has-own", "es.map.get-or-insert-computed",
"es.regexp.escape",
"es.regexp.flags", "es.regexp.flags",
"es.set.difference.v2", "es.set.difference.v2",
"es.set.intersection.v2", "es.set.intersection.v2",
@@ -45,11 +49,6 @@ const polyfills = [
"es.set.is-superset-of.v2", "es.set.is-superset-of.v2",
"es.set.symmetric-difference.v2", "es.set.symmetric-difference.v2",
"es.set.union.v2", "es.set.union.v2",
"es.string.at-alternative",
"es.typed-array.at",
"es.typed-array.find-last-index",
"es.typed-array.find-last",
"es.typed-array.set",
"es.typed-array.to-reversed", "es.typed-array.to-reversed",
"es.typed-array.to-sorted", "es.typed-array.to-sorted",
"es.typed-array.with", "es.typed-array.with",
@@ -57,8 +56,8 @@ const polyfills = [
"es.uint8-array.set-from-hex", "es.uint8-array.set-from-hex",
"es.uint8-array.to-base64", "es.uint8-array.to-base64",
"es.uint8-array.to-hex", "es.uint8-array.to-hex",
"es.weak-map.get-or-insert-computed",
"es.weak-map.get-or-insert", "es.weak-map.get-or-insert",
"es.weak-map.get-or-insert-computed",
"esnext.array.group", "esnext.array.group",
"web.dom-exception.stack", "web.dom-exception.stack",
"web.immediate", "web.immediate",
@@ -95,11 +94,10 @@ export default defineConfig(({ mode }) => {
return { return {
plugins: [ plugins: [
vue(), vue(),
// 机房存在 Chrome 91 等旧浏览器:不做 SystemJS 双构建, // 不做 SystemJS 双构建,只给现代产物注入 polyfillmodernTargets 不写
// 只按使用情况给现代产物注入 core-js API polyfillArray.at 等) // 用插件自带的基线(chrome>=105 / firefox>=106 / safari>=16.4),正好是机房那档。
legacy({ legacy({
renderLegacyChunks: false, renderLegacyChunks: false,
modernTargets: "chrome>=90",
modernPolyfills: polyfills, modernPolyfills: polyfills,
}), }),
AutoImport({ AutoImport({
@@ -142,9 +140,6 @@ export default defineConfig(({ mode }) => {
envPrefix: "PUBLIC_", envPrefix: "PUBLIC_",
resolve: { resolve: {
alias: { alias: {
// mermaid-legacy (mermaid@9) 写死了 UMD 路径,新版 cytoscape 的 exports
// 不允许 import 条件访问它,转到 ESM 产物
"cytoscape/dist/cytoscape.umd.js": "cytoscape/dist/cytoscape.esm.mjs",
utils: fileURLToPath(new URL("./src/utils", import.meta.url)), utils: fileURLToPath(new URL("./src/utils", import.meta.url)),
oj: fileURLToPath(new URL("./src/oj", import.meta.url)), oj: fileURLToPath(new URL("./src/oj", import.meta.url)),
admin: fileURLToPath(new URL("./src/admin", import.meta.url)), admin: fileURLToPath(new URL("./src/admin", import.meta.url)),
+132 -149
View File
@@ -70,7 +70,6 @@
"lib0": "0.2.117", "lib0": "0.2.117",
"md-editor-v3": "^6.5.6", "md-editor-v3": "^6.5.6",
"mermaid": "^11.17.2", "mermaid": "^11.17.2",
"mermaid-legacy": "npm:mermaid@^9.4.3",
"naive-ui": "^2.45.2", "naive-ui": "^2.45.2",
"nanoid": "^6.0.1", "nanoid": "^6.0.1",
"normalize.css": "^8.0.1", "normalize.css": "^8.0.1",
@@ -110,185 +109,185 @@
"packages": { "packages": {
"@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "https://registry.npmjs.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "https://registry.npmjs.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "https://registry.npmjs.com/@babel/code-frame/-/code-frame-7.29.7.tgz", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "https://registry.npmjs.com/@babel/compat-data/-/compat-data-7.29.7.tgz", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "https://registry.npmjs.com/@babel/core/-/core-7.29.7.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@8.0.0", "https://registry.npmjs.com/@babel/generator/-/generator-8.0.0.tgz", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="], "@babel/generator": ["@babel/generator@8.0.0", "https://registry.npmjs.com/@babel/generator/-/generator-8.0.0.tgz", { "dependencies": { "@babel/parser": "^8.0.0", "@babel/types": "^8.0.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" } }, "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g=="],
"@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "https://registry.npmjs.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "https://registry.npmjs.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "https://registry.npmjs.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="], "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="],
"@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.29.7", "https://registry.npmjs.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg=="], "@babel/helper-create-regexp-features-plugin": ["@babel/helper-create-regexp-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg=="],
"@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "https://registry.npmjs.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="], "@babel/helper-define-polyfill-provider": ["@babel/helper-define-polyfill-provider@0.6.8", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "debug": "^4.4.3", "lodash.debounce": "^4.0.8", "resolve": "^1.22.11" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "https://registry.npmjs.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "https://registry.npmjs.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="], "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "https://registry.npmjs.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "https://registry.npmjs.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "https://registry.npmjs.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="], "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="],
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "https://registry.npmjs.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="], "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
"@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.29.7", "https://registry.npmjs.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-wrap-function": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og=="], "@babel/helper-remap-async-to-generator": ["@babel/helper-remap-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-wrap-function": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og=="],
"@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "https://registry.npmjs.com/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="], "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="],
"@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "https://registry.npmjs.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="], "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "https://registry.npmjs.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "https://registry.npmjs.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "https://registry.npmjs.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "https://registry.npmjs.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "https://registry.npmjs.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.29.7", "https://registry.npmjs.com/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", { "dependencies": { "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw=="], "@babel/helper-wrap-function": ["@babel/helper-wrap-function@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw=="],
"@babel/helpers": ["@babel/helpers@7.29.7", "https://registry.npmjs.com/@babel/helpers/-/helpers-7.29.7.tgz", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
"@babel/parser": ["@babel/parser@7.29.8", "https://registry.npmjs.com/@babel/parser/-/parser-7.29.8.tgz", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], "@babel/parser": ["@babel/parser@7.29.8", "https://registry.npmjs.com/@babel/parser/-/parser-7.29.8.tgz", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="],
"@babel/plugin-bugfix-firefox-class-in-computed-class-key": ["@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7", "https://registry.npmjs.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w=="], "@babel/plugin-bugfix-firefox-class-in-computed-class-key": ["@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w=="],
"@babel/plugin-bugfix-safari-class-field-initializer-scope": ["@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7", "https://registry.npmjs.com/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ=="], "@babel/plugin-bugfix-safari-class-field-initializer-scope": ["@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ=="],
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": ["@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7", "https://registry.npmjs.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ=="], "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": ["@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ=="],
"@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": ["@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7", "https://registry.npmjs.com/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA=="], "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": ["@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA=="],
"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": ["@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7", "https://registry.npmjs.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-transform-optional-chaining": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.13.0" } }, "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw=="], "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": ["@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-transform-optional-chaining": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.13.0" } }, "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw=="],
"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": ["@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7", "https://registry.npmjs.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw=="], "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": ["@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw=="],
"@babel/plugin-proposal-private-property-in-object": ["@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2", "https://registry.npmjs.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w=="], "@babel/plugin-proposal-private-property-in-object": ["@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2", "", { "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w=="],
"@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.29.7", "https://registry.npmjs.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw=="], "@babel/plugin-syntax-import-assertions": ["@babel/plugin-syntax-import-assertions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw=="],
"@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.29.7", "https://registry.npmjs.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg=="], "@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg=="],
"@babel/plugin-syntax-unicode-sets-regex": ["@babel/plugin-syntax-unicode-sets-regex@7.18.6", "https://registry.npmjs.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg=="], "@babel/plugin-syntax-unicode-sets-regex": ["@babel/plugin-syntax-unicode-sets-regex@7.18.6", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-plugin-utils": "^7.18.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg=="],
"@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ=="], "@babel/plugin-transform-arrow-functions": ["@babel/plugin-transform-arrow-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ=="],
"@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA=="], "@babel/plugin-transform-async-generator-functions": ["@babel/plugin-transform-async-generator-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA=="],
"@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w=="], "@babel/plugin-transform-async-to-generator": ["@babel/plugin-transform-async-to-generator@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-remap-async-to-generator": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w=="],
"@babel/plugin-transform-block-scoped-functions": ["@babel/plugin-transform-block-scoped-functions@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA=="], "@babel/plugin-transform-block-scoped-functions": ["@babel/plugin-transform-block-scoped-functions@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA=="],
"@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ=="], "@babel/plugin-transform-block-scoping": ["@babel/plugin-transform-block-scoping@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ=="],
"@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA=="], "@babel/plugin-transform-class-properties": ["@babel/plugin-transform-class-properties@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA=="],
"@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="], "@babel/plugin-transform-class-static-block": ["@babel/plugin-transform-class-static-block@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.12.0" } }, "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A=="],
"@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g=="], "@babel/plugin-transform-classes": ["@babel/plugin-transform-classes@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g=="],
"@babel/plugin-transform-computed-properties": ["@babel/plugin-transform-computed-properties@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/template": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA=="], "@babel/plugin-transform-computed-properties": ["@babel/plugin-transform-computed-properties@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/template": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA=="],
"@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="], "@babel/plugin-transform-destructuring": ["@babel/plugin-transform-destructuring@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg=="],
"@babel/plugin-transform-dotall-regex": ["@babel/plugin-transform-dotall-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ=="], "@babel/plugin-transform-dotall-regex": ["@babel/plugin-transform-dotall-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ=="],
"@babel/plugin-transform-duplicate-keys": ["@babel/plugin-transform-duplicate-keys@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ=="], "@babel/plugin-transform-duplicate-keys": ["@babel/plugin-transform-duplicate-keys@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ=="],
"@babel/plugin-transform-duplicate-named-capturing-groups-regex": ["@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA=="], "@babel/plugin-transform-duplicate-named-capturing-groups-regex": ["@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA=="],
"@babel/plugin-transform-dynamic-import": ["@babel/plugin-transform-dynamic-import@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg=="], "@babel/plugin-transform-dynamic-import": ["@babel/plugin-transform-dynamic-import@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg=="],
"@babel/plugin-transform-explicit-resource-management": ["@babel/plugin-transform-explicit-resource-management@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw=="], "@babel/plugin-transform-explicit-resource-management": ["@babel/plugin-transform-explicit-resource-management@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw=="],
"@babel/plugin-transform-exponentiation-operator": ["@babel/plugin-transform-exponentiation-operator@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ=="], "@babel/plugin-transform-exponentiation-operator": ["@babel/plugin-transform-exponentiation-operator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ=="],
"@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA=="], "@babel/plugin-transform-export-namespace-from": ["@babel/plugin-transform-export-namespace-from@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA=="],
"@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ=="], "@babel/plugin-transform-for-of": ["@babel/plugin-transform-for-of@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ=="],
"@babel/plugin-transform-function-name": ["@babel/plugin-transform-function-name@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg=="], "@babel/plugin-transform-function-name": ["@babel/plugin-transform-function-name@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg=="],
"@babel/plugin-transform-json-strings": ["@babel/plugin-transform-json-strings@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg=="], "@babel/plugin-transform-json-strings": ["@babel/plugin-transform-json-strings@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg=="],
"@babel/plugin-transform-literals": ["@babel/plugin-transform-literals@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw=="], "@babel/plugin-transform-literals": ["@babel/plugin-transform-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw=="],
"@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q=="], "@babel/plugin-transform-logical-assignment-operators": ["@babel/plugin-transform-logical-assignment-operators@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q=="],
"@babel/plugin-transform-member-expression-literals": ["@babel/plugin-transform-member-expression-literals@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg=="], "@babel/plugin-transform-member-expression-literals": ["@babel/plugin-transform-member-expression-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg=="],
"@babel/plugin-transform-modules-amd": ["@babel/plugin-transform-modules-amd@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew=="], "@babel/plugin-transform-modules-amd": ["@babel/plugin-transform-modules-amd@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew=="],
"@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="], "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="],
"@babel/plugin-transform-modules-systemjs": ["@babel/plugin-transform-modules-systemjs@7.29.8", "https://registry.npmjs.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.8" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA=="], "@babel/plugin-transform-modules-systemjs": ["@babel/plugin-transform-modules-systemjs@7.29.8", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.8" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA=="],
"@babel/plugin-transform-modules-umd": ["@babel/plugin-transform-modules-umd@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA=="], "@babel/plugin-transform-modules-umd": ["@babel/plugin-transform-modules-umd@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA=="],
"@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="], "@babel/plugin-transform-named-capturing-groups-regex": ["@babel/plugin-transform-named-capturing-groups-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ=="],
"@babel/plugin-transform-new-target": ["@babel/plugin-transform-new-target@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A=="], "@babel/plugin-transform-new-target": ["@babel/plugin-transform-new-target@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A=="],
"@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg=="], "@babel/plugin-transform-nullish-coalescing-operator": ["@babel/plugin-transform-nullish-coalescing-operator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg=="],
"@babel/plugin-transform-numeric-separator": ["@babel/plugin-transform-numeric-separator@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw=="], "@babel/plugin-transform-numeric-separator": ["@babel/plugin-transform-numeric-separator@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw=="],
"@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="], "@babel/plugin-transform-object-rest-spread": ["@babel/plugin-transform-object-rest-spread@7.29.7", "", { "dependencies": { "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A=="],
"@babel/plugin-transform-object-super": ["@babel/plugin-transform-object-super@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA=="], "@babel/plugin-transform-object-super": ["@babel/plugin-transform-object-super@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA=="],
"@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="], "@babel/plugin-transform-optional-catch-binding": ["@babel/plugin-transform-optional-catch-binding@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng=="],
"@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ=="], "@babel/plugin-transform-optional-chaining": ["@babel/plugin-transform-optional-chaining@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ=="],
"@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="], "@babel/plugin-transform-parameters": ["@babel/plugin-transform-parameters@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g=="],
"@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug=="], "@babel/plugin-transform-private-methods": ["@babel/plugin-transform-private-methods@7.29.7", "", { "dependencies": { "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug=="],
"@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA=="], "@babel/plugin-transform-private-property-in-object": ["@babel/plugin-transform-private-property-in-object@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA=="],
"@babel/plugin-transform-property-literals": ["@babel/plugin-transform-property-literals@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA=="], "@babel/plugin-transform-property-literals": ["@babel/plugin-transform-property-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA=="],
"@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.29.8", "https://registry.npmjs.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg=="], "@babel/plugin-transform-regenerator": ["@babel/plugin-transform-regenerator@7.29.8", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg=="],
"@babel/plugin-transform-regexp-modifiers": ["@babel/plugin-transform-regexp-modifiers@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ=="], "@babel/plugin-transform-regexp-modifiers": ["@babel/plugin-transform-regexp-modifiers@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ=="],
"@babel/plugin-transform-reserved-words": ["@babel/plugin-transform-reserved-words@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA=="], "@babel/plugin-transform-reserved-words": ["@babel/plugin-transform-reserved-words@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA=="],
"@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg=="], "@babel/plugin-transform-shorthand-properties": ["@babel/plugin-transform-shorthand-properties@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg=="],
"@babel/plugin-transform-spread": ["@babel/plugin-transform-spread@7.29.8", "https://registry.npmjs.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA=="], "@babel/plugin-transform-spread": ["@babel/plugin-transform-spread@7.29.8", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA=="],
"@babel/plugin-transform-sticky-regex": ["@babel/plugin-transform-sticky-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA=="], "@babel/plugin-transform-sticky-regex": ["@babel/plugin-transform-sticky-regex@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA=="],
"@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA=="], "@babel/plugin-transform-template-literals": ["@babel/plugin-transform-template-literals@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA=="],
"@babel/plugin-transform-typeof-symbol": ["@babel/plugin-transform-typeof-symbol@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A=="], "@babel/plugin-transform-typeof-symbol": ["@babel/plugin-transform-typeof-symbol@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A=="],
"@babel/plugin-transform-unicode-escapes": ["@babel/plugin-transform-unicode-escapes@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA=="], "@babel/plugin-transform-unicode-escapes": ["@babel/plugin-transform-unicode-escapes@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA=="],
"@babel/plugin-transform-unicode-property-regex": ["@babel/plugin-transform-unicode-property-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw=="], "@babel/plugin-transform-unicode-property-regex": ["@babel/plugin-transform-unicode-property-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw=="],
"@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA=="], "@babel/plugin-transform-unicode-regex": ["@babel/plugin-transform-unicode-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA=="],
"@babel/plugin-transform-unicode-sets-regex": ["@babel/plugin-transform-unicode-sets-regex@7.29.7", "https://registry.npmjs.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg=="], "@babel/plugin-transform-unicode-sets-regex": ["@babel/plugin-transform-unicode-sets-regex@7.29.7", "", { "dependencies": { "@babel/helper-create-regexp-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg=="],
"@babel/preset-env": ["@babel/preset-env@7.29.7", "https://registry.npmjs.com/@babel/preset-env/-/preset-env-7.29.7.tgz", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-import-assertions": "^7.29.7", "@babel/plugin-syntax-import-attributes": "^7.29.7", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.29.7", "@babel/plugin-transform-async-generator-functions": "^7.29.7", "@babel/plugin-transform-async-to-generator": "^7.29.7", "@babel/plugin-transform-block-scoped-functions": "^7.29.7", "@babel/plugin-transform-block-scoping": "^7.29.7", "@babel/plugin-transform-class-properties": "^7.29.7", "@babel/plugin-transform-class-static-block": "^7.29.7", "@babel/plugin-transform-classes": "^7.29.7", "@babel/plugin-transform-computed-properties": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-dotall-regex": "^7.29.7", "@babel/plugin-transform-duplicate-keys": "^7.29.7", "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", "@babel/plugin-transform-dynamic-import": "^7.29.7", "@babel/plugin-transform-explicit-resource-management": "^7.29.7", "@babel/plugin-transform-exponentiation-operator": "^7.29.7", "@babel/plugin-transform-export-namespace-from": "^7.29.7", "@babel/plugin-transform-for-of": "^7.29.7", "@babel/plugin-transform-function-name": "^7.29.7", "@babel/plugin-transform-json-strings": "^7.29.7", "@babel/plugin-transform-literals": "^7.29.7", "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", "@babel/plugin-transform-member-expression-literals": "^7.29.7", "@babel/plugin-transform-modules-amd": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-modules-systemjs": "^7.29.7", "@babel/plugin-transform-modules-umd": "^7.29.7", "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", "@babel/plugin-transform-new-target": "^7.29.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", "@babel/plugin-transform-numeric-separator": "^7.29.7", "@babel/plugin-transform-object-rest-spread": "^7.29.7", "@babel/plugin-transform-object-super": "^7.29.7", "@babel/plugin-transform-optional-catch-binding": "^7.29.7", "@babel/plugin-transform-optional-chaining": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/plugin-transform-private-methods": "^7.29.7", "@babel/plugin-transform-private-property-in-object": "^7.29.7", "@babel/plugin-transform-property-literals": "^7.29.7", "@babel/plugin-transform-regenerator": "^7.29.7", "@babel/plugin-transform-regexp-modifiers": "^7.29.7", "@babel/plugin-transform-reserved-words": "^7.29.7", "@babel/plugin-transform-shorthand-properties": "^7.29.7", "@babel/plugin-transform-spread": "^7.29.7", "@babel/plugin-transform-sticky-regex": "^7.29.7", "@babel/plugin-transform-template-literals": "^7.29.7", "@babel/plugin-transform-typeof-symbol": "^7.29.7", "@babel/plugin-transform-unicode-escapes": "^7.29.7", "@babel/plugin-transform-unicode-property-regex": "^7.29.7", "@babel/plugin-transform-unicode-regex": "^7.29.7", "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.15", "babel-plugin-polyfill-corejs3": "^0.14.0", "babel-plugin-polyfill-regenerator": "^0.6.6", "core-js-compat": "^3.48.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA=="], "@babel/preset-env": ["@babel/preset-env@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", "@babel/plugin-syntax-import-assertions": "^7.29.7", "@babel/plugin-syntax-import-attributes": "^7.29.7", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", "@babel/plugin-transform-arrow-functions": "^7.29.7", "@babel/plugin-transform-async-generator-functions": "^7.29.7", "@babel/plugin-transform-async-to-generator": "^7.29.7", "@babel/plugin-transform-block-scoped-functions": "^7.29.7", "@babel/plugin-transform-block-scoping": "^7.29.7", "@babel/plugin-transform-class-properties": "^7.29.7", "@babel/plugin-transform-class-static-block": "^7.29.7", "@babel/plugin-transform-classes": "^7.29.7", "@babel/plugin-transform-computed-properties": "^7.29.7", "@babel/plugin-transform-destructuring": "^7.29.7", "@babel/plugin-transform-dotall-regex": "^7.29.7", "@babel/plugin-transform-duplicate-keys": "^7.29.7", "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", "@babel/plugin-transform-dynamic-import": "^7.29.7", "@babel/plugin-transform-explicit-resource-management": "^7.29.7", "@babel/plugin-transform-exponentiation-operator": "^7.29.7", "@babel/plugin-transform-export-namespace-from": "^7.29.7", "@babel/plugin-transform-for-of": "^7.29.7", "@babel/plugin-transform-function-name": "^7.29.7", "@babel/plugin-transform-json-strings": "^7.29.7", "@babel/plugin-transform-literals": "^7.29.7", "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", "@babel/plugin-transform-member-expression-literals": "^7.29.7", "@babel/plugin-transform-modules-amd": "^7.29.7", "@babel/plugin-transform-modules-commonjs": "^7.29.7", "@babel/plugin-transform-modules-systemjs": "^7.29.7", "@babel/plugin-transform-modules-umd": "^7.29.7", "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", "@babel/plugin-transform-new-target": "^7.29.7", "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", "@babel/plugin-transform-numeric-separator": "^7.29.7", "@babel/plugin-transform-object-rest-spread": "^7.29.7", "@babel/plugin-transform-object-super": "^7.29.7", "@babel/plugin-transform-optional-catch-binding": "^7.29.7", "@babel/plugin-transform-optional-chaining": "^7.29.7", "@babel/plugin-transform-parameters": "^7.29.7", "@babel/plugin-transform-private-methods": "^7.29.7", "@babel/plugin-transform-private-property-in-object": "^7.29.7", "@babel/plugin-transform-property-literals": "^7.29.7", "@babel/plugin-transform-regenerator": "^7.29.7", "@babel/plugin-transform-regexp-modifiers": "^7.29.7", "@babel/plugin-transform-reserved-words": "^7.29.7", "@babel/plugin-transform-shorthand-properties": "^7.29.7", "@babel/plugin-transform-spread": "^7.29.7", "@babel/plugin-transform-sticky-regex": "^7.29.7", "@babel/plugin-transform-template-literals": "^7.29.7", "@babel/plugin-transform-typeof-symbol": "^7.29.7", "@babel/plugin-transform-unicode-escapes": "^7.29.7", "@babel/plugin-transform-unicode-property-regex": "^7.29.7", "@babel/plugin-transform-unicode-regex": "^7.29.7", "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.15", "babel-plugin-polyfill-corejs3": "^0.14.0", "babel-plugin-polyfill-regenerator": "^0.6.6", "core-js-compat": "^3.48.0", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA=="],
"@babel/preset-modules": ["@babel/preset-modules@0.1.6-no-external-plugins", "https://registry.npmjs.com/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", "esutils": "^2.0.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA=="], "@babel/preset-modules": ["@babel/preset-modules@0.1.6-no-external-plugins", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@babel/types": "^7.4.4", "esutils": "^2.0.2" }, "peerDependencies": { "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" } }, "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "https://registry.npmjs.com/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], "@babel/runtime": ["@babel/runtime@7.29.7", "https://registry.npmjs.com/@babel/runtime/-/runtime-7.29.7.tgz", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/template": ["@babel/template@7.29.7", "https://registry.npmjs.com/@babel/template/-/template-7.29.7.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.8", "https://registry.npmjs.com/@babel/traverse/-/traverse-7.29.8.tgz", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="],
"@babel/types": ["@babel/types@7.29.8", "https://registry.npmjs.com/@babel/types/-/types-7.29.8.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], "@babel/types": ["@babel/types@7.29.8", "https://registry.npmjs.com/@babel/types/-/types-7.29.8.tgz", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="],
@@ -434,7 +433,7 @@
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmjs.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "https://registry.npmjs.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "https://registry.npmjs.com/@jridgewell/source-map/-/source-map-0.3.11.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmjs.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "https://registry.npmjs.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
@@ -722,7 +721,7 @@
"@vavt/util": ["@vavt/util@2.1.2", "https://registry.npmjs.com/@vavt/util/-/util-2.1.2.tgz", {}, "sha512-L3UbSJthJwr3wq0x93O5TrCepimrmVZaIl2ciZbeL18G5++gBhJXNhcH7RcVk/6rr3SavWOvwhig0mqRLoR7dw=="], "@vavt/util": ["@vavt/util@2.1.2", "https://registry.npmjs.com/@vavt/util/-/util-2.1.2.tgz", {}, "sha512-L3UbSJthJwr3wq0x93O5TrCepimrmVZaIl2ciZbeL18G5++gBhJXNhcH7RcVk/6rr3SavWOvwhig0mqRLoR7dw=="],
"@vitejs/plugin-legacy": ["@vitejs/plugin-legacy@8.2.3", "https://registry.npmjs.com/@vitejs/plugin-legacy/-/plugin-legacy-8.2.3.tgz", { "dependencies": { "@babel/core": "^7.29.7", "@babel/plugin-transform-dynamic-import": "^7.29.7", "@babel/plugin-transform-modules-systemjs": "^7.29.8", "@babel/preset-env": "^7.29.7", "babel-plugin-polyfill-corejs3": "^0.14.2", "babel-plugin-polyfill-regenerator": "^0.6.8", "browserslist": "^4.28.7", "browserslist-to-esbuild": "^2.1.1", "core-js": "^3.49.0", "magic-string": "^1.1.0", "regenerator-runtime": "^0.14.1", "systemjs": "^6.15.1" }, "peerDependencies": { "terser": "^5.16.0", "vite": "^8.0.0" } }, "sha512-MMHLsn/0FrBkCQyb1x44cPYGwkfqcGr2FblcEP2ne79aXe7d5tFKoZ09xs5C4wuh8fOu2JRaNhgAS3K3uvUeJA=="], "@vitejs/plugin-legacy": ["@vitejs/plugin-legacy@8.2.3", "", { "dependencies": { "@babel/core": "^7.29.7", "@babel/plugin-transform-dynamic-import": "^7.29.7", "@babel/plugin-transform-modules-systemjs": "^7.29.8", "@babel/preset-env": "^7.29.7", "babel-plugin-polyfill-corejs3": "^0.14.2", "babel-plugin-polyfill-regenerator": "^0.6.8", "browserslist": "^4.28.7", "browserslist-to-esbuild": "^2.1.1", "core-js": "^3.49.0", "magic-string": "^1.1.0", "regenerator-runtime": "^0.14.1", "systemjs": "^6.15.1" }, "peerDependencies": { "terser": "^5.16.0", "vite": "^8.0.0" } }, "sha512-MMHLsn/0FrBkCQyb1x44cPYGwkfqcGr2FblcEP2ne79aXe7d5tFKoZ09xs5C4wuh8fOu2JRaNhgAS3K3uvUeJA=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.8", "https://registry.npmjs.com/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew=="], "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.8", "https://registry.npmjs.com/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", "vue": "^3.2.25" } }, "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew=="],
@@ -818,19 +817,19 @@
"axios": ["axios@1.19.0", "https://registry.npmjs.com/axios/-/axios-1.19.0.tgz", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw=="], "axios": ["axios@1.19.0", "https://registry.npmjs.com/axios/-/axios-1.19.0.tgz", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw=="],
"babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "https://registry.npmjs.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="], "babel-plugin-polyfill-corejs2": ["babel-plugin-polyfill-corejs2@0.4.17", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-define-polyfill-provider": "^0.6.8", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w=="],
"babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.14.2", "https://registry.npmjs.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8", "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g=="], "babel-plugin-polyfill-corejs3": ["babel-plugin-polyfill-corejs3@0.14.2", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8", "core-js-compat": "^3.48.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g=="],
"babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "https://registry.npmjs.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="], "babel-plugin-polyfill-regenerator": ["babel-plugin-polyfill-regenerator@0.6.8", "", { "dependencies": { "@babel/helper-define-polyfill-provider": "^0.6.8" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.12", "https://registry.npmjs.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.11.24", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg=="],
"birpc": ["birpc@2.9.0", "https://registry.npmjs.com/birpc/-/birpc-2.9.0.tgz", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], "birpc": ["birpc@2.9.0", "https://registry.npmjs.com/birpc/-/birpc-2.9.0.tgz", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
"browserslist": ["browserslist@4.28.7", "https://registry.npmjs.com/browserslist/-/browserslist-4.28.7.tgz", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="], "browserslist": ["browserslist@4.29.0", "", { "dependencies": { "baseline-browser-mapping": "^2.11.23", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.427", "node-releases": "^2.0.55", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA=="],
"browserslist-to-esbuild": ["browserslist-to-esbuild@2.1.1", "https://registry.npmjs.com/browserslist-to-esbuild/-/browserslist-to-esbuild-2.1.1.tgz", { "dependencies": { "meow": "^13.0.0" }, "peerDependencies": { "browserslist": "*" }, "bin": { "browserslist-to-esbuild": "cli/index.js" } }, "sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw=="], "browserslist-to-esbuild": ["browserslist-to-esbuild@2.1.1", "", { "dependencies": { "meow": "^13.0.0" }, "peerDependencies": { "browserslist": "*" }, "bin": { "browserslist-to-esbuild": "cli/index.js" } }, "sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw=="],
"buffer-from": ["buffer-from@1.1.2", "https://registry.npmjs.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "https://registry.npmjs.com/buffer-from/-/buffer-from-1.1.2.tgz", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
@@ -840,7 +839,7 @@
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmjs.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmjs.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001807", "https://registry.npmjs.com/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz", {}, "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q=="], "caniuse-lite": ["caniuse-lite@1.0.30001810", "", {}, "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg=="],
"canvas-confetti": ["canvas-confetti@1.9.4", "https://registry.npmjs.com/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="], "canvas-confetti": ["canvas-confetti@1.9.4", "https://registry.npmjs.com/canvas-confetti/-/canvas-confetti-1.9.4.tgz", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="],
@@ -862,13 +861,13 @@
"confbox": ["confbox@0.2.4", "https://registry.npmjs.com/confbox/-/confbox-0.2.4.tgz", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "confbox": ["confbox@0.2.4", "https://registry.npmjs.com/confbox/-/confbox-0.2.4.tgz", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
"convert-source-map": ["convert-source-map@2.0.0", "https://registry.npmjs.com/convert-source-map/-/convert-source-map-2.0.0.tgz", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"copy-text-to-clipboard": ["copy-text-to-clipboard@3.2.2", "https://registry.npmjs.com/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz", {}, "sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A=="], "copy-text-to-clipboard": ["copy-text-to-clipboard@3.2.2", "https://registry.npmjs.com/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz", {}, "sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A=="],
"core-js": ["core-js@3.50.0", "https://registry.npmjs.com/core-js/-/core-js-3.50.0.tgz", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="], "core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
"core-js-compat": ["core-js-compat@3.50.0", "https://registry.npmjs.com/core-js-compat/-/core-js-compat-3.50.0.tgz", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="], "core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="],
"cose-base": ["cose-base@1.0.3", "https://registry.npmjs.com/cose-base/-/cose-base-1.0.3.tgz", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], "cose-base": ["cose-base@1.0.3", "https://registry.npmjs.com/cose-base/-/cose-base-1.0.3.tgz", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
@@ -984,9 +983,7 @@
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmjs.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmjs.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.402", "https://registry.npmjs.com/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", {}, "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA=="], "electron-to-chromium": ["electron-to-chromium@1.5.430", "", {}, "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ=="],
"elkjs": ["elkjs@0.8.2", "https://registry.npmjs.com/elkjs/-/elkjs-0.8.2.tgz", {}, "sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ=="],
"entities": ["entities@4.5.0", "https://registry.npmjs.com/entities/-/entities-4.5.0.tgz", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "entities": ["entities@4.5.0", "https://registry.npmjs.com/entities/-/entities-4.5.0.tgz", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
@@ -1008,7 +1005,7 @@
"esbuild": ["esbuild@0.25.12", "https://registry.npmjs.com/esbuild/-/esbuild-0.25.12.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild": ["esbuild@0.25.12", "https://registry.npmjs.com/esbuild/-/esbuild-0.25.12.tgz", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "https://registry.npmjs.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmjs.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "escape-string-regexp": ["escape-string-regexp@5.0.0", "https://registry.npmjs.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
@@ -1016,7 +1013,7 @@
"estree-walker": ["estree-walker@3.0.3", "https://registry.npmjs.com/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "estree-walker": ["estree-walker@3.0.3", "https://registry.npmjs.com/estree-walker/-/estree-walker-3.0.3.tgz", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"esutils": ["esutils@2.0.3", "https://registry.npmjs.com/esutils/-/esutils-2.0.3.tgz", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"event-emitter": ["event-emitter@0.3.5", "https://registry.npmjs.com/event-emitter/-/event-emitter-0.3.5.tgz", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="], "event-emitter": ["event-emitter@0.3.5", "https://registry.npmjs.com/event-emitter/-/event-emitter-0.3.5.tgz", { "dependencies": { "d": "1", "es5-ext": "~0.10.14" } }, "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA=="],
@@ -1040,7 +1037,7 @@
"function-bind": ["function-bind@1.1.2", "https://registry.npmjs.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "https://registry.npmjs.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "https://registry.npmjs.com/gensync/-/gensync-1.0.0-beta.2.tgz", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmjs.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmjs.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
@@ -1078,7 +1075,7 @@
"ioredis": ["ioredis@6.0.0", "https://registry.npmjs.com/ioredis/-/ioredis-6.0.0.tgz", { "dependencies": { "@ioredis/commands": "2.0.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "standard-as-callback": "2.1.0" } }, "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w=="], "ioredis": ["ioredis@6.0.0", "https://registry.npmjs.com/ioredis/-/ioredis-6.0.0.tgz", { "dependencies": { "@ioredis/commands": "2.0.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "standard-as-callback": "2.1.0" } }, "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w=="],
"is-core-module": ["is-core-module@2.16.2", "https://registry.npmjs.com/is-core-module/-/is-core-module-2.16.2.tgz", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
"is-hotkey": ["is-hotkey@0.2.0", "https://registry.npmjs.com/is-hotkey/-/is-hotkey-0.2.0.tgz", {}, "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="], "is-hotkey": ["is-hotkey@0.2.0", "https://registry.npmjs.com/is-hotkey/-/is-hotkey-0.2.0.tgz", {}, "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw=="],
@@ -1088,7 +1085,7 @@
"isomorphic.js": ["isomorphic.js@0.2.5", "https://registry.npmjs.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="], "isomorphic.js": ["isomorphic.js@0.2.5", "https://registry.npmjs.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz", {}, "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw=="],
"js-tokens": ["js-tokens@4.0.0", "https://registry.npmjs.com/js-tokens/-/js-tokens-4.0.0.tgz", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsbi": ["jsbi@3.2.5", "https://registry.npmjs.com/jsbi/-/jsbi-3.2.5.tgz", {}, "sha512-aBE4n43IPvjaddScbvWRA2YlTzKEynHzu7MqOyTipdHucf/VxS63ViCjxYRg86M8Rxwbt/GfzHl1kKERkt45fQ=="], "jsbi": ["jsbi@3.2.5", "https://registry.npmjs.com/jsbi/-/jsbi-3.2.5.tgz", {}, "sha512-aBE4n43IPvjaddScbvWRA2YlTzKEynHzu7MqOyTipdHucf/VxS63ViCjxYRg86M8Rxwbt/GfzHl1kKERkt45fQ=="],
@@ -1148,7 +1145,7 @@
"lodash.toarray": ["lodash.toarray@4.4.0", "https://registry.npmjs.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz", {}, "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw=="], "lodash.toarray": ["lodash.toarray@4.4.0", "https://registry.npmjs.com/lodash.toarray/-/lodash.toarray-4.4.0.tgz", {}, "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw=="],
"lru-cache": ["lru-cache@5.1.1", "https://registry.npmjs.com/lru-cache/-/lru-cache-5.1.1.tgz", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"luxon": ["luxon@3.7.2", "https://registry.npmjs.com/luxon/-/luxon-3.7.2.tgz", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], "luxon": ["luxon@3.7.2", "https://registry.npmjs.com/luxon/-/luxon-3.7.2.tgz", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
@@ -1174,12 +1171,10 @@
"medium-zoom": ["medium-zoom@1.1.0", "https://registry.npmjs.com/medium-zoom/-/medium-zoom-1.1.0.tgz", {}, "sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ=="], "medium-zoom": ["medium-zoom@1.1.0", "https://registry.npmjs.com/medium-zoom/-/medium-zoom-1.1.0.tgz", {}, "sha512-ewyDsp7k4InCUp3jRmwHBRFGyjBimKps/AJLjRSox+2q/2H4p/PNpQf+pwONWlJiOudkBXtbdmVbFjqyybfTmQ=="],
"meow": ["meow@13.2.0", "https://registry.npmjs.com/meow/-/meow-13.2.0.tgz", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="],
"mermaid": ["mermaid@11.17.2", "https://registry.npmjs.com/mermaid/-/mermaid-11.17.2.tgz", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.21", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "fastdom": "1.0.12", "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg=="], "mermaid": ["mermaid@11.17.2", "https://registry.npmjs.com/mermaid/-/mermaid-11.17.2.tgz", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.21", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "fastdom": "1.0.12", "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg=="],
"mermaid-legacy": ["mermaid@9.4.3", "https://registry.npmjs.com/mermaid/-/mermaid-9.4.3.tgz", { "dependencies": { "@braintree/sanitize-url": "^6.0.0", "cytoscape": "^3.23.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.1.0", "d3": "^7.4.0", "dagre-d3-es": "7.0.9", "dayjs": "^1.11.7", "dompurify": "2.4.3", "elkjs": "^0.8.2", "khroma": "^2.0.0", "lodash-es": "^4.17.21", "non-layered-tidy-tree-layout": "^2.0.2", "stylis": "^4.1.2", "ts-dedent": "^2.2.0", "uuid": "^9.0.0", "web-worker": "^1.2.0" } }, "sha512-TLkQEtqhRSuEHSE34lh5bCa94KATCyluAXmFnNI2PRZwOpXFeqiJWwZl+d2CcemE1RS6QbbueSSq9QIg8Uxcyw=="],
"mime-db": ["mime-db@1.52.0", "https://registry.npmjs.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "mime-db": ["mime-db@1.52.0", "https://registry.npmjs.com/mime-db/-/mime-db-1.52.0.tgz", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-match": ["mime-match@1.0.2", "https://registry.npmjs.com/mime-match/-/mime-match-1.0.2.tgz", { "dependencies": { "wildcard": "^1.1.0" } }, "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg=="], "mime-match": ["mime-match@1.0.2", "https://registry.npmjs.com/mime-match/-/mime-match-1.0.2.tgz", { "dependencies": { "wildcard": "^1.1.0" } }, "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg=="],
@@ -1212,9 +1207,7 @@
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "https://registry.npmjs.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "https://registry.npmjs.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
"node-releases": ["node-releases@2.0.53", "https://registry.npmjs.com/node-releases/-/node-releases-2.0.53.tgz", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], "node-releases": ["node-releases@2.0.55", "", {}, "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ=="],
"non-layered-tidy-tree-layout": ["non-layered-tidy-tree-layout@2.0.2", "https://registry.npmjs.com/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", {}, "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw=="],
"normalize.css": ["normalize.css@8.0.1", "https://registry.npmjs.com/normalize.css/-/normalize.css-8.0.1.tgz", {}, "sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg=="], "normalize.css": ["normalize.css@8.0.1", "https://registry.npmjs.com/normalize.css/-/normalize.css-8.0.1.tgz", {}, "sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg=="],
@@ -1230,7 +1223,7 @@
"path-data-parser": ["path-data-parser@0.1.0", "https://registry.npmjs.com/path-data-parser/-/path-data-parser-0.1.0.tgz", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], "path-data-parser": ["path-data-parser@0.1.0", "https://registry.npmjs.com/path-data-parser/-/path-data-parser-0.1.0.tgz", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
"path-parse": ["path-parse@1.0.7", "https://registry.npmjs.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"pathe": ["pathe@2.0.3", "https://registry.npmjs.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathe": ["pathe@2.0.3", "https://registry.npmjs.com/pathe/-/pathe-2.0.3.tgz", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -1242,7 +1235,7 @@
"pinia": ["pinia@4.0.3", "https://registry.npmjs.com/pinia/-/pinia-4.0.3.tgz", { "dependencies": { "nostics": "^1.1.4" }, "peerDependencies": { "@vue/devtools-api": "^8.1.5", "typescript": ">=5.6.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA=="], "pinia": ["pinia@4.0.3", "https://registry.npmjs.com/pinia/-/pinia-4.0.3.tgz", { "dependencies": { "nostics": "^1.1.4" }, "peerDependencies": { "@vue/devtools-api": "^8.1.5", "typescript": ">=5.6.0", "vue": "^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA=="],
"pinyin-pro": ["pinyin-pro@3.29.3", "", {}, "sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ=="], "pinyin-pro": ["pinyin-pro@3.29.3", "https://registry.npmjs.com/pinyin-pro/-/pinyin-pro-3.29.3.tgz", {}, "sha512-+UU9bx6vfDw8amOJGHm0TE0rdQl8VPylsDWviQ5OOQ3e+on1xRP4OqDbiDuMT5OISgvfl/Y6ez1BBRaIP80GLQ=="],
"pkg-types": ["pkg-types@2.3.1", "https://registry.npmjs.com/pkg-types/-/pkg-types-2.3.1.tgz", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], "pkg-types": ["pkg-types@2.3.1", "https://registry.npmjs.com/pkg-types/-/pkg-types-2.3.1.tgz", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="],
@@ -1270,19 +1263,19 @@
"redis-errors": ["redis-errors@1.2.0", "https://registry.npmjs.com/redis-errors/-/redis-errors-1.2.0.tgz", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-errors": ["redis-errors@1.2.0", "https://registry.npmjs.com/redis-errors/-/redis-errors-1.2.0.tgz", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"regenerate": ["regenerate@1.4.2", "https://registry.npmjs.com/regenerate/-/regenerate-1.4.2.tgz", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],
"regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "https://registry.npmjs.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="], "regenerate-unicode-properties": ["regenerate-unicode-properties@10.2.2", "", { "dependencies": { "regenerate": "^1.4.2" } }, "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g=="],
"regenerator-runtime": ["regenerator-runtime@0.14.1", "https://registry.npmjs.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="], "regenerator-runtime": ["regenerator-runtime@0.14.1", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
"regexpu-core": ["regexpu-core@6.4.0", "https://registry.npmjs.com/regexpu-core/-/regexpu-core-6.4.0.tgz", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="], "regexpu-core": ["regexpu-core@6.4.0", "", { "dependencies": { "regenerate": "^1.4.2", "regenerate-unicode-properties": "^10.2.2", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" } }, "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA=="],
"regjsgen": ["regjsgen@0.8.0", "https://registry.npmjs.com/regjsgen/-/regjsgen-0.8.0.tgz", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="], "regjsgen": ["regjsgen@0.8.0", "", {}, "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q=="],
"regjsparser": ["regjsparser@0.13.2", "https://registry.npmjs.com/regjsparser/-/regjsparser-0.13.2.tgz", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ=="], "regjsparser": ["regjsparser@0.13.2", "", { "dependencies": { "jsesc": "~3.1.0" }, "bin": { "regjsparser": "bin/parser" } }, "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ=="],
"resolve": ["resolve@1.22.12", "https://registry.npmjs.com/resolve/-/resolve-1.22.12.tgz", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmjs.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "https://registry.npmjs.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
@@ -1334,11 +1327,11 @@
"stylis": ["stylis@4.4.0", "https://registry.npmjs.com/stylis/-/stylis-4.4.0.tgz", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="], "stylis": ["stylis@4.4.0", "https://registry.npmjs.com/stylis/-/stylis-4.4.0.tgz", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmjs.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"systemjs": ["systemjs@6.15.1", "https://registry.npmjs.com/systemjs/-/systemjs-6.15.1.tgz", {}, "sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA=="], "systemjs": ["systemjs@6.15.1", "", {}, "sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA=="],
"terser": ["terser@5.49.2", "https://registry.npmjs.com/terser/-/terser-5.49.2.tgz", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-rGbJiKeQ4WDe3EXlDAIaQcwftVfv2Q8o1awFNfvXolJYKkb1AuZY1RTOmqx4LJXZENbWZA7eIsYGHuEzHsi1nQ=="], "terser": ["terser@5.51.2", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q=="],
"tinyexec": ["tinyexec@1.3.0", "https://registry.npmjs.com/tinyexec/-/tinyexec-1.3.0.tgz", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], "tinyexec": ["tinyexec@1.3.0", "https://registry.npmjs.com/tinyexec/-/tinyexec-1.3.0.tgz", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
@@ -1368,13 +1361,13 @@
"undici-types": ["undici-types@8.3.0", "https://registry.npmjs.com/undici-types/-/undici-types-8.3.0.tgz", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "undici-types": ["undici-types@8.3.0", "https://registry.npmjs.com/undici-types/-/undici-types-8.3.0.tgz", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "https://registry.npmjs.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="], "unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
"unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "https://registry.npmjs.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="], "unicode-match-property-ecmascript": ["unicode-match-property-ecmascript@2.0.0", "", { "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" } }, "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q=="],
"unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "https://registry.npmjs.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="], "unicode-match-property-value-ecmascript": ["unicode-match-property-value-ecmascript@2.2.1", "", {}, "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg=="],
"unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "https://registry.npmjs.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="], "unicode-property-aliases-ecmascript": ["unicode-property-aliases-ecmascript@2.2.0", "", {}, "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ=="],
"unimport": ["unimport@6.4.0", "https://registry.npmjs.com/unimport/-/unimport-6.4.0.tgz", { "dependencies": { "acorn": "^8.18.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.2.1", "magic-string": "^1.1.0", "mlly": "^1.8.2", "pathe": "^2.0.3", "picomatch": "^4.0.5", "pkg-types": "^2.3.1", "scule": "^1.3.0", "strip-literal": "^4.0.0", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "oxc-parser": "*", "rolldown": "^1.0.0" }, "optionalPeers": ["oxc-parser", "rolldown"] }, "sha512-JJOOuNMFq8b4ZPBKwQUxEcba4MplskDzYI1Lvrf8rJfWphZTWvPNXWa493qsPngHUmub89w6C7j+SeLWTE/UIQ=="], "unimport": ["unimport@6.4.0", "https://registry.npmjs.com/unimport/-/unimport-6.4.0.tgz", { "dependencies": { "acorn": "^8.18.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.2.1", "magic-string": "^1.1.0", "mlly": "^1.8.2", "pathe": "^2.0.3", "picomatch": "^4.0.5", "pkg-types": "^2.3.1", "scule": "^1.3.0", "strip-literal": "^4.0.0", "tinyglobby": "^0.2.17", "unplugin": "^3.3.0", "unplugin-utils": "^0.3.2" }, "peerDependencies": { "oxc-parser": "*", "rolldown": "^1.0.0" }, "optionalPeers": ["oxc-parser", "rolldown"] }, "sha512-JJOOuNMFq8b4ZPBKwQUxEcba4MplskDzYI1Lvrf8rJfWphZTWvPNXWa493qsPngHUmub89w6C7j+SeLWTE/UIQ=="],
@@ -1386,7 +1379,7 @@
"unplugin-vue-components": ["unplugin-vue-components@32.1.0", "https://registry.npmjs.com/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz", { "dependencies": { "chokidar": "^5.0.0", "local-pkg": "^1.2.0", "magic-string": "^0.30.21", "mlly": "^1.8.2", "obug": "^2.1.1", "picomatch": "^4.0.4", "tinyglobby": "^0.2.16", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "@nuxt/kit": "^3.2.2 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@nuxt/kit"] }, "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg=="], "unplugin-vue-components": ["unplugin-vue-components@32.1.0", "https://registry.npmjs.com/unplugin-vue-components/-/unplugin-vue-components-32.1.0.tgz", { "dependencies": { "chokidar": "^5.0.0", "local-pkg": "^1.2.0", "magic-string": "^0.30.21", "mlly": "^1.8.2", "obug": "^2.1.1", "picomatch": "^4.0.4", "tinyglobby": "^0.2.16", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "@nuxt/kit": "^3.2.2 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@nuxt/kit"] }, "sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg=="],
"update-browserslist-db": ["update-browserslist-db@1.3.0", "https://registry.npmjs.com/update-browserslist-db/-/update-browserslist-db-1.3.0.tgz", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-x/M6q3w4Ybp91CNaS4S69UnliqR3BzRpOT6LWbksjth0S/+jhfaPJsWjt/TewpT8j9eLIojUf5jr29WextHroA=="], "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="],
"uuid": ["uuid@14.0.1", "https://registry.npmjs.com/uuid/-/uuid-14.0.1.tgz", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], "uuid": ["uuid@14.0.1", "https://registry.npmjs.com/uuid/-/uuid-14.0.1.tgz", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
@@ -1416,8 +1409,6 @@
"web-tree-sitter": ["web-tree-sitter@0.26.13", "https://registry.npmjs.com/web-tree-sitter/-/web-tree-sitter-0.26.13.tgz", {}, "sha512-5bUZ7vbQ1kcondet96wzP974+JfCZDeQ7bTpacICm2nnvHpa5cO0ByRsoMcAhUP+743vpkb4m0BFlVSm+Ye9VA=="], "web-tree-sitter": ["web-tree-sitter@0.26.13", "https://registry.npmjs.com/web-tree-sitter/-/web-tree-sitter-0.26.13.tgz", {}, "sha512-5bUZ7vbQ1kcondet96wzP974+JfCZDeQ7bTpacICm2nnvHpa5cO0ByRsoMcAhUP+743vpkb4m0BFlVSm+Ye9VA=="],
"web-worker": ["web-worker@1.5.0", "https://registry.npmjs.com/web-worker/-/web-worker-1.5.0.tgz", {}, "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "https://registry.npmjs.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "https://registry.npmjs.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"wildcard": ["wildcard@1.1.2", "https://registry.npmjs.com/wildcard/-/wildcard-1.1.2.tgz", {}, "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng=="], "wildcard": ["wildcard@1.1.2", "https://registry.npmjs.com/wildcard/-/wildcard-1.1.2.tgz", {}, "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng=="],
@@ -1428,7 +1419,7 @@
"y-protocols": ["y-protocols@1.0.7", "https://registry.npmjs.com/y-protocols/-/y-protocols-1.0.7.tgz", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="], "y-protocols": ["y-protocols@1.0.7", "https://registry.npmjs.com/y-protocols/-/y-protocols-1.0.7.tgz", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],
"yallist": ["yallist@3.1.1", "https://registry.npmjs.com/yallist/-/yallist-3.1.1.tgz", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yaml": ["yaml@2.9.0", "https://registry.npmjs.com/yaml/-/yaml-2.9.0.tgz", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yaml": ["yaml@2.9.0", "https://registry.npmjs.com/yaml/-/yaml-2.9.0.tgz", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
@@ -1436,23 +1427,23 @@
"zod": ["zod@4.4.3", "https://registry.npmjs.com/zod/-/zod-4.4.3.tgz", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod": ["zod@4.4.3", "https://registry.npmjs.com/zod/-/zod-4.4.3.tgz", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@babel/core/@babel/generator": ["@babel/generator@7.29.8", "https://registry.npmjs.com/@babel/generator/-/generator-7.29.8.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], "@babel/core/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@babel/core/semver": ["semver@6.3.1", "https://registry.npmjs.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/generator/@babel/parser": ["@babel/parser@8.0.4", "https://registry.npmjs.com/@babel/parser/-/parser-8.0.4.tgz", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], "@babel/generator/@babel/parser": ["@babel/parser@8.0.4", "https://registry.npmjs.com/@babel/parser/-/parser-8.0.4.tgz", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="],
"@babel/generator/@babel/types": ["@babel/types@8.0.4", "https://registry.npmjs.com/@babel/types/-/types-8.0.4.tgz", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], "@babel/generator/@babel/types": ["@babel/types@8.0.4", "https://registry.npmjs.com/@babel/types/-/types-8.0.4.tgz", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "https://registry.npmjs.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "https://registry.npmjs.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "https://registry.npmjs.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/preset-env/semver": ["semver@6.3.1", "https://registry.npmjs.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/traverse/@babel/generator": ["@babel/generator@7.29.8", "https://registry.npmjs.com/@babel/generator/-/generator-7.29.8.tgz", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="], "@babel/traverse/@babel/generator": ["@babel/generator@7.29.8", "", { "dependencies": { "@babel/parser": "^7.29.8", "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "https://registry.npmjs.com/esbuild/-/esbuild-0.18.20.tgz", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "https://registry.npmjs.com/esbuild/-/esbuild-0.18.20.tgz", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
@@ -1482,7 +1473,7 @@
"@wangeditor-next/video-module/nanoid": ["nanoid@5.1.16", "https://registry.npmjs.com/nanoid/-/nanoid-5.1.16.tgz", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="], "@wangeditor-next/video-module/nanoid": ["nanoid@5.1.16", "https://registry.npmjs.com/nanoid/-/nanoid-5.1.16.tgz", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ=="],
"babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "https://registry.npmjs.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"css-render/csstype": ["csstype@3.0.11", "https://registry.npmjs.com/csstype/-/csstype-3.0.11.tgz", {}, "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw=="], "css-render/csstype": ["csstype@3.0.11", "https://registry.npmjs.com/csstype/-/csstype-3.0.11.tgz", {}, "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw=="],
@@ -1500,14 +1491,6 @@
"magic-string-ast/magic-string": ["magic-string@0.30.21", "https://registry.npmjs.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string-ast/magic-string": ["magic-string@0.30.21", "https://registry.npmjs.com/magic-string/-/magic-string-0.30.21.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mermaid-legacy/@braintree/sanitize-url": ["@braintree/sanitize-url@6.0.4", "https://registry.npmjs.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", {}, "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A=="],
"mermaid-legacy/dagre-d3-es": ["dagre-d3-es@7.0.9", "https://registry.npmjs.com/dagre-d3-es/-/dagre-d3-es-7.0.9.tgz", { "dependencies": { "d3": "^7.8.2", "lodash-es": "^4.17.21" } }, "sha512-rYR4QfVmy+sR44IBDvVtcAmOReGBvRCWDpO2QjYwqgh9yijw6eSHBqaPG/LIOEy7aBsniLvtMW6pg19qJhq60w=="],
"mermaid-legacy/dompurify": ["dompurify@2.4.3", "https://registry.npmjs.com/dompurify/-/dompurify-2.4.3.tgz", {}, "sha512-q6QaLcakcRjebxjg8/+NP+h0rPfatOgOzc46Fst9VAA3jF2ApfKBNKMzdP4DYTqtUMXSCd5pRS/8Po/OmoCHZQ=="],
"mermaid-legacy/uuid": ["uuid@9.0.1", "https://registry.npmjs.com/uuid/-/uuid-9.0.1.tgz", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"mlly/pkg-types": ["pkg-types@1.3.1", "https://registry.npmjs.com/pkg-types/-/pkg-types-1.3.1.tgz", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "mlly/pkg-types": ["pkg-types@1.3.1", "https://registry.npmjs.com/pkg-types/-/pkg-types-1.3.1.tgz", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"postcss/nanoid": ["nanoid@3.3.17", "https://registry.npmjs.com/nanoid/-/nanoid-3.3.17.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="], "postcss/nanoid": ["nanoid@3.3.17", "https://registry.npmjs.com/nanoid/-/nanoid-3.3.17.tgz", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="],
+9 -3
View File
@@ -1,9 +1,17 @@
# 本机开发用的依赖服务。判题沙箱单独放,因为它要挂载测试用例目录。 # 本机开发用的依赖服务。判题沙箱单独放,因为它要挂载测试用例目录。
# #
# 起: docker compose -f docker/compose.dev.yml up -d # 起: docker compose -f docker/compose.dev.yml up -d = bun run db:up
# 停: docker compose -f docker/compose.dev.yml down # 停: docker compose -f docker/compose.dev.yml down
# 清空:docker compose -f docker/compose.dev.yml down -v ← 会删掉库里的数据 # 清空:docker compose -f docker/compose.dev.yml down -v ← 会删掉库里的数据
# #
# 起完库是空的,结构由迁移自己建:
#
# bun run db:migrate # 空库从 0000 自举出全部结构
# bun run seed:dev # 可选,建两个本机用的账号
#
# 以前这里挂一份 schema dump 当 initdb,现在不需要了 —— 0000 就是完整的建表迁移,
# 而且和「灌 dump + 打基线 + 跑迁移」跑出来的结构实测零差异(见 docs/database.md)。
#
# PostgreSQL 固定 16,与生产版本(16.10)一致 —— 别图新换 17/18, # PostgreSQL 固定 16,与生产版本(16.10)一致 —— 别图新换 17/18,
# 开发环境用了生产不支持的特性,要到切换那天才炸。 # 开发环境用了生产不支持的特性,要到切换那天才炸。
# #
@@ -30,8 +38,6 @@ services:
- "5433:5432" # 5433 避开可能存在的本机 5432 - "5433:5432" # 5433 避开可能存在的本机 5432
volumes: volumes:
- oj2-pgdata:/var/lib/postgresql/data - oj2-pgdata:/var/lib/postgresql/data
# 首次启动时自动灌入生产库结构(仅结构,无数据)
- ../docs/specs/schema.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U onlinejudge -d onlinejudge"] test: ["CMD-SHELL", "pg_isready -U onlinejudge -d onlinejudge"]
interval: 5s interval: 5s
+1 -1
View File
@@ -27,7 +27,7 @@
# 源码也要重编 160s。CI 那条路(--prebuilt)反过来必须传产物,它自己的 rsync 在 # 源码也要重编 160s。CI 那条路(--prebuilt)反过来必须传产物,它自己的 rsync 在
# .github/workflows/deploy.yml 里,别照抄这条。 # .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),而下面那行 # `sh docker/deploy.sh` 会用 dash 跑(Debian 的 /bin/sh 就是 dash),而下面那行
# 的 pipefail 是 bash 专有的,一上来就报 `Illegal option -o pipefail`。 # 的 pipefail 是 bash 专有的,一上来就报 `Illegal option -o pipefail`。
+51
View File
@@ -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 里看不到那组规则,保存却被拦下。
+47
View File
@@ -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。
+135
View File
@@ -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 才炸出来。这是当初难定位的原因。
+133
View File
@@ -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`apt269MB、二进制 112MB、基底 79MB、`ruff` 28MB |
| `oj2-web` | 75MB | |
一半以上是 clang-format 拖进来的 LLVM`libLLVM.so` 一个就 124MB)。设计文档当初写的
「降至数十 MB」没做到,就是漏算了它。想再瘦只有一条路:换成 PyPI 那个静态链接的
clang-format 独立二进制,能砍掉约 265MB。没做 —— 镜像是各站点本地构建的、不走镜像仓库,
磁盘不是瓶颈。
@@ -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。注意其中两条不符合"`<app>/urls/{oj,admin}.py`"的命名惯例:`tutorial.urls.tutorial`(目录里但文件名不叫 oj)与 `utils.urls`(模块文件,没有 `urls/` 目录)。**提取器必须以 `oj/urls.py` 为唯一入口,不得按文件名白名单猜。**
- 前端调用路径基线:**148 条 `method + path` / 104 条不同路径**(含模板字符串,`${...}` 归一化为 `:param`)。计划初稿写的 78 是只数字面量、不含模板串和泛型 `get<T>(...)` 的旧口径,已作废。
- 反向对账基线:前端调用路径全部能在后端端点全集里找到对应,**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`)扫 `<app>/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`,否则无法与后端的 `<int:pk>` 之类对账。
- [ ] **Step 1: 写提取脚本**
```typescript
#!/usr/bin/env bun
// 从 ojnext 提取前端实际发起的 API 调用
import { readdirSync, readFileSync, statSync } from "node:fs"
import { join, relative } from "node:path"
const ROOT = process.argv[2] ?? "/home/xuyue/Projects/OJ/ojnext"
const SRC = join(ROOT, "src")
type Call = { file: string; fn: string; method: string; path: string }
function walkFiles(dir: string, out: string[] = []): string[] {
for (const e of readdirSync(dir)) {
const p = join(dir, e)
if (statSync(p).isDirectory()) walkFiles(p, out)
else if (/\.(ts|vue)$/.test(p)) out.push(p)
}
return out
}
// 同时吃 get("x") 和 get(`x/${id}`)
const CALL_RE = /\b(get|post|put|delete)\(\s*(["'`])([^"'`]*)\2/g
// 就近向上找所属的导出函数名
const FN_RE = /export\s+(?:async\s+)?function\s+(\w+)|export\s+const\s+(\w+)\s*=/g
function normalize(p: string): string {
return p.replace(/\$\{[^}]*\}/g, ":param").replace(/^\/+/, "")
}
const calls: Call[] = []
for (const file of walkFiles(SRC)) {
const src = readFileSync(file, "utf8")
// 先建立 "偏移量 -> 函数名" 的索引
const fns: { at: number; name: string }[] = []
FN_RE.lastIndex = 0
let f: RegExpExecArray | null
while ((f = FN_RE.exec(src)) !== null) fns.push({ at: f.index, name: f[1] ?? f[2] })
CALL_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = CALL_RE.exec(src)) !== null) {
const path = normalize(m[3])
if (!path || path.startsWith("http")) continue // 跳过外部 URL 和空串
const owner = fns.filter((x) => x.at < m!.index).pop()
calls.push({
file: relative(ROOT, file),
fn: owner?.name ?? "",
method: m[1],
path,
})
}
}
const uniq = new Set(calls.map((c) => `${c.method} ${c.path}`))
console.log(`前端调用点 ${calls.length} 处,去重后 ${uniq.size} 条`)
await Bun.write("endpoints-frontend.json", JSON.stringify(calls, null, 2))
console.log("→ endpoints-frontend.json")
```
- [ ] **Step 2: 运行并核对基线**
```bash
cd /home/xuyue/Projects/OJ/OJ2/docs/spikes
bun run extract-frontend-calls.ts /home/xuyue/Projects/OJ/ojnext
```
预期:`前端调用点 154 处,去重后 148 条`148 是 `method + path` 去重;只按 path 去重是 104 条)。若明显低于此数,说明正则漏了写法,检查 `src/utils/http.ts` 里 http 客户端的实际调用形式再修 —— 泛型 `get<T>("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`;后端有 `<int:pk>` 之类占位符,前端归一化成了 `: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`,归一化规则单点定义。
-675
View File
@@ -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<typeof problemSummarySchema>
```
- [ ] **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
<script setup lang="ts">
import { ref, onMounted } from "vue"
import type { ProblemSummary } from "@oj2/contract"
const problems = ref<ProblemSummary[]>([])
const error = ref("")
onMounted(async () => {
try {
const res = await fetch("/api2/problems")
const body = await res.json()
problems.value = body.data
} catch (e) {
error.value = String(e)
}
})
</script>
<template>
<div style="padding: 24px">
<h2>阶段 1 链路验证临时页阶段 3 删除</h2>
<p v-if="error" style="color: red">{{ error }}</p>
<p> {{ problems.length }} 道题</p>
<ul>
<li v-for="p in problems" :key="p.id">
{{ p._id }} {{ p.title }}{{ p.difficulty }}通过 {{ p.acceptedNumber }}/{{ p.submissionNumber }}
</li>
</ul>
</div>
</template>
```
- [ ] **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 workspacesTask 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 管)。
@@ -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;前端对应实时配置连接暂不启动。
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,11 @@
# OJ2 设计文档:后端重写为 Bun + TypeScript # OJ2 设计文档:后端重写为 Bun + TypeScript
日期:2026-08-06 日期:2026-08-06
状态:已确认,待实施 状态:**已实施、已上线**(2026-08 切换完成,旧栈已不可逆下线)。本文保留为「当初为什么这么定」
的记录,**不是现状的说明** —— 现状看 `CLAUDE.md``docs/` 下的专题文档。
文中引用的 `docs/spikes/`(三个 spike 脚本)与 `docs/specs/schema.sql`(生产库结构 dump
已在 2026-09-16 删除:结论都已落进代码,脚本跑完就没用了,schema dump 的内容进了迁移
`0000_crazy_gateway.sql`。要看它们去 git 历史。
## 1. 背景与动机 ## 1. 背景与动机
@@ -65,7 +69,7 @@
| 全站不用的分支 | OI 赛制(所有比赛均为 ACM) | | 全站不用的分支 | OI 赛制(所有比赛均为 ACM) |
| Python 生态锁定 | 仅 2 处:`jieba``flowchart/views/admin.py` 单文件)、`tree-sitter``ast_checker/`177 行) | | Python 生态锁定 | 仅 2 处:`jieba``flowchart/views/admin.py` 单文件)、`tree-sitter``ast_checker/`177 行) |
> 更正(2026-08-06 阶段 0 重跑后):本表原写「端点合计 122(oj 74 / admin 48)、DEPRECATED 16、前端调用 78、疑似无人调用约 35%」,四项全错。前三项来自一版漏抓了 `tutorial/urls/tutorial.py` 与 `utils/urls.py` 的提取脚本(共漏 5 个端点,其中 4 个前端在用)与一版只数字面量、不含模板串的前端统计;「约 35%」是从 `(122−78)/122` 推出来的,两个输入都错。现表为 `docs/spikes/` 三个脚本重跑的实测值,独立核验:`cd OnlineJudge && cat */urls/*.py utils/urls.py | grep -c "path("` → 127。 > 更正(2026-08-06 阶段 0 重跑后):本表原写「端点合计 122(oj 74 / admin 48)、DEPRECATED 16、前端调用 78、疑似无人调用约 35%」,四项全错。前三项来自一版漏抓了 `tutorial/urls/tutorial.py` 与 `utils/urls.py` 的提取脚本(共漏 5 个端点,其中 4 个前端在用)与一版只数字面量、不含模板串的前端统计;「约 35%」是从 `(122−78)/122` 推出来的,两个输入都错。现表为当时三个提取脚本重跑的实测值,独立核验:`cd OnlineJudge && cat */urls/*.py utils/urls.py | grep -c "path("` → 127。
> **减法空间只有 18%,不是三分之一。** 后续阶段按 18% 排期。 > **减法空间只有 18%,不是三分之一。** 后续阶段按 18% 排期。
前端网络层集中度高,改动面小: 前端网络层集中度高,改动面小:
@@ -141,9 +145,9 @@ Projects/OJ/
## 7. 已验证的技术假设 ## 7. 已验证的技术假设
三处高风险假设已在 Bun 1.3.11 上实测通过spike 代码 `docs/spikes/`。依赖清单与 lockfile 已随 spike 源码入库(`docs/spikes/package.json``bun.lock`),`cd docs/spikes && bun install` 后三个脚本均可直接重跑 三处高风险假设已在 Bun 1.3.11 上实测通过。当时的 spike 代码 `docs/spikes/`,已删除(见顶部说明),三条结论都已落进 `apps/api` 的实现
### 7.1 Django 密码哈希兼容`docs/spikes/pbkdf2-spike.ts` ### 7.1 Django 密码哈希兼容
用 Django 生成 `pbkdf2_sha256$1200000$...` 格式哈希,Bun 侧用 `node:crypto``pbkdf2` 验证: 用 Django 生成 `pbkdf2_sha256$1200000$...` 格式哈希,Bun 侧用 `node:crypto``pbkdf2` 验证:
@@ -163,7 +167,7 @@ argon2id : true 耗时 88 ms
#### 7.1.1 `raw_password` 明文列保留(已决策) #### 7.1.1 `raw_password` 明文列保留(已决策)
生产库 `user` 表有一列 `raw_password character varying(20)``docs/specs/schema.sql:939`),存学生明文密码。 生产库 `user` 表有一列 `raw_password character varying(20)`),存学生明文密码。
**决策:保留。** 这是有意的运维需求——学生忘记密码是高频事件,教师需要能直接查到并告知,走"重置密码"流程在机房环境里成本过高。新后端照样维护这一列。 **决策:保留。** 这是有意的运维需求——学生忘记密码是高频事件,教师需要能直接查到并告知,走"重置密码"流程在机房环境里成本过高。新后端照样维护这一列。
@@ -176,7 +180,7 @@ argon2id : true 耗时 88 ms
**若日后想在不改变教师查密码这一工作流的前提下收紧**(本次未采纳,仅备查):把 `raw_password` 改为用一把存在环境变量/密钥文件里、**不在数据库内**的密钥做可逆加密。教师查询走应用层解密,体验不变;而一份裸的数据库备份泄露时不再直接暴露明文。改动量约为一个加解密工具函数 + 一次存量数据迁移。 **若日后想在不改变教师查密码这一工作流的前提下收紧**(本次未采纳,仅备查):把 `raw_password` 改为用一把存在环境变量/密钥文件里、**不在数据库内**的密钥做可逆加密。教师查询走应用层解密,体验不变;而一份裸的数据库备份泄露时不再直接暴露明文。改动量约为一个加解密工具函数 + 一次存量数据迁移。
### 7.2 tree-sitter 迁移`docs/spikes/ast-spike.ts` ### 7.2 tree-sitter 迁移
复刻 `ast_checker/mappings/c.py` 的映射表,在 Bun 中用 `web-tree-sitter` 解析 C 代码: 复刻 `ast_checker/mappings/c.py` 的映射表,在 Bun 中用 `web-tree-sitter` 解析 C 代码:
@@ -204,7 +208,7 @@ C 与 Python 两套 grammar 均正常。`.wasm` 文件随 npm 包分发(`tree-
未选原生 NAPI 绑定:性能更高但需 node-gyp 现场编译,Bun 支持稳定性较差。1.1ms 解析耗时在判题流程中可忽略(沙箱启动本身即数十毫秒),选 WASM 图部署简单。 未选原生 NAPI 绑定:性能更高但需 node-gyp 现场编译,Bun 支持稳定性较差。1.1ms 解析耗时在判题流程中可忽略(沙箱启动本身即数十毫秒),选 WASM 图部署简单。
### 7.3 `@node-rs/jieba` 替代 Python jieba`docs/spikes/jieba-spike.ts` ### 7.3 `@node-rs/jieba` 替代 Python jieba
对照 `flowchart/views/admin.py:65,191` 的两处用法——`jieba.add_word(w, freq=9999)` 加自定义词、`jieba.cut(text)` 切词——在 Bun 1.3.11 下验证 `@node-rs/jieba@2.0.1`NAPI 绑定): 对照 `flowchart/views/admin.py:65,191` 的两处用法——`jieba.add_word(w, freq=9999)` 加自定义词、`jieba.cut(text)` 切词——在 Bun 1.3.11 下验证 `@node-rs/jieba@2.0.1`NAPI 绑定):
@@ -249,9 +253,9 @@ jieba.loadDict(Buffer.from("两个整数 9999\n"))
盘点中发现的、明确不应带进新后端的实现: 盘点中发现的、明确不应带进新后端的实现:
- **`SessionRecordMiddleware``account/middleware.py:22-33`)**:每个已登录请求都写一遍 sessionuser_agent / ip / last_activity),遇到新 session key 还额外触发一次 `request.user.save()` —— 即每请求一次数据库写。这是"Django 太慢"的实际来源之一。新后端的会话信息留在 Redis,不落库。 - **`SessionRecordMiddleware``account/middleware.py:22-33`)**:每个已登录请求都写一遍 sessionuser_agent / ip / last_activity),遇到新 session key 还额外触发一次 `request.user.save()` —— 即每请求一次数据库写。这是"Django 太慢"的实际来源之一。新后端的会话信息留在 Redis,不落库。
- **`User.session_keys`**:只写不读的死字段,随 `/api/sessions` 端点一并砍掉。(已由 `schema.sql:938` 确认该列存在。) - **`User.session_keys`**:只写不读的死字段,随 `/api/sessions` 端点一并砍掉。
反过来,**必须复刻**的一项:`user.raw_password``schema.sql:939`保留,见 7.1.1。 反过来,**必须复刻**的一项:`user.raw_password` 保留,见 7.1.1。
## 9. 判题链路 ## 9. 判题链路
@@ -1,7 +1,8 @@
# OJ2 设计文档:课堂求助与协作编辑 # OJ2 设计文档:课堂求助与协作编辑
日期:2026-08-28 日期:2026-08-28
状态:已实施 状态:已实施。**第 1 节写的是重做之前的实现**(y-webrtc 那套,`sync.ts` / `syncStatus.ts`
早已删除),留着是为了说明为什么要改;现状是第 3 节往后。
## 1. 背景 ## 1. 背景
-190
View File
@@ -1,190 +0,0 @@
# 端点清单(已裁决)
生成时间:2026-08-06  裁决时间:2026-08-06
合计 127 个端点 —— KEEP 110、CUT 17、REVIEW 0
**裁决结果:新后端需实现 110 个端点,砍掉 17 个(占 13%)。**
裁决说明:机器初判的 6 条 REVIEW 全部判为 KEEP。其中 5 条是下述提取盲点造成的假阴性
(前端确实在调用,只是提取脚本抓不到);`/api/judge_server_heartbeat/` 不经前端,是判题机
向后端注册心跳的接口,新架构判题沙箱镜像原样复用,必须保留。
> ⚠️ 本文件已完成人工裁决,**不要再运行 `docs/spikes/reconcile.ts`** —— 它会重新生成本文件,
> 把上面的裁决结果和末列理由全部冲掉。若确需重跑(例如后端 urls 有变动),先备份本文件。
> 已知盲点 1`ojnext/src/oj/api.ts` 第 45、73 行用变量动态传路径(形如 `http.get(endpoint)`),提取脚本的正则匹配不到这类调用。因此对应的后端端点会被本表判成“前端无调用”,但实际可能仍在使用 —— 例如 `/api/contest_submissions``getSubmissions` 里 `endpoint` 变量的另一分支)。
> 已知盲点 2:`ojnext` 里有 4 处用原生 `fetch("/api/...")` 而非 `http.get/post(...)` 发起请求(AI 流式响应场景:`src/oj/store/ai.ts`、`src/oj/problem/components/SubmissionResult.vue`、`src/oj/rank/list.vue`、`src/oj/class/pk.vue`),提取脚本只认 `get/post/put/delete(...)` 调用形式,完全抓不到 `fetch(...)`。本轮 REVIEW 里的 `/api/ai/analysis`、`/api/ai/hint`、`/api/ai/class_pk`、`/api/ai/class_single` 经人工核实均属此类,实际都在用。
> 已知盲点 3`ojnext/src/utils/download.ts` 是一个独立的 axios 实例(`baseURL: "/api/admin"`,与 `src/utils/http.ts` 那个共用实例无关),对外只暴露 `download(url)` 一个函数,内部走 `http.get(url)`。提取脚本既不认 `download(...)` 这种调用名,也抓不到内部那个变量 `url`,所以这条通道上的调用一律是假阴性。当前两个调用点(`src/admin/problem/components/Actions.vue:46`、`src/admin/problem/detail.vue:316`)都指向 `admin/test_case`,而该端点已因别处的字面量调用被判成 KEEP,**本轮结论不受影响**。但日后新增的 `download(...)` 调用会静默变成假阴性 CUT,裁决时留意。
> 盲点 1、2、3 都是“前端有调用=否”但实际有调用,人工裁决时不要仅凭本表这一列就判 CUT;REVIEW 里唯一不属于此类的是 `/api/judge_server_heartbeat/`——它是判题机而非前端调用的接口,不受提取脚本盲点影响,是否保留需按后端间调用来判断。
> 反向对账(前端调用了、后端却查无此端点):**0 条**,前端全部调用路径都能在后端端点全集里找到对应。
| 裁决 | app | 侧 | 路径 | 视图 | 前端有调用 | 已标 DEPRECATED | 理由 |
|---|---|---|---|---|---|---|---|
| CUT | account | oj | `/api/change_password` | UserChangePasswordAPI.as_view | 否 | 是 | |
| CUT | account | oj | `/api/change_email` | UserChangeEmailAPI.as_view | 否 | 是 | |
| CUT | account | oj | `/api/check_username_or_email` | UsernameOrEmailCheck.as_view | 否 | 是 | |
| CUT | account | oj | `/api/sessions` | SessionManagementAPI.as_view | 否 | 是 | |
| CUT | account | oj | `/api/open_api_appkey` | OpenAPIAppkeyAPI.as_view | 否 | 是 | |
| CUT | account | oj | `/api/sso` | SSOAPI.as_view | 否 | 是 | |
| CUT | account | admin | `/api/admin/generate_user` | GenerateUserAPI.as_view | 否 | 是 | |
| CUT | conf | oj | `/api/languages` | LanguagesAPI.as_view | 否 | 是 | |
| CUT | contest | oj | `/api/contest/announcement` | ContestAnnouncementListAPI.as_view | 否 | 是 | |
| CUT | contest | admin | `/api/admin/contest/announcement` | ContestAnnouncementAPI.as_view | 否 | 是 | |
| CUT | contest | admin | `/api/admin/download_submissions` | DownloadContestSubmissions.as_view | 否 | 是 | |
| CUT | problemset | oj | `/api/problemset/<int:problem_set_id>/problems/<int:problem_id>` | ProblemSetProblemAPI.as_view | 否 | 是 | |
| CUT | problemset | oj | `/api/problemset/<int:problem_set_id>/progress` | ProblemSetProgressAPI.as_view | 否 | 是 | |
| CUT | problemset | oj | `/api/user/progress` | UserProgressAPI.as_view | 否 | 是 | |
| CUT | problemset | admin | `/api/admin/problemset/<int:problem_set_id>/sync` | ProblemSetSyncAPI.as_view | 否 | 是 | |
| CUT | submission | oj | `/api/submission_exists` | SubmissionExistsAPI.as_view | 否 | 是 | |
| CUT | utils | admin | `/api/admin/upload_file` | SimditorFileUploadAPIView.as_view | 否 | 是 | |
| KEEP | account | oj | `/api/login` | UserLoginAPI.as_view | 是 | 否 | |
| KEEP | account | oj | `/api/logout` | UserLogoutAPI.as_view | 是 | 否 | |
| KEEP | account | oj | `/api/register` | UserRegisterAPI.as_view | 是 | 否 | |
| KEEP | account | oj | `/api/profile` | UserProfileAPI.as_view | 是 | 否 | |
| KEEP | account | oj | `/api/profile/fresh_display_id` | ProfileProblemDisplayIDRefreshAPI.as_view | 是 | 否 | |
| KEEP | account | oj | `/api/metrics` | Metrics.as_view | 是 | 否 | |
| KEEP | account | oj | `/api/upload_avatar` | AvatarUploadAPI.as_view | 是 | 否 | |
| KEEP | account | oj | `/api/user_rank` | UserRankAPI.as_view | 是 | 否 | |
| KEEP | account | oj | `/api/user_activity_rank` | UserActivityRankAPI.as_view | 是 | 否 | |
| KEEP | account | oj | `/api/user_problem_rank` | UserProblemRankAPI.as_view | 是 | 否 | |
| KEEP | account | admin | `/api/admin/user` | UserAdminAPI.as_view | 是 | 否 | |
| KEEP | account | admin | `/api/admin/reset_password` | ResetUserPasswordAPI.as_view | 是 | 否 | |
| KEEP | achievement | oj | `/api/achievements` | AchievementListAPI.as_view | 是 | 否 | |
| KEEP | achievement | oj | `/api/achievements/summary` | AchievementSummaryAPI.as_view | 是 | 否 | |
| KEEP | achievement | oj | `/api/achievements/pending` | AchievementPendingAPI.as_view | 是 | 否 | |
| KEEP | achievement | admin | `/api/admin/achievement` | AchievementAdminAPI.as_view | 是 | 否 | |
| KEEP | achievement | admin | `/api/admin/achievement/metrics` | AchievementMetricAdminAPI.as_view | 是 | 否 | |
| KEEP | ai | oj | `/api/ai/detail` | AIDetailDataAPI.as_view | 是 | 否 | |
| KEEP | ai | oj | `/api/ai/duration` | AIDurationDataAPI.as_view | 是 | 否 | |
| KEEP | ai | oj | `/api/ai/heatmap` | AIHeatmapDataAPI.as_view | 是 | 否 | |
| KEEP | ai | oj | `/api/ai/login_summary` | AILoginSummaryAPI.as_view | 是 | 否 | |
| KEEP | ai | oj | `/api/ai/pinned` | AIPinnedReportAPI.as_view | 是 | 否 | |
| KEEP | ai | admin | `/api/admin/ai/reports` | AIAnalysisAdminAPI.as_view | 是 | 否 | |
| KEEP | announcement | oj | `/api/announcement` | AnnouncementAPI.as_view | 是 | 否 | |
| KEEP | announcement | admin | `/api/admin/announcement` | AnnouncementAdminAPI.as_view | 是 | 否 | |
| KEEP | class_pk | oj | `/api/class_rank` | ClassRankAPI.as_view | 是 | 否 | |
| KEEP | class_pk | oj | `/api/user_class_rank` | UserClassRankAPI.as_view | 是 | 否 | |
| KEEP | class_pk | oj | `/api/class_pk` | ClassPKAPI.as_view | 是 | 否 | |
| KEEP | conf | oj | `/api/website` | WebsiteConfigAPI.as_view | 是 | 否 | |
| KEEP | conf | oj | `/api/hitokoto` | HitokotoAPI.as_view | 是 | 否 | |
| KEEP | conf | oj | `/api/class_usernames` | ClassUsernamesAPI.as_view | 是 | 否 | |
| KEEP | conf | admin | `/api/admin/website` | WebsiteConfigAPI.as_view | 是 | 否 | |
| KEEP | conf | admin | `/api/admin/random_user` | RandomUsernameAPI.as_view | 是 | 否 | |
| KEEP | conf | admin | `/api/admin/judge_server` | JudgeServerAPI.as_view | 是 | 否 | |
| KEEP | conf | admin | `/api/admin/prune_test_case` | TestCasePruneAPI.as_view | 是 | 否 | |
| KEEP | conf | admin | `/api/admin/dashboard_info` | DashboardInfoAPI.as_view | 是 | 否 | |
| KEEP | contest | oj | `/api/contests` | ContestListAPI.as_view | 是 | 否 | |
| KEEP | contest | oj | `/api/contest` | ContestAPI.as_view | 是 | 否 | |
| KEEP | contest | oj | `/api/contest/password` | ContestPasswordVerifyAPI.as_view | 是 | 否 | |
| KEEP | contest | oj | `/api/contest/access` | ContestAccessAPI.as_view | 是 | 否 | |
| KEEP | contest | oj | `/api/contest_rank` | ContestRankAPI.as_view | 是 | 否 | |
| KEEP | contest | admin | `/api/admin/contest` | ContestAPI.as_view | 是 | 否 | |
| KEEP | contest | admin | `/api/admin/contest/clone` | ContestCloneAPI.as_view | 是 | 否 | |
| KEEP | contest | admin | `/api/admin/contest/acm_helper` | ACMContestHelper.as_view | 是 | 否 | |
| KEEP | flowchart | oj | `/api/flowchart/submission` | FlowchartSubmissionAPI.as_view | 是 | 否 | |
| KEEP | flowchart | oj | `/api/flowchart/submissions` | FlowchartSubmissionListAPI.as_view | 是 | 否 | |
| KEEP | flowchart | oj | `/api/flowchart/submission/retry` | FlowchartSubmissionRetryAPI.as_view | 是 | 否 | |
| KEEP | flowchart | oj | `/api/flowchart/submission/detail` | FlowchartSubmissionDetailAPI.as_view | 是 | 否 | |
| KEEP | flowchart | oj | `/api/flowchart/submission/current` | FlowchartSubmissionCurrentAPI.as_view | 是 | 否 | |
| KEEP | flowchart | admin | `/api/admin/flowchart/statistics` | FlowchartStatisticsAPI.as_view | 是 | 否 | |
| KEEP | message | oj | `/api/message` | MessageAPI.as_view | 是 | 否 | |
| KEEP | problem | oj | `/api/problem/tags` | ProblemTagAPI.as_view | 是 | 否 | |
| KEEP | problem | oj | `/api/problem` | ProblemAPI.as_view | 是 | 否 | |
| KEEP | problem | oj | `/api/problem/beat_count` | ProblemSolvedPeopleCount.as_view | 是 | 否 | |
| KEEP | problem | oj | `/api/problem/similar` | SimilarProblemAPI.as_view | 是 | 否 | |
| KEEP | problem | oj | `/api/problem/author` | ProblemAuthorAPI.as_view | 是 | 否 | |
| KEEP | problem | oj | `/api/problem/yearly_ac` | ProblemYearlyACRateAPI.as_view | 是 | 否 | |
| KEEP | problem | oj | `/api/pickone` | PickOneAPI.as_view | 是 | 否 | |
| KEEP | problem | oj | `/api/contest/problem` | ContestProblemAPI.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/test_case` | TestCaseAPI.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/sql_test_case_preview` | SQLTestCasePreviewAPI.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/sql_test_case_scripts` | SQLTestCaseScriptsAPI.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/sql_test_case_ai_gen` | SQLTestCaseAIGenAPI.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/problem` | ProblemAPI.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/problem/visible` | ProblemVisibleAPI.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/problem/stuck` | StuckProblemsAPI.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/problem/top_ac_trend` | TopACTrendAPI.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/problem/flowchart` | ProblemFlowchartAIGen.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/problem/tag` | TagAdminAPI.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/problem/batch_tag` | BatchProblemTagAPI.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/contest/problem` | ContestProblemAPI.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/contest_problem/make_public` | MakeContestProblemPublicAPIView.as_view | 是 | 否 | |
| KEEP | problem | admin | `/api/admin/contest/add_problem_from_public` | AddContestProblemAPI.as_view | 是 | 否 | |
| KEEP | problemset | oj | `/api/problemset` | ProblemSetAPI.as_view | 是 | 否 | |
| KEEP | problemset | oj | `/api/problemset/<int:problem_set_id>` | ProblemSetDetailAPI.as_view | 是 | 否 | |
| KEEP | problemset | oj | `/api/problemset/<int:problem_set_id>/problems` | ProblemSetProblemAPI.as_view | 是 | 否 | |
| KEEP | problemset | oj | `/api/problemset/progress` | ProblemSetProgressAPI.as_view | 是 | 否 | |
| KEEP | problemset | oj | `/api/user/badges` | UserBadgeAPI.as_view | 是 | 否 | |
| KEEP | problemset | oj | `/api/problemset/<int:problem_set_id>/badges` | ProblemSetBadgeAPI.as_view | 是 | 否 | |
| KEEP | problemset | oj | `/api/problemset/<int:problem_set_id>/users_progress` | ProblemSetUserProgressAPI.as_view | 是 | 否 | |
| KEEP | problemset | admin | `/api/admin/problemset` | ProblemSetAdminAPI.as_view | 是 | 否 | |
| KEEP | problemset | admin | `/api/admin/problemset/visible` | ProblemSetVisibleAPI.as_view | 是 | 否 | |
| KEEP | problemset | admin | `/api/admin/problemset/status` | ProblemSetStatusAPI.as_view | 是 | 否 | |
| KEEP | problemset | admin | `/api/admin/problemset/<int:problem_set_id>` | ProblemSetDetailAdminAPI.as_view | 是 | 否 | |
| KEEP | problemset | admin | `/api/admin/problemset/<int:problem_set_id>/problems` | ProblemSetProblemAdminAPI.as_view | 是 | 否 | |
| KEEP | problemset | admin | `/api/admin/problemset/<int:problem_set_id>/problems/<int:problem_set_problem_id>` | ProblemSetProblemAdminAPI.as_view | 是 | 否 | |
| KEEP | problemset | admin | `/api/admin/problemset/<int:problem_set_id>/badges` | ProblemSetBadgeAdminAPI.as_view | 是 | 否 | |
| KEEP | problemset | admin | `/api/admin/problemset/<int:problem_set_id>/badges/<int:badge_id>` | ProblemSetBadgeAdminAPI.as_view | 是 | 否 | |
| KEEP | problemset | admin | `/api/admin/problemset/<int:problem_set_id>/progress` | ProblemSetProgressAdminAPI.as_view | 是 | 否 | |
| KEEP | problemset | admin | `/api/admin/problemset/<int:problem_set_id>/progress/<int:user_id>` | ProblemSetProgressAdminAPI.as_view | 是 | 否 | |
| KEEP | reaction | oj | `/api/reaction` | ReactionAPI.as_view | 是 | 否 | |
| KEEP | submission | oj | `/api/submission` | SubmissionAPI.as_view | 是 | 否 | |
| KEEP | submission | oj | `/api/submissions` | SubmissionListAPI.as_view | 是 | 否 | |
| KEEP | submission | oj | `/api/submissions/today_count` | SubmissionsTodayCount.as_view | 是 | 否 | |
| KEEP | submission | oj | `/api/format_code` | FormatCodeAPI.as_view | 是 | 否 | |
| KEEP | submission | admin | `/api/admin/submission/rejudge` | SubmissionRejudgeAPI.as_view | 是 | 否 | |
| KEEP | submission | admin | `/api/admin/submission/statistics` | SubmissionStatisticsAPI.as_view | 是 | 否 | |
| KEEP | tutorial | oj | `/api/tutorial` | TutorialAPI.as_view | 是 | 否 | |
| KEEP | tutorial | oj | `/api/tutorials` | TutorialTitlesAPI.as_view | 是 | 否 | |
| KEEP | tutorial | oj | `/api/exercises` | ExerciseAPI.as_view | 是 | 否 | |
| KEEP | tutorial | admin | `/api/admin/tutorial` | TutorialAdminAPI.as_view | 是 | 否 | |
| KEEP | tutorial | admin | `/api/admin/tutorial/visibility` | TutorialVisibilityAPI.as_view | 是 | 否 | |
| KEEP | tutorial | admin | `/api/admin/exercise` | ExerciseAdminAPI.as_view | 是 | 否 | |
| KEEP | utils | admin | `/api/admin/upload_image` | SimditorImageUploadAPIView.as_view | 是 | 否 | |
| KEEP | ai | oj | `/api/ai/analysis` | AIAnalysisAPI.as_view | 否 | 否 | 盲点 2:走原生 `fetch``src/oj/store/ai.ts:107`),实际在用 |
| KEEP | ai | oj | `/api/ai/hint` | AIHintAPI.as_view | 否 | 否 | 盲点 2:走原生 `fetch``src/oj/problem/components/SubmissionResult.vue:86`),实际在用 |
| KEEP | ai | oj | `/api/ai/class_pk` | ClassPKAnalysisAPI.as_view | 否 | 否 | 盲点 2:走原生 `fetch``src/oj/class/pk.vue:179`),实际在用 |
| KEEP | ai | oj | `/api/ai/class_single` | SingleClassAnalysisAPI.as_view | 否 | 否 | 盲点 2:走原生 `fetch``src/oj/rank/list.vue:98`),实际在用 |
| KEEP | conf | oj | `/api/judge_server_heartbeat/` | JudgeServerHeartbeatAPI.as_view | 否 | 否 | 非前端调用:判题机向后端注册心跳。新架构判题沙箱镜像原样复用,此接口必须保留 |
| KEEP | submission | oj | `/api/contest_submissions` | ContestSubmissionListAPI.as_view | 否 | 否 | 盲点 1`getSubmissions``endpoint` 变量的比赛分支(`src/oj/api.ts:73`),实际在用 |
---
## 交付核对(2026-08-08,阶段 5 之后)
把这张表里的 **110 条 KEEP 逐条对到新后端**,确认没有「当初判了要搬、后来忘了」的。
**结果:110/110 全部有对应实现,零缺口。**
核对方法:把旧路径和新后端注册的 167 条路由都做词元化(去掉 `/api``admin`
参数段,snake/kebab 拆开,单复数归一)后求交集。87 条自动匹配上,剩下 23 条
(API 是重新设计过的,路径本来就对不上)逐条人工落实,见下表。
> 方法的局限:词元匹配只能提示「这两条像是同一个」,不能证明**行为**一致。
> 行为一致性靠的是阶段 3/4 的两轮独立评审和阶段 5 的实跑演练,不是这张表。
### 非显然的改名对照
`problemset``problem-sets` 这类一眼能猜到的没列。下面这些是**猜不到、
日后排查时会卡住人**的:
| 旧(Django | 新(Bun |
|---|---|
| `POST /api/register` | `POST /api/users` |
| `GET /api/logout` | `DELETE /api/auth/session` |
| `GET /api/hitokoto` | `GET /api/quotes/random` |
| `GET /api/pickone` | `GET /api/problems/random` |
| `GET /api/user_activity_rank` | `GET /api/rankings/activity` |
| `GET /api/profile/fresh_display_id` | `POST /api/me/problem-display-ids/refresh` |
| `GET /api/flowchart/submission/detail` | `GET /api/flowcharts/:id` |
| `POST /api/reaction` | `POST /api/problems/:id/reaction` |
| `PUT /api/admin/problemset/visible` | `PUT /api/admin/problem-sets/:id/visibility` |
| `GET /api/judge_server_heartbeat/` | `POST /api/judge-server/heartbeat` |
最后一条尤其要注意:**判题沙箱镜像是原样复用的**,它靠 compose 里的
`BACKEND_URL` 找后端,三套 compose 都已改成新路径。改动这条要同步改 compose,
否则判题机会静默离线。
-87
View File
@@ -1,87 +0,0 @@
# 阶段 0 / 1 / 2 出口标准核验
日期:2026-08-07
方法:**逐条实跑,不采信文档声称**。阶段 1 后半段至阶段 3 的实现由外部 agent(Codex)完成,
本次核验按外部代码对待,独立验证。
## 结论
| 阶段 | 出口标准条数 | 通过 | 结果 |
|---|---|---|---|
| 0 存量盘点 | 4 | 4 | ✅ |
| 1 骨架 | 5 | 5 | ✅ |
| 2 判题竖线 | 1(端到端) | 1 | ✅ |
## 阶段 0
| 标准 | 实测 |
|---|---|
| `endpoint-inventory.md` 的 REVIEW 计数为 0 | `grep -c '^| REVIEW |'`**0** |
| 清单顶部记录最终数字 | 「合计 127 —— KEEP 110、CUT 17、REVIEW 0;新后端需实现 110 个」 |
| jieba 结论写回设计文档 7.3 | `@node-rs/jieba` 在文档中出现 6 处,7.3 节为实测结论 |
| `schema.sql` 入库 | `git ls-files` 命中 |
## 阶段 1
| 标准 | 实测 |
|---|---|
| `@oj2/contract` 为符号链接 | `contract -> ../../packages/contract`(另有 api、web 两个 workspace 链接) |
| `schema.ts` 27 张表且无框架表 | `grep -c 'pgTable('`**27**`django_*` / `auth_*` 匹配为空 |
| `/api/problems` 返回真实数据 | HTTP 200,返回中文标题含 emoji(如「🎮好耶!是大冒险!」),字段完整 |
| `apps/web` 构建成功 | `bun run build` 退出码 **0** |
| `/dev-problems` 显示 20 道题 | `GET /api/dev/problems` → 200,题目数 **20** |
### 旧 Chrome 兼容约束(硬约束)专项核验
学校机房浏览器版本低,`mermaid-legacy` 等 fallback 依赖与 `legacy()` 插件不可动。实测:
- `apps/web/vite.config.ts``ojnext/vite.config.ts` 差异 **17 行,全部是新增代理**
`/api2``/ws2``/public/avatar`),未触碰 `legacy()` 插件与 Chrome 90 兼容配置
- 依赖 38 → 39**新增仅 `@oj2/contract`,删除 0 个**`mermaid-legacy` 仍在
约束守住。
## 阶段 2
出口标准:一名学生能登录、读公开题、提交代码,并通过 WebSocket 看到真实 JudgeServer 的判题结果。
实测流程(全部真实执行,非模拟):
1. **注册** `POST /api/users` → 201
2. **登录** `POST /api/auth/login` → 200`Set-Cookie: oj2_session=<32 字节随机令牌>`
3. **会话** `GET /api/me` → 200,返回用户身份
4. **读公开题** `GET /api/problems/1004` → 200
5. **提交** `POST /api/submissions` → 201,返回 `submissionId`
6. **判题** 真实 JudgeServer 执行,返回 `result: 0`ACCEPTED),两个测试点均带
`memory` / `cpu_time` / `output_md5`
7. **WebSocket 推送** `ws://localhost:3000/ws/submissions`,订阅后收到三条:
```
{"type":"submission_update","result":6,"status":"pending"}
{"type":"submission_update","result":7,"status":"judging"}
{"type":"submission_update","result":0,"status":"finished","time_cost":0,"memory_cost":1470464,"score":0}
```
8. **统计回写** 题目 `myStatus` 变为 0(已通过)
### 附带核验:公开题详情无泄露
`GET /api/problems/1004` 的响应字段中**不含** `testCaseId``testCaseScore``answers`
`astRules` —— 学生侧看不到测试点与答案。
## 遗留待处理项(不影响上述判定)
1. **`GET /api/dev/problems``apps/web/src/oj/dev-problems.vue` 仍在。** 二者是阶段 1 的
临时链路验证物,阶段 1 计划正文写明「阶段 3 会删」。`apps/web/src/routes.ts` 里的
`dev-problems` 路由同样待删。
2. **占位用户 `devadmin` 无法登录。**`password` 列是字面量 `'unusable'`(非有效哈希),
`raw_password``'devonly'`。这是阶段 1 为满足 `problem.created_by_id` 外键而造的占位记录,
设计如此,不是缺陷。需要管理员账号做本地验证时得另建。
3. **判题容器健康状态依赖后端。** `oj2-judge` 的 healthcheck 需要能连上后端心跳端点;
API 未启动时 `docker compose ps` 会显示 unhealthy,属预期行为,非故障。
## 未覆盖
本次只核验出口标准,**未做代码质量与安全审查**。阶段 2、3 的实现均未经过任务级评审
(阶段 0、1 的评审曾逮到 `_id`/`id` 静默碰撞、端点漏抓 5 个、`key()` 误剥前缀等缺陷),
相关审查另行进行,见 `phase3-review-authz.md``phase3-review-leakage.md`
-226
View File
@@ -1,226 +0,0 @@
# 阶段 3 覆盖率对账
日期:2026-08-07(首次对账)/2026-08-07 补记(阶段 3 收口)
基准:`docs/specs/endpoint-inventory.md` 的 110 条 KEEP 端点
对象:`apps/api/src/routes/*.ts` 的路由 handler
> ## 状态:阶段 3 出口标准已达成
>
> 出口标准(设计文档第 11 节)是「用户侧全部功能运行在新后端上」。达成判据:
> `apps/web` 里 `oj/` 与 `shared/` 两个目录**已无任何指向旧 Django 的运行时调用**,
> 残留的 `utils/http` 引用全是 `import type { ApiResponse }` 这一个类型。
> 仍走旧后端的只剩 `admin/api.ts`85 处)与 `utils/download.ts`,入口都在后台管理界面,属阶段 4。
>
> 收口时补做的三件事:
> 1. 补齐 3 条被用户侧页面调用的 admin 端点(重判 / 提交统计 / 流程图统计),见下表;
> 2. 删除阶段 1 的临时验证物(`GET dev/problems`、`dev-problems.vue`、路由项、`problemSummarySchema`);
> 3. 给 `utils/api2.ts` 补上 `login-required` 弹登录框、`permission-denied` 弹提示 ——
> 这两条 `utils/http.ts` 一直有,api2 从建包起就漏了,导致此前已迁移的所有端点
> 在鉴权失败时都是「点了没反应」。新加的两个教师专属端点会放大这个问题,故一并补。
>
> 补记:两份评审里的 7 条 Minor 也已全部处理,逐条结论见 `phase3-fix-list.md` 文末。
## 结论
| | 旧端点 KEEP | 新后端已实现 | 缺口 |
|---|---|---|---|
| **oj 侧** | 65 | 65(另有 4 条新增) | **0** |
| **admin 侧** | 45 | **3** | **42** |
| 合计 | 110 | 68 | 42 |
**oj 侧已全部覆盖。** admin 侧原计划整块推到阶段 4,但其中 3 条被用户侧页面直接调用,
不做完阶段 3 的出口标准(用户侧全部功能跑在新后端上)就不成立,因此在本阶段一并补上:
| 旧端点 | 新路由 | 调用它的用户侧页面 |
|---|---|---|
| `GET admin/submission/rejudge` | `POST submissions/:id/rejudge` | `oj/submission/list.vue` 的重判按钮 |
| `GET admin/submission/statistics` | `GET submissions/statistics` | `StatisticsPanel.vue`(提交列表页 + 题目页) |
| `GET admin/flowchart/statistics` | `GET flowcharts/statistics` | `FlowchartStatisticsPanel.vue`(提交列表页) |
这三条虽然挂在旧后端的 admin 路由下,权限也确实是 `teacher_admin_required` /
`super_admin_required`,但入口在用户侧页面里 —— **「admin 路由」和「admin 页面」不是一回事**,
按 URL 前缀切阶段会漏掉它们。剩下 42 条的入口都在后台管理界面,留给阶段 4。
> 对账方法说明:阶段 0 已定案 API 重新设计,新旧路径不同名(如旧 `/api/problem` → 新 `/api/problems`、
> 旧 `/api/pickone` → 新 `/api/problems/random`),**无法按字符串自动匹配**。本表由人工按语义逐条比对,
> 判断依据是路由文件归属 + 路径语义 + HTTP 动词。个别条目的对应关系带主观判断,下表逐条列出以便复核。
## oj 侧逐条对照(65 条)
| 旧 app | 旧端点 | 新路由 |
|---|---|---|
| account | `login` | `POST auth/login` |
| account | `logout` | `DELETE auth/session` |
| account | `register` | `POST users` |
| account | `profile` | `GET me` / `GET profiles/:username` / `PUT me/profile` |
| account | `profile/fresh_display_id` | `POST me/problem-display-ids/refresh` |
| account | `metrics` | `GET users/:id/metrics` |
| account | `upload_avatar` | `POST me/avatar` |
| account | `user_rank` | `GET rankings/users` |
| account | `user_activity_rank` | `GET rankings/activity` |
| account | `user_problem_rank` | `GET problems/:displayId/rank` |
| achievement | `achievements` | `GET achievements` |
| achievement | `achievements/summary` | `GET achievements/summary` |
| achievement | `achievements/pending` | `GET achievements/pending` |
| ai | `ai/detail` | `GET ai/detail` |
| ai | `ai/duration` | `GET ai/duration` |
| ai | `ai/heatmap` | `GET ai/heatmap` |
| ai | `ai/login_summary` | `GET ai/login-summary` |
| ai | `ai/pinned` | `GET ai/pinned` |
| ai | `ai/analysis` | `POST ai/analysis` |
| ai | `ai/hint` | `POST ai/hint` |
| ai | `ai/class_pk` | `POST ai/class-pk-analysis` |
| ai | `ai/class_single` | `POST ai/class-analysis` |
| announcement | `announcement` | `GET announcements` / `GET announcements/:id` |
| class_pk | `class_rank` | `GET rankings/classes` |
| class_pk | `user_class_rank` | `GET me/class-rank` |
| class_pk | `class_pk` | `POST classes/comparison` |
| conf | `website` | `GET site` |
| conf | `hitokoto` | `GET quotes/random` |
| conf | `class_usernames` | `GET classes/:className/usernames` |
| conf | `judge_server_heartbeat/` | `POST judge-server/heartbeat` |
| contest | `contests` | `GET contests` |
| contest | `contest` | `GET contests/:id` |
| contest | `contest/password` | `POST contests/:id/access` |
| contest | `contest/access` | `GET contests/:id/access` |
| contest | `contest_rank` | `GET contests/:id/rank` |
| flowchart | `flowchart/submission`POST | `POST flowcharts` |
| flowchart | `flowchart/submissions` | `GET flowcharts` |
| flowchart | `flowchart/submission/retry` | `POST flowcharts/:id/retry` |
| flowchart | `flowchart/submission/detail` | `GET flowcharts/:id` |
| flowchart | `flowchart/submission/current` | `GET problems/:id/flowchart/current` |
| message | `message` | `GET messages` / `POST messages` |
| problem | `problem/tags` | `GET problem-tags` |
| problem | `problem` | `GET problems/:displayId` |
| problem | `problem/beat_count` | `GET problems/:id/beat-count` |
| problem | `problem/similar` | `GET problems/:displayId/similar` |
| problem | `problem/author` | `GET problem-authors` |
| problem | `problem/yearly_ac` | `GET problems/:displayId/yearly-ac` |
| problem | `pickone` | `GET problems/random` |
| problem | `contest/problem` | `GET contests/:id/problems` + `GET contests/:id/problems/:displayId` |
| problemset | `problemset` | `GET problem-sets` |
| problemset | `problemset/<id>` | `GET problem-sets/:id` |
| problemset | `problemset/<id>/problems` | `GET problem-sets/:id/problems` |
| problemset | `problemset/progress` | `POST problem-set-progress` / `PUT problem-set-progress` |
| problemset | `user/badges` | `GET users/:username/badges` |
| problemset | `problemset/<id>/badges` | `GET problem-sets/:id/badges` |
| problemset | `problemset/<id>/users_progress` | `GET problem-sets/:id/user-progress` |
| reaction | `reaction` | `GET problems/:id/reaction` / `POST problems/:id/reaction` |
| submission | `submission` | `GET submissions/:id` |
| submission | `submissions` | `GET submissions` |
| submission | `submissions/today_count` | `GET submissions/today-count` |
| submission | `format_code` | `POST code/format` |
| submission | `contest_submissions` | `GET contests/:contestId/submissions` |
| tutorial | `tutorial` | `GET tutorials/:id` |
| tutorial | `tutorials` | `GET tutorials` |
| tutorial | `exercises` | `GET tutorials/:id/exercises` |
### 新增的 4 条(旧后端没有对应)
| 新路由 | 说明 |
|---|---|
| `POST submissions` | 旧后端提交走 `POST /api/submission`,与 `GET submission` 同路径不同动词,拆开后成独立条目 |
| `PUT submissions/:id` | **提交分享开关**(对齐旧 `SubmissionAPI.put` + `ShareSubmissionSerializer`)。判题结果写回走内部 worker,不经 HTTP —— 早先这里写成「判题结果写回」,会让人误以为存在一个需要判题机凭据的写入端点 |
| `POST achievements/pending/read` | 成就已读标记 |
| `GET problems/:id/flowchart/history` | 流程图历史 |
| ~~`GET dev/problems`~~ | 阶段 1 的临时验证端点,**已删除**(连同 `dev-problems.vue`、路由与 `problemSummarySchema` |
## admin 侧缺口(42 条,按 app
| app | 条数 |
|---|---|
| problem | 14 |
| problemset | 10 |
| conf | 5 |
| contest | 3 |
| tutorial | 3 |
| account | 2 |
| achievement | 2 |
| ai | 1 |
| announcement | 1 |
| utils | 1 |
`problem``problemset` 两块占了 24 条,超过 admin 缺口的一半 —— 排期时应作为主体。
`submission` 原 2 条、`flowchart` 原 1 条已在本阶段做完,见上方表格。)
## 待处理项
1. 本报告的对照关系带人工判断成分,若某条对应有异议,以实际业务行为为准。
2. `utils/download.ts` 仍指向旧后端的 `/api/admin`blob 下载),只被 admin 侧两个页面用,随阶段 4 一起切。
---
## 阶段 5 切换必做项(阶段 4 施工时发现,记在这里以免忘)
> **2026-08-08 更正:这条不是「切换必做项」,降级为「手工造库时的注意事项」。**
>
> 阶段 5 演练时实测了真实的 pg_dumpall 备份:里面带 30 条 `setval`
> 并且把生产快照里所有序列和 `max(id)` 逐个对过,**错位 0 个**。
> 下面这个现象只出现在我手工按显式 id 导入、又没补 setval 的本地库上,
> 对正常的备份/恢复不成立。切换当天不用管序列。详见
> [phase5-cutover-runbook.md](phase5-cutover-runbook.md)。
**导入数据后必须重置全部序列。** 本地库是按显式 id 从生产导入的,
`problem_tag_id_seq` 停在 6 而表里 max(id)=87,于是第一次新建标签就撞
`duplicate key value violates unique constraint "problem_tag_pkey"`500)。
生产切换若沿用同一个库则不受影响(序列本来就是对的);但只要有任何一步是
「导出 → 导入到新库」,就必须补这一句:
```sql
do $$
declare r record; mx bigint;
begin
for r in
select split_part(pg_get_serial_sequence(quote_ident(t.table_name), c.column_name), '.', 2) as seqname,
t.table_name, c.column_name
from information_schema.tables t
join information_schema.columns c on c.table_name = t.table_name
join pg_sequences s on s.sequencename = split_part(pg_get_serial_sequence(quote_ident(t.table_name), c.column_name), '.', 2)
where t.table_schema = 'public'
loop
execute format('select coalesce(max(%I),0) from %I', r.column_name, r.table_name) into mx;
execute format('select setval(%L, greatest(%s, 1))', r.seqname, mx);
end loop;
end $$;
```
症状很隐蔽:读全部正常,只有**写**才炸,而且是导入后第一次写才炸。
---
## SQL 判题链路(阶段 2 补课,2026-08-07
阶段 4 做题目管理时才发现:新后端**完全没有 SQL 判题**。旧后端有
`judge/sql_runner.py`378 行)+ `sql_dispatcher.py`113 行),走的是与沙箱完全
不同的路径(跑 SQLite 比结果集)。阶段 2 纵切时只打通了沙箱那条线,漏了这条。
### 防护为什么换了实现
旧实现靠 Python sqlite3 的三件套。`bun:sqlite` 一个都没有,实测:
| | 结论 |
|---|---|
| `setAuthorizer` / `setProgressHandler` / `setLimit` | 均无 |
| `PRAGMA max_page_count` | 有效 |
| `Worker.terminate()` 能否停掉跑飞的查询 | **不能** —— 递归 CTE 死循环卡死整个 worker,只能从外面杀进程 |
| `node:sqlite` | 该 Bun 版本不可用 |
因此改成「WASM 引擎(sql.js)+ 独立子进程」,逐条替代:
| 旧防护 | 新做法 | 实测 |
|---|---|---|
| authorizer 禁 ATTACH | WASM 无宿主文件系统绑定,**结构上**够不到 | `attach '/etc/passwd'``unable to open database` |
| authorizer 白名单让查询题只读 | `PRAGMA query_only=1` | 查询题里 INSERT → 运行错误并说明 |
| progress_handler 墙钟超时 | 子进程外部 SIGKILL | 递归 CTE 死循环 → CPU 超时 |
| `setlimit(LIMIT_LENGTH)` | 子进程 `ulimit -d` | `hex(zeroblob(2e8))` → 内存超限 |
ATTACH 这条比旧实现**更强**:旧的靠 authorizer 拦,新的是够不到。
### 两个踩过的坑
1. **`ulimit` 必须用 `-d` 不能用 `-v`。** `-v` 限虚拟地址空间,而 JS 引擎预留巨量地址;
实测 `-v` 之下 Bun 退出时有概率 panic(SIGILL),结果早已写出但进程异常终止,
父进程读到空串误判成超时 —— 6 次里坏 2 次,时好时坏。换 `-d`(实际提交内存,
Linux 4.7 起也覆盖匿名 mmap)后 12/12 稳定。
2. 子进程写完结果**直接 SIGKILL 自己**,不走 `process.exit()` —— 后者仍有一段清理会撞限额。
-199
View File
@@ -1,199 +0,0 @@
# 阶段 3 修复清单(两份评审合并)
日期:2026-08-07
来源:`phase3-review-authz.md`(权限边界)+ `phase3-review-leakage.md`(数据泄露)
受审代码:commit `8c00cdc`oj 侧 65 个端点
> ## 状态:Critical / Important 已修复并复评通过;7 条 Minor 已于 2026-08-07 全部处理(见文末)
>
> 修复提交:`b4b61af` / `8237909` / `b7adf29` / `f548aef`
> 复评报告:`phase3-review-rereview.md` —— 8 条(F1-F6 + 收尾的 F4b、F5b**全部 ADDRESSED**
> 修复 diff 内无新引入破坏。
>
> 复评补上了控制方没验充分的一条:**F4b 的 `contestId`**。控制方当时用的提交本就不属于比赛,
> `contestId` 天然为 null,运行时证据不成立。复评真造了一条比赛提交(`contestId=7`、`ip` 有值),
> 确认数据库存的是真值、而 API 返回给提交者本人的是 `info:{}` / `ip:null` / `contestId:null`。
>
> 复评另核实:`sampleUser()` 是真正的默认关闭开关(覆盖全部 14 个下发点,含本清单未列的
> rankings 与题目列表/详情);`isRegularUser` 全仓确实只有一个调用点;`.env` 加载器的优先级
> 正确(真实环境变量 > cwd 的 .env > 仓库根 .env),畸形行不崩、缺文件静默跳过;限流参数
> `fill_rate=0.03` 与旧后端 `options/options.py` 逐值吻合,且拦截位置与
> `submission/views/oj.py` 一致(比赛权限校验之后、取题目之前)。
## 合并说明
两份评审独立进行、互不知情,却各自命中了同两条问题(`/profiles/:username` 匿名可读、
`realName` 无条件下发)。**独立复现提高了可信度**,此处合并为一条。
两份评审对同一问题的严重度判定不一致时,**取更严的一方**。理由:使用者是中职学生(未成年人),
姓名、邮箱、班级属于个人信息,泄露的后果不由技术标准衡量。
控制方(本文档作者)已对 F1、F2、F3 独立实跑复现,证据附在各条下。
---
## F1 —— 匿名可读任意用户完整档案 【Critical】
- **位置**`apps/api/src/routes/account.ts:101`(路由)、`apps/api/src/services/profile.ts:19`(字段)
- **两份评审均命中**authz C1 / leakage I-1
- **问题**`GET /profiles/:username` 只挂 `optionalAuth`,handler 内无登录判断,匿名可读
`email``adminType``problemPermission``isDisabled``className``lastLogin`
- **旧后端行为**`OnlineJudge/account/views/oj.py``UserProfileAPI.get` 第一行即
`if not user.is_authenticated: return self.success()` —— 匿名直接返回空
- **放大效应**:用户名可经 `GET /rankings/users` 公开枚举,因此可无 cookie 批量收集
全校学生的邮箱与最后登录时间
**控制方实跑证据**
```
匿名 GET /api/profiles/e2etest -> 200
含 email: true | 含 adminType: true | 含 realName: true
{"user":{"username":"e2etest","email":"e2e@local.test","adminType":"Regular User",
"problemPermission":"None","lastLogin":"...","isDisabled":false,"className":"2301"}}
```
**修法**:handler 开头判断未登录即返回空,对齐旧行为。
---
## F2 —— 学生真名无条件下发 【Critical】
- **位置**:13 个下发点,其中 8 个匿名可达;`apps/api/src/routes/account.ts:168` 为典型
- **两份评审均命中**authz C2 / leakage I-2
- **问题**:旧后端将「是否返回真名」设计为 DRF `UsernameSerializer(need_real_name=False)`
**默认关闭**开关,全仓 11 处调用中仅比赛榜单一处显式打开。新后端未搬运这一层,真名随
用户对象无条件下发
- **唯一做对的地方**`apps/api/src/routes/contest.ts:209`
**控制方实跑证据**
```
匿名 GET /api/rankings/users -> 200
[{"user":{"id":4,"username":"e2etest","realName":"..."},...},
{"user":{"id":2,"username":"student","realName":"Phase 2 Student"},...}]
```
`"Phase 2 Student"` 为本机既有数据,非评审探针造出)
**修法**:在用户对象的序列化层加一个默认关闭的 `includeRealName` 开关,逐个下发点显式开启;
默认不给。**不要逐处删字段** —— 那样下次新增端点会重犯。
---
## F3 —— 匿名绕过提交可见性守卫,权限大于登录用户 【Critical】
- **位置**`apps/api/src/routes/submission.ts:211``apps/api/src/routes/helpers.ts:27`
- **来源**authz C3leakage 未覆盖)
- **问题**
```ts
export function isRegularUser(user: AuthUser | null | undefined) {
return user?.adminType === "Regular User" // isRegularUser(null) === false
}
// submission.ts:211
if (!(await getBooleanOption("submission_list_show_all", true)) && isRegularUser(user)) {
// 限制为只看自己的提交
}
```
匿名用户的 `isRegularUser(null)` 为 `false`,守卫整体短路,限制不生效
- **潜伏性**`submission_list_show_all` 默认为 `true`,该分支平时不执行,**开关一旦关闭立即暴露**。
而关闭这个开关的典型场景正是考试
**控制方实跑证据**(临时把开关置 false,测后已还原):
```
匿名 total = 10 ← 全部可见
登录学生 total = 0 ← 被正确限制
```
**修法**:守卫应为「非管理员即受限」,而非「是普通用户才受限」。匿名必须落入受限分支。
建议改用 `!isAdminRole(user)`(该函数对 null 返回 `false`,语义正确)。
---
## F4 —— 自己的提交详情返回判题内部信息与 IP 【Important】
- **位置**`apps/api/src/routes/submission.ts:187`
- **来源**leakage I-3
- **问题**:返回 `info`(含每个测试点的 `test_case` 编号与 `output_md5`)与 `ip`
- **旧后端行为**:这两个字段以 `is_admin_role()` 把关,而非「是不是自己的提交」
- **风险**:测试点编号与输出 md5 可用于反推测试数据规模与部分答案特征
**修法**`info` 与 `ip` 改为仅管理员可见,对齐旧后端。
---
## F5 —— 判题机 token 默认值硬编码进仓库 【Important】
- **位置**`docker/compose.dev.yml` 的 `${OJ2_JUDGE_TOKEN:-oj2-dev-token}` 及后端读取处
- **来源**authz Important
- **问题**:token 校验本身实现正确(用了 `timingSafeEqual`),但缺省值 `"oj2-dev-token"`
写死在仓库里。旧后端在 env 缺失时用 `rand_str()` fail-safe,宁可不可用也不用弱默认值
- **当前影响有限**`judge/run.ts` 用 env 里的 URL 而非数据库中的 `service_url`
**修法**:去掉默认值,env 缺失时启动失败或生成随机值,不要静默使用弱默认。
---
## F6 —— 提交接口缺少限流 【Important】
- **位置**`apps/api/src/routes/submission.ts` 的 `POST /submissions`
- **来源**authz Important
- **问题**:旧后端有 TokenBucket 限流,新后端未搬运
- **风险**:判题沙箱是有限资源,学生(或脚本)可连续提交打满队列
**修法**:按旧后端的限流参数重建。
---
## Minor(共 7 条)—— 2026-08-07 已全部处理
在 admin 侧开工前清掉,避免同样的模式被复制 42 次。逐条结论:
| 编号 | 内容 | 处理 |
|---|---|---|
| authz M1 | `isAdminRole` 从白名单退化为黑名单 | **改回白名单**(`ADMIN_ROLES` / `TEACHER_ROLES` 显式列举,对齐 `account/models.py:65-73`)。实测四种已知角色行为不变,而虚构的新角色「助教」现在默认**不是**管理员——黑名单写法下它会默认拿到管理员权限 |
| authz M2 | 比赛权限判断没有中间件兜底 | **新增 `requireContestAccess(checkType, paramName)` 中间件**`services/contest.ts`),把「取比赛 → 404 → 鉴权 → 401/403」收进路由注册行。手工调用点从 5 处降到 2 处:`GET /contests/:id/access` 是**报告**权限而非强制(不能 403),`POST /submissions` 的比赛 id 来自请求体、中间件跑时 body 还没解析,两处都就地写了说明 |
| authz M3 | `blog` / `github` 从 URLField 降级为自由字符串 | **加回 URL 校验**(只放行 `http(s)://`,空串表示清空)。实测 `javascript:alert(1)` 与 `not a url` 均 400`https://example.com/x` 与空串 200 |
| authz M4 | `GET /dev/problems` 仍在线且无鉴权 | **已删除**(连同 `dev-problems.vue`、路由项、`problemSummarySchema`),实测 404 |
| 文档纠错 | `phase3-coverage.md` 把 `PUT submissions/:id` 写成「判题结果写回」 | **已改正**为「提交分享开关」,并注明判题结果写回走内部 worker 不经 HTTP |
| leakage M-1 | 站内信内嵌的 submission 多出 `info` / `ip` 两个空键 | **新增 `embeddedSubmissionSchema`**`omit` 掉 `info`/`ip`/`contestId`),不再复用 `submissionDetailSchema` 传空值——形状对上了,将来有人把空值改成真值也不会变成泄露。实测三个键均已消失 |
| leakage M-2 | `problem-sets/:id/user-progress` 返回学生 `realName` | **无需改动**F2 的 `sampleUser()` 默认关闭开关已经覆盖此处,现在返回 `null`,与旧后端 `UsernameSerializer()` 一致。报告提的「别把教师端功能删掉」的顾虑不成立——旧后端本来就不给 |
| leakage M-3 | `tutorials/:id/exercises` 下发练习答案 | **不改**:旧后端逐字相同(`ExerciseSerializer` 整个 `data` jsonb 出去,且无 `@login_required`)。这是教程练习「答案下发到浏览器、客户端比对」的既有设计,属遗留设计债,不是本次重写引入的回归。要改得连判题方式一起改,不在迁移范围内 |
### 顺带修掉的一个真回归(评审未覆盖)
`GET /api/messages` 内嵌的 submission 原本给的是 `problemId`(数字主键),而旧后端
`SubmissionSafeModelSerializer` 的 `problem` 是 `SlugRelatedField(slug_field="_id")`
即**展示用题号**。`oj/user/message.vue:20` 拿它拼 `/problem/<题号>` 链接,迁移后拼出的是
`/problem/undefined`,题号那一栏也是空的。已改为下发 `problem`(展示题号),实测返回 `"1004"`。
---
## 评审确认没有问题的部分
两份评审各自独立核实、结论一致的部分:
- **无任何敏感字段泄露**`raw_password`、`password`、`auth_token`、`open_api_appkey`、
`session_keys` 在 111 个请求的响应中零命中(含明文密码值的全文 grep)
- **无泄题**`answers`、`ast_rules`、`test_case_id`、`test_case_score` 均未泄露,
已在 `answers` 含完整 C/Python 参考解的题目 1002 上专项验证
- **模板隐藏区正确剥离**
- **`contest.password` 未泄露**
- **11 处 `db.select()` 选全列的写法全部后接显式字段映射 + Zod strip,无一泄露**
- **比赛权限是重建得最好的一块**:`sig#timestamp` 密码、未开始拦截、`ProblemSafeSerializer`
脱敏字段集逐行对齐,五个端点实跑全部正确拦截
- 覆盖面:111 个请求,65 个 oj 路由中 61 个取得 2xx
---
## 附带需处理:本地样本数据受损
泄露评审的种子脚本覆盖了**题目 1001(`problem.id=2`**的 `ast_rules`、`answers`、
`test_case_score`、`template`,原值已丢失(评审用兄弟题目的值做了近似填充,当前
`answers` 为 `[]`)。导入用的 `/tmp/problems.csv` 也已不存在。
影响:本地样本数据的价值在于真实(真实富文本、LaTeX、中文最能暴露序列化问题),
1001 现已失真。
处理:需要时从生产重新导出该行:
```bash
docker exec oj-postgres psql -U onlinejudge -d onlinejudge -c \
"\copy (SELECT * FROM problem WHERE _id='1001') TO STDOUT WITH CSV HEADER" > p1001.csv
```
不紧急 —— 其余 19 道题未受影响。
-433
View File
@@ -1,433 +0,0 @@
# 阶段 3 修复报告
日期:2026-08-07
需求文档:`docs/specs/phase3-fix-list.md`(两份评审合并的 6 条 findings
受修代码:`apps/api`Hono + Drizzle),基线 commit `9c04b00`
提交:
| 短 SHA | 标题 | 覆盖 |
|---|---|---|
| `b4b61af` | fix(阶段3): 匿名不可读用户档案,真名改为默认不下发 | F1、F2 |
| `8237909` | fix(阶段3): 提交可见性守卫补上匿名,详情脱敏,提交接口加限流 | F3、F4、F6 |
| `b7adf29` | fix(阶段3): 去掉判题机 token 的弱默认值 | F5 |
验证环境:postgres `:5433`、redis `:6380`、judge `:8081`API 跑在 `:3000``bun --watch`,改完自动重载)。
测试账号 `e2etest`/`Test123456`Regular User)、`student`Regular User,真名 `Phase 2 Student`)、
`devadmin`Super Admin)。
`cd apps/api && bunx tsc --noEmit`**0 错误**
14 条路径 × {匿名, 登录} 冒烟 → **无 5xx**
---
## F1 —— 匿名可读任意用户完整档案【Critical】
**改法**`apps/api/src/routes/account.ts:101` 的 handler 开头加登录判断,未登录返回空。
对齐旧后端 `OnlineJudge/account/views/oj.py:40``UserProfileAPI.get` 首行
`if not user.is_authenticated: return self.success()`
`services/profile.ts` **没动** —— 它已有 `showRealName` 形参,且调用方传的是
`c.get("user")?.id === target.id`(只有看自己的档案才给真名),这一点与旧后端
`UserProfileSerializer(profile, show_real_name=show_real_name)` 的语义一致。
**实跑对比**(为让证据可读,临时把 `e2etest``real_name` 设为 `张三-测试`,测后已还原为 `NULL`):
修复前:
```
### F1 anon GET /api/profiles/e2etest
status 200 -> {"data":{"id":2,"user":{"id":4,"username":"e2etest","email":"e2e@local.test",
"adminType":"Regular User","problemPermission":"None","createTime":"2026-08-07 07:19:10.789+00",
"lastLogin":"2026-08-07 07:50:40.139+00","openApi":false,"isDisabled":false,"className":null},
"realName":null,"acmProblemsStatus":{"problems":{"5":{"_id":"1004","status":0}}},...
### F1 logged-in (self) GET /api/profiles/e2etest
status 200 email: e2e@local.test realName: 张三-测试
```
修复后:
```
### F1 anon GET /api/profiles/e2etest
status 200 -> {"data":null}
### F1 logged-in (self) GET /api/profiles/e2etest
status 200 email: e2e@local.test realName: 张三-测试
```
匿名拿到空,登录看自己的档案不受影响。
**前端影响:无。** `apps/web/src/shared/api.ts:28` 已经有
`if (response.data === null) return { error: null, data: null }` 的分支,天然兼容。
---
## F2 —— 学生真名无条件下发【Critical】
**改法(结构性,不是逐处删字段)**`apps/api/src/routes/helpers.ts` 新增序列化层:
```ts
export function sampleUser(
source: { id: number; username: string },
realName: string | null | undefined,
options: { includeRealName?: boolean } = {},
): SampleUser
```
`realName` 默认不下发,需要的地方显式传 `{ includeRealName: true }`。这是旧后端
`OnlineJudge/utils/api/_serializers.py``UsernameSerializer(need_real_name=False)`
同一套约定:全仓 11 处调用只有 `contest/serializers.py:84` 一处显式打开。
函数上写了注释,说明「所有下发用户对象的地方都必须走这个函数,不要再手写
`{ id, username, realName }`」——目的就是让后面 admin 侧 45 个端点铺开时不会重犯。
### 13 个下发点清单及处置
| # | 位置 | 端点 | 匿名可达 | 处置 |
|---|---|---|---|---|
| 1 | `routes/account.ts:168` | `GET /rankings/users` | 是 | `sampleUser()`,关 |
| 2 | `routes/problem.ts:86``listItem()` | `GET /problems``GET /problems/:displayId/similar` | 是 | `sampleUser()`,关 |
| 3 | `routes/problem.ts:333` | `GET /problems/:displayId` | 是 | 原本就写死 `null`;改为走 `sampleUser()` 统一入口 |
| 4 | `routes/contest.ts:34``creator()` | `GET /contests``GET /contests/:id` | 是 | `sampleUser()`,关 |
| 5 | `routes/contest.ts:131` | `GET /contests/:id/problems` | 否(比赛权限) | `sampleUser()`,关 |
| 6 | `routes/contest.ts:177` | `GET /contests/:id/problems/:displayId` | 否(比赛权限) | `sampleUser()`,关 |
| 7 | **`routes/contest.ts:209`** | `GET /contests/:id/rank` | 否 | **`{ includeRealName: admin }`,保留** —— 唯一打开的一处 |
| 8 | `routes/content.ts:43` | `GET /announcements` | 是 | `sampleUser()`,关 |
| 9 | `routes/content.ts:64` | `GET /announcements/:id` | 是 | `sampleUser()`,关 |
| 10 | `routes/content.ts:85` | `GET /messages` | 否(`requireAuth` | `sampleUser()`,关 |
| 11 | `routes/content.ts:196` | `GET /tutorials/:id` | 是 | `sampleUser()`,关 |
| 12 | `routes/problemset.ts:62``problemSetCreator()` | `GET /problemsets``GET /problemsets/:id` | 是 | `sampleUser()`,关 |
| 13 | `routes/problemset.ts:169` | `GET /problemsets/:id/problems` | 是 | `sampleUser()`,关 |
| 14 | `routes/problemset.ts:398` | 题单进度列表 | 否(教师及以上) | `sampleUser()`,关。旧后端 `problemset/serializers.py:248` 同样是默认关 |
清单比文档说的「13 个」多一条:文档大概把 `problem.ts:333`(本来就写死 `null`)没计入,
或者把 `problem.ts:86` 服务的两个端点算成一个。**14 处全部收口,无遗漏**:
```
$ grep -rn "realName:" apps/api/src/routes/*.ts | grep -v sampleUser \
| grep -v "realName: schema.userProfile.realName" | grep -v "string | null"
account.ts:94: realName: null, ← 注册时写库的 insert values,不是下发点
helpers.ts:23: realName: options.includeRealName === true ? (realName ?? null) : null,
```
`services/profile.ts:29``realName` 由 F1 的 `showRealName` 形参把关(只有看自己的档案才给),
与旧后端 `UserProfileSerializer` 一致,未改。
**实跑对比**(修复前用 `git stash` 把改动摘掉实测,非静态推断):
修复前:
```
--- announcements (anon) ---
[{"id":2,"username":"student","realName":"Phase 2 Student"}]
--- contests createdBy (anon) ---
[{"id":4,"username":"e2etest","realName":"张三-测试"}]
--- contest rank as CONTEST ADMIN (e2etest, creator) ---
[{"id":2,"username":"student","realName":"Phase 2 Student"}]
--- contest rank as NON-admin (student) ---
[{"id":2,"username":"student","realName":null}]
--- contest detail createdBy (anon) ---
{"id":4,"username":"e2etest","realName":"张三-测试"}
### 匿名 GET /api/rankings/users
[{"id":4,"username":"e2etest","realName":"张三-测试"},{"id":2,"username":"student","realName":"Phase 2 Student"}]
```
修复后:
```
--- announcements (anon) ---
[{"id":2,"username":"student","realName":null}]
--- contests createdBy (anon) ---
[{"id":4,"username":"e2etest","realName":null}]
--- contest rank as CONTEST ADMIN (e2etest, creator) ---
[{"id":2,"username":"student","realName":"Phase 2 Student"}] ← 仍然有,符合预期
--- contest rank as NON-admin (student) ---
[{"id":2,"username":"student","realName":null}]
--- contest detail createdBy (anon) ---
{"id":4,"username":"e2etest","realName":null}
### 匿名 GET /api/rankings/users
[{"id":2,"username":"student","realName":null},{"id":4,"username":"e2etest","realName":null}]
### 匿名 GET /api/problems (createdBy)
[{"id":1,"username":"devadmin","realName":null},{"id":1,"username":"devadmin","realName":null}]
```
比赛榜单对**比赛管理员**(这里是比赛创建者 `e2etest`)仍然下发真名,对非管理员仍然是 `null` —— 行为未变。
**前端影响**`apps/web` 只在 `oj/api.ts:86``shared/api.ts:37,47``realName`,都做了
`?? ""` 的兜底,字段变 `null` 不会炸。真名本来也不该在这些位置显示。
---
## F3 —— 匿名绕过提交可见性守卫【Critical】
**改法**`routes/submission.ts:220``isRegularUser(user)` 改为 `!isAdminRole(user)`
(非管理员即受限,匿名落入受限分支)。
**`isRegularUser` 的其他调用点:查过了,全仓只有这一处。**
```
$ grep -rn "isRegularUser" --include=*.ts apps/ packages/
apps/api/src/routes/submission.ts:31 import
apps/api/src/routes/submission.ts:211 (唯一调用点,即本 finding)
apps/api/src/routes/helpers.ts:27 (定义)
```
既然零个其他调用点,**把 `isRegularUser` 整个删掉**,原地留了一条注释说明为什么不要再加回来。
留着一个对 null 返回 false 的「是普通用户才受限」判定,就是给下一个人准备的坑。
(这一步超出了「只改 findings」的字面范围,但属于 F3 的根因,不是顺手重构。)
**实跑对比**(临时把 `options_sysoptions``submission_list_show_all` 置 false
**该行原本不存在,测后已 DELETE**):
修复前:
```
### F3 GET /api/submissions with submission_list_show_all=false
anon total = 23 ← 全部可见
student total = 0 ← 被正确限制
```
修复后:
```
### F3 GET /api/submissions with submission_list_show_all=false
anon total = 0
student total = 0
```
还原核验:
```
$ bun db.ts delete → deleted rows: 1
$ bun db.ts show → [] (该 key 无行,回到原始状态)
```
---
## F4 —— 提交详情返回判题内部信息与 IP【Important】
**改法**`routes/submission.ts:195``const full = isAdminRole(user) || row.submission.userId === user.id`
改为 `const full = isAdminRole(user)`
依据旧后端 `OnlineJudge/submission/views/oj.py:100-104`
```python
if request.user.is_admin_role():
submission_data = ... SubmissionModelSerializer ... # fields = "__all__"
else:
submission_data = ... SubmissionSafeModelSerializer ... # exclude = ("info", "contest", "ip")
```
把关的是**角色**,不是「是不是自己的提交」。
**实跑对比**(用一条已判完、`info` 有内容的真实提交;`devadmin` 是 Super Admin):
修复前(提交所有者 `e2etest`Regular User):
```
### F4 own submission detail
id 0249d3606fab3e05f4926c45d00efdee | has info: {"err":null,"data":[{"error":0,"memory":7811072,
"output":null,"result":-1,"signal":0,"cpu_time":3,"exit_code":0,"real_time":8,
"test_case":"1","output_md5":"c4ca4238a0b923820dcc509a6f75849b"},{"error": ... | ip: null
```
修复后:
```
submission 0249d3606fab3e05f4926c45d00efdee
OWNER (Regular User) -> info: {} | ip: null | result: -1
ADMIN (Super Admin) -> info: {"err":null,"data":[{"error":0,"memory":7811072,"output":null,
"result":-1,"signal":0,"cpu_time":3,"exit_code":0,"real_time":8,"test_case":"1",
"output_md5":"c4ca4238a0b923820dcc509a6f75849b"},{"error":0,"memory":7749632, ... | ip: null
```
上面这条老提交的 `ip` 本来就是 `NULL`(本机请求没有 `X-Forwarded-For`),
为了单独验证 `ip` 确实被挡住,另造了一条带 `X-Forwarded-For: 10.11.12.13` 的提交:
```
库里实际值: [{"id":"...","ip":"10.11.12.13","info_type":"object"}]
OWNER (Regular User) -> info: {} | ip: null
ADMIN (Super Admin) -> info: {} | ip: "10.11.12.13"
```
库里有值、学生看不到、管理员看得到 —— `ip` 是被脱敏而不是本来就空。
### 前端影响(这条确实波及前端)
`apps/web/src/oj/submission/detail.vue:149`
```html
<n-data-table
v-if="!hideList && submission.info && submission.info.data"
:columns="columns"
:data="submission.info.data"
/>
```
改完之后学生拿到 `info: {}``submission.info.data``undefined``v-if` 不成立,
**测试点结果表格不再渲染。不会报错,只是不显示。**
按要求仍按旧后端行为修。补充一点判断依据:**这不算功能回退**。旧后端 `SubmissionSafeModelSerializer`
本来就 `exclude=("info", ...)`,也就是说**生产环境的学生从来就没看到过这张表**——
`v-if` 的存在本身就是为这个场景写的。所以新后端这一版是「多给了」,现在改回去,
前端不需要适配,线上行为反而回到一致。
**另注意**:旧后端的 `SubmissionSafeModelSerializer` 还 exclude 了 `contest`
新后端仍无条件下发 `contestId`。这超出 F4 的字面范围(评审只点了 `info``ip`),
本次**未改**,记在下方「疑虑」里。
---
## F5 —— 判题机 token 默认值硬编码【Important】
**改法**(三处):
1. `apps/api/src/config.ts`:抽出 `judgeServerToken()`env 缺失时 `randomBytes(32).toString("hex")`
`console.warn` 告警。对齐旧后端 `OnlineJudge/options/options.py:93`
```python
token = os.environ.get("JUDGE_SERVER_TOKEN")
return token if token else rand_str()
```
选「随机 fail-safe」而不是「启动失败」,就是为了不把本地开发搞死 —— 服务照起,
只是判题机心跳被 403 挡掉,日志里有明显告警。
2. `docker/compose.dev.yml``${OJ2_JUDGE_TOKEN:-oj2-dev-token}` → `${OJ2_JUDGE_TOKEN:?...}`
未设置时 compose 直接报错退出。文件顶部加了本地怎么设的三行命令。
3. `.env.example``JUDGE_SERVER_TOKEN=` 留空 + 生成命令注释,说明
`JUDGE_SERVER_TOKEN`(后端读)与 `OJ2_JUDGE_TOKEN`(判题机容器读)名字不同但值必须一致。
**实跑对比**
后端 —— 修复前 `config.judgeServerToken` 恒为 `"oj2-dev-token"`;修复后:
```
--- no env (fail-safe random) ---
[config] JUDGE_SERVER_TOKEN 未设置,已生成一次性随机 token。判题机将无法通过鉴权,
本地开发请在 .env 里设置 JUDGE_SERVER_TOKEN,并让 docker/compose.dev.yml 的 OJ2_JUDGE_TOKEN 取同一个值。
token: 9acd77a3ea1bbd17... len=64
--- second run: different value (proves it is random, not a repo constant) ---
token: f2328363bcda0ad9...
--- with env set ---
token: real-token-from-env
```
两次运行值不同 → 确实是随机,不是换了个仓库常量。env 存在时原样使用。
compose
```
--- compose without OJ2_JUDGE_TOKEN ---
error while interpolating services.judge.environment.TOKEN:
required variable OJ2_JUDGE_TOKEN is missing a value: 请先设置 OJ2_JUDGE_TOKEN,见本文件顶部注释
--- compose with OJ2_JUDGE_TOKEN ---
TOKEN: abc123
```
(当前跑着的判题机容器是用旧的 `oj2-dev-token` 起的,本次没有重启它 ——
下次 `docker compose up` 之前需要按注释设一次 `OJ2_JUDGE_TOKEN` 与 `JUDGE_SERVER_TOKEN`。)
---
## F6 —— 提交接口缺少限流【Important】
**参数来自旧后端,不是拍脑袋定的**:
- `OnlineJudge/utils/throttling.py` —— TokenBucket 算法
- `OnlineJudge/options/options.py:120` —— 默认参数
```python
throttling = {"ip": {"capacity": 100, "fill_rate": 0.1, "default_capacity": 50},
"user": {"capacity": 20, "fill_rate": 0.03, "default_capacity": 10}}
```
- `OnlineJudge/submission/views/oj.py:36-42` —— `SubmissionAPI.throttling` **只用 user 桶**
key 是 `str(request.user.id)``auth_method == "api_key"` 时直接跳过
**改法**:新增 `apps/api/src/services/throttling.ts`
- 同样只挂 user 桶、同样按 user id 做 key,默认参数逐字照抄,且和旧后端一样
**实际值以数据库 `throttling` 配置项为准**,缺失时才用默认值
- 落在 redis`throttling:user:<id>`),TTL = `capacity/fill_rate + 60` 秒,每次调用刷新
- **改用 Lua 脚本做成原子操作**。旧实现自己在 docstring 里写明「对于单个 key 的操作不是线程安全的」,
而限流要挡的正是并发突发 —— 读改写有竞态等于没挡。算法与参数不变,只是把它做对
- 挂点位置与旧后端一致:比赛权限校验之后、取题目之前
- 超限返回 `429 too-many-submissions` + `Please wait N seconds`(旧后端文案
`"Please wait %d seconds" % int(wait)`)。为此 `http.ts` 的 `failure()` 状态码联合类型加了 `429`
**实跑对比**(同一账号连打 15 次 `POST /api/submissions`):
修复前:
```
### F6 rapid POST /api/submissions x15
#1 201 #2 201 #3 201 #4 201 #5 201
#6 201 #7 201 #8 201 #9 201 #10 201
#11 201 #12 201 #13 201 #14 201 #15 201
```
15/15 全过,判题队列可以被一个脚本随便打满。
修复后:
```
### F6 rapid POST /api/submissions x15
#1 201 ... #9 201
#10 429:{"error":{"code":"too-many-submissions","message":"Please wait 23 seconds"}}
#11 429 ... #15 429
```
9 条通过后开始 429。桶初始 `default_capacity=10`,此前 F4 的验证提交已消耗 2 个、
期间回填约 1 个,落在 9 —— 与参数吻合。
---
## 改动文件清单
| 文件 | finding |
|---|---|
| `apps/api/src/routes/account.ts` | F1、F2 |
| `apps/api/src/routes/helpers.ts` | F2(新增 `sampleUser`)、F3(删除 `isRegularUser` |
| `apps/api/src/routes/contest.ts` | F2 |
| `apps/api/src/routes/content.ts` | F2 |
| `apps/api/src/routes/problem.ts` | F2 |
| `apps/api/src/routes/problemset.ts` | F2 |
| `apps/api/src/routes/submission.ts` | F3、F4、F6 |
| `apps/api/src/services/throttling.ts` | F6(新文件) |
| `apps/api/src/http.ts` | F6`failure()` 支持 429 |
| `apps/api/src/config.ts` | F5 |
| `docker/compose.dev.yml` | F5 |
| `.env.example` | F5 |
`apps/web` **一个字节没改**(F1/F4 的前端影响见上文,均无需适配)。
---
## 纪律自查
- **旧仓库冻结**`OnlineJudge/` 与 `ojnext/` 的 `git status --short` 均为空,全程只读。
- **没写测试**:符合项目既定策略,验证一律走实跑 API。
- **没有范围蔓延**:唯一超出 findings 字面范围的是「删掉 `isRegularUser`」和
「`http.ts` 的 `failure()` 加 429」,前者是 F3 的根因、后者是 F6 的必要条件,都在上面说明了。
- **数据库改动全部还原**
| 改动 | 还原 | 核验 |
|---|---|---|
| `e2etest.real_name` 设为 `张三-测试` | 改回 `NULL`(原值已知) | `[{"username":"student","real_name":"Phase 2 Student"},{"username":"e2etest","real_name":null}]` |
| `options_sysoptions` 插入 `submission_list_show_all=false`**该行原本不存在** | `DELETE` | `bun db.ts show` → `[]` |
| 造了 1 个比赛 + 1 条榜单行 + 1 条公告(原本 0/0/0) | 全部删除 | `contests: 0 \| ranks: 0 \| announcements: 0` |
| 限流/F4 测试产生 26 条提交 | 按快照删除,并把 `problem` 与 `user_profile` 的 `submission_number`/`accepted_number`/`statistic_info`/`acm_problems_status` 恢复到快照值 | `submissions now: 8 \| snapshot had: 8` |
| `devadmin` 密码临时改成探针口令(为了验证 F4 的管理员视角) | 用保存的原 hash 写回 | `restored devadmin password hash, identical: true \| raw_password: devonly` |
redis 里的 `throttling:*` 也清了(`deleted throttling keys: 1`),免得本地开发一上来就撞限流。
- **临时脚本全在 `/tmp` 的 scratchpad**,仓库里没留(`git status` 干净,只有 3 个提交)。
- **没有 drop/truncate 任何表。**
---
## 疑虑
1. **`contestId` 仍无条件下发**。旧后端的 `SubmissionSafeModelSerializer` 是
`exclude = ("info", "contest", "ip")` —— 三个字段,新后端只脱敏了两个。
评审 F4 只点了 `info` 与 `ip`,我按 finding 的字面范围修,没顺手动 `contestId`。
泄露面比另外两个小得多(只是一个比赛 id,且比赛可见性另有把关),但**严格说没对齐旧后端**。
建议下一轮一并处理,或明确判定新后端就是要下发它。
2. **限流的 429 状态码是新引入的语义**。旧后端所有错误都走 `self.error()` → HTTP 200 +
`{"error": ..., "data": null}` 信封;新后端用真实状态码。前端目前**没有**针对 429 的处理,
学生撞到限流时会走通用错误提示(能看到 `Please wait N seconds` 文案,但没有专门的 UI)。
不阻塞,但前端接线时值得单独确认一下。
3. **限流按 user id 计,匿名不涉及** —— 因为 `POST /submissions` 挂了 `requireAuth`。
旧后端的 ip 桶(`capacity 100, fill_rate 0.1`)在 `SubmissionAPI` 里同样没用上,
所以这里是对齐的,不是漏搬。但配置项里保留了 ip 桶的默认值,将来要挂匿名端点可以直接用。
4. **判题机容器还在用旧 token**。F5 改的是「以后」的行为,当前跑着的
`oj2-judge` 容器是之前用 `oj2-dev-token` 起的,本次没有重启它(重启会打断验证)。
下次 `docker compose -f docker/compose.dev.yml up` 之前必须先按注释设好
`OJ2_JUDGE_TOKEN` 与 `JUDGE_SERVER_TOKEN`(两者取同一个值),否则 compose 会直接报错。
5. **F2 的下发点我数出 14 处,文档说 13 处**。差异见上文表格的说明(大概率是
`problem.ts:333` 本来就写死 `null` 没计入)。已用 grep 反证收口完整,
但如果评审方手里有一份逐条清单,值得对一遍确认不是我漏看了某个反方向的差异。
-338
View File
@@ -1,338 +0,0 @@
# 阶段 3 权限与鉴权边界评审
日期:2026-08-07
受审对象:`apps/api`Hono + Bun),oj 侧 65 条端点
参照基准:`../OnlineJudge`Django + DRF,只读)
工作清单:`docs/specs/phase3-coverage.md` 的「oj 侧逐条对照」表
验证方式:逐条读源码 + 对本机 `http://localhost:3000` 实跑(测试账号 `e2etest`,普通学生)
---
## 1. 结论摘要
**3 条 Critical2 条 Important4 条 Minor。**
整体上权限骨架是**忠实重建**的:比赛的 `check_contest_permission`(含密码保护、未开始拦截、contest admin 豁免、ProblemSafeSerializer 的字段脱敏)、提交的 `check_user_permission`、流程图的所有者判断、角色判定函数(`is_admin_role` / `is_teacher_or_above` / `is_contest_admin`)都与旧后端逐行等价,实跑验证也全部对上。**我事先怀疑的四处里有三处是误判**——`GET /contests/:id``GET /classes/:className/usernames``GET /users/:id/metrics` 在旧后端本来就是无鉴权的,新后端没有放宽;判题机心跳也确实做了 token 校验,只是校验用的密钥有问题。
真正的问题集中在**"序列化层的隐式权限"**:旧后端把权限判断藏在 DRF 序列化器的默认参数里(`UsernameSerializer(need_real_name=False)``UserProfileAPI` 开头的 `is_authenticated` 短路),新后端重写时只搬了装饰器、没搬序列化器里的这层,于是**真实姓名和邮箱被无差别下发给未登录访客**。学生是中职生(未成年人),真名 + 用户名 + 班级可以直接拼出花名册,这是本次最严重的问题。
另有一处逻辑翻转:`submission_list_show_all=false` 时,**未登录用户能拿到全站提交列表,而登录的学生拿到空列表**——匿名比登录更有权限。
---
## 2. 逐条对照表(只列有差异的行)
未列出的条目 = 新旧权限要求一致,见第 4 节。
| # | 旧端点 | 旧权限要求 | 新路由 | 新权限实现 | 判定 |
|---|---|---|---|---|---|
| 1 | `account/profile`GET | 视图首行 `if not user.is_authenticated: return success()` —— 等价 `@login_required` | `GET /profiles/:username` | `optionalAuth`,无任何 user 判断 | **更松(Critical** |
| 2 | `account/user_rank` | 无装饰器,但 `RankInfoSerializer → UsernameSerializer(need_real_name=False)` 恒返回 `real_name: null` | `GET /rankings/users` | 无中间件,`realName: profile.realName` 无条件下发 | **更松(Critical** |
| 3 | `submission/submissions` | 无装饰器;`not show_all and user.is_regular_user()` —— 匿名走到这行会 `AttributeError`Django `AnonymousUser` 无该方法),必定拿不到数据 | `GET /submissions` | `optionalAuth``isRegularUser(null) === false` → 守卫不生效,直接返回全量 | **更松(Critical** |
| 4 | `announcement/announcement` | `AnnouncementSerializer.created_by = UsernameSerializer()``real_name: null` | `GET /announcements``GET /announcements/:id` | `createdBy.realName` 无条件下发(content.ts:43、64 | **更松** |
| 5 | `contest/contests``contest/contest` | `ContestSerializer.created_by = UsernameSerializer()``null` | `GET /contests``GET /contests/:id` | `creator()` 返回 realNamecontest.ts:31-34 → :47 | **更松** |
| 6 | `problem/problem`(列表与详情) | `ProblemListSerializer.created_by = UsernameSerializer()``null` | `GET /problems``GET /problems/:displayId/similar` | `listItem()` 下发 realNameproblem.ts:86 | **更松** |
| 7 | `problem/contest/problem` | 同上 | `GET /contests/:id/problems``.../:displayId` | contest.ts:131、:177 下发 realName | **更松** |
| 8 | `problemset/problemset``/<id>``/<id>/problems` | `UsernameSerializer()``null` | `GET /problem-sets*` | problemset.ts:62、:169 下发 realName | **更松** |
| 9 | `problemset/<id>/users_progress` | `@teacher_admin_required` + `ProblemSetProgressSerializer.user = UsernameSerializer()`**即使教师也拿不到 real_name** | `GET /problem-sets/:id/user-progress` | `requireAuth` + `isTeacherOrAbove`(角色判断一致),但 problemset.ts:398 下发 realName | 角色一致,字段**更松** |
| 10 | `message/message`GET | `@login_required` + `sender = UsernameSerializer()``null` | `GET /messages` | `requireAuth`(一致),content.ts:85 下发 `sender.realName` | 角色一致,字段**更松** |
| 11 | `tutorial/tutorial` | 无装饰器;`created_by = UserSerializer()`**含 email**,无 real_name | `GET /tutorials/:id` | 无中间件;`createdBy.realName` 下发,不含 email | realName **更松** / email **更严** |
| 12 | `conf/judge_server_heartbeat/` | 无装饰器,校验 `sha256(SysOptions.judge_server_token)`env 缺失时回落到 `rand_str()`(随机、fail-safe | `POST /judge-server/heartbeat` | 校验 `sha256(config.judgeServerToken)`env 缺失时回落到硬编码常量 `"oj2-dev-token"`fail-open | **更松(Important** |
| 13 | `submission/submission`POST | `@login_required` + `TokenBucket` 用户级限流(`SysOptions.throttling` | `POST /submissions` | `requireAuth`**无限流** | **更松(Important** |
| 14 | `conf/class_usernames` | 无装饰器,`classroom` 参数无格式校验 | `GET /classes/:className/usernames` | 无中间件,但加了 `^\d{3,4}$` 校验 | **更严** |
| 15 | 全部需登录端点 | 装饰器内 `if request.user.is_disabled: return error` | 所有路由 | `getUserByToken` 发现 `isDisabled` 直接删 session 并返回 null | **更严**(禁用即时踢线) |
---
## 3. Findings
### Critical
---
#### C1 — `GET /api/profiles/:username` 未登录即可读取任意用户完整档案(含邮箱)
**位置**`apps/api/src/routes/account.ts:101-108``optionalAuth`
**服务层**`apps/api/src/services/profile.ts:6-41`
**旧后端**`OnlineJudge/account/views/oj.py:38-41`
```python
async def get(self, request, **kwargs):
user = request.user
if not user.is_authenticated:
return self.success() # ← 匿名直接返回空,等价 @login_required
```
新后端只用了 `optionalAuth`handler 内部**没有任何** `c.get("user")` 判断,`getUserProfileById` 的第二参数只控制 `realName` 是否下发,其余字段(含 `email`)无条件返回。
**实跑证据**(无 cookie):
```
$ curl -s http://localhost:3000/api/profiles/e2etest
{"data":{"id":2,"user":{"id":4,"username":"e2etest","email":"e2e@local.test",
"adminType":"Regular User","problemPermission":"None",
"createTime":"2026-08-07 07:19:10.789+00","lastLogin":"2026-08-07 07:25:35.98+00",
"openApi":false,"isDisabled":false,"className":null},
"realName":null,"acmProblemsStatus":{"problems":{"5":{"_id":"1004","status":0}}},
"avatar":"/public/avatar/default.png","blog":null,"mood":"probe",
"github":null,"school":null,"major":null,"language":null,
"acceptedNumber":1,"submissionNumber":2}}
```
**复现**`curl http://localhost:3000/api/profiles/<任意用户名>`,不带 Cookie。
**影响**:泄露邮箱、账号角色(`adminType` 可用于定位管理员账号做定向爆破)、最后登录时间、班级、全部做题记录。配合同样无鉴权的 `GET /classes/:className/usernames`(可枚举整班用户名,旧后端同样开放),可无凭据批量拉取全校师生的邮箱与角色。
---
#### C2 — 真实姓名(`realName`)被无条件序列化,未登录即可读取学生真名
**最严重实例**`apps/api/src/routes/account.ts:168``GET /rankings/users`,无任何中间件)
```ts
user: { id: user.id, username: user.username, realName: profile.realName },
```
**旧后端**`OnlineJudge/account/serializers.py:145-150` + `OnlineJudge/utils/api/_serializers.py:9-14`
```python
class RankInfoSerializer(serializers.ModelSerializer):
user = UsernameSerializer() # ← need_real_name 默认 False
class UsernameSerializer(serializers.Serializer):
def get_real_name(self, obj):
return obj.userprofile.real_name if self.need_real_name else None
```
旧后端把"是否下发真名"做成了序列化器的**默认关闭开关**,全仓库 11 处 `UsernameSerializer(...)` 调用里只有 1 处显式打开(`contest/serializers.py:84`,且条件是 `is_contest_admin`)。新后端没有对应机制,凡是 join 到 `user_profile` 的地方就直接把 `realName` 塞进响应。
**实跑证据**(先给 `e2etest` 写入真名,再无 cookie 请求,最后已还原为 NULL):
```
$ curl -s "http://localhost:3000/api/rankings/users?limit=5"
{"data":{"results":[
{"id":2,"user":{"id":4,"username":"e2etest","realName":"张三(审计测试)"},
"acceptedNumber":1,"submissionNumber":2,"mood":"probe"},
{"id":1,"user":{"id":2,"username":"student","realName":"Phase 2 Student"},
"acceptedNumber":1,"submissionNumber":5,"mood":null}, ...]}}
```
`student` 这条是**本机既有数据**,没有经过我任何修改,其 `real_name = "Phase 2 Student"` 直接被匿名请求读到。
**同一根因的全部下发点**(旧后端对应字段一律为 `null`):
| 文件:行 | 路由 | 是否匿名可达 |
|---|---|---|
| `routes/account.ts:168` | `GET /rankings/users` | **是**(学生真名,危害最大) |
| `routes/content.ts:43` | `GET /announcements` | 是 |
| `routes/content.ts:64` | `GET /announcements/:id` | 是 |
| `routes/content.ts:196` | `GET /tutorials/:id` | 是 |
| `routes/contest.ts:34``creator()`,经 :47 | `GET /contests``GET /contests/:id` | 是 |
| `routes/problem.ts:86``listItem()` | `GET /problems``GET /problems/:displayId/similar` | 是 |
| `routes/problemset.ts:62``problemSetCreator()` | `GET /problem-sets``GET /problem-sets/:id` | 是 |
| `routes/problemset.ts:169` | `GET /problem-sets/:id/problems` | 是 |
| `routes/contest.ts:131` | `GET /contests/:id/problems` | 需登录 + 过密码 |
| `routes/contest.ts:177` | `GET /contests/:id/problems/:displayId` | 需登录 + 过密码 |
| `routes/content.ts:85` | `GET /messages``sender.realName` | 需登录 |
| `routes/problemset.ts:398` | `GET /problem-sets/:id/user-progress` | 需教师 |
注:`routes/problem.ts:333``GET /problems/:displayId`)和 `routes/contest.ts:209`(比赛榜单,`admin ? realName : null`)是**正确**的两处,说明这套逻辑并非无人知晓,只是没有被统一执行。
**建议**:在 `sampleUserSchema` 层面把 `realName` 变成必须显式打开的字段(例如收敛成一个 `serializeUserRef(user, { realName: boolean })` 帮助函数),而不是在 13 个调用点分别记得置 null。
---
#### C3 — `submission_list_show_all=false` 时,未登录用户能读全站提交列表,登录学生反而读不到
**位置**`apps/api/src/routes/submission.ts:211`
```ts
if (!(await getBooleanOption("submission_list_show_all", true)) && isRegularUser(user)) {
return success(c, submissionListSchema.parse({ results: [], total: 0 }))
}
```
`isRegularUser` 定义于 `routes/helpers.ts:27-29``user?.adminType === "Regular User"`。**匿名用户 `user === null` → 返回 `false` → 守卫短路,全量下发。**
**旧后端**`OnlineJudge/submission/views/oj.py:149-151`
```python
show_all = await SysOptions.aget("submission_list_show_all")
if not show_all and request.user.is_regular_user():
return self.success({"results": [], "total": 0})
```
Django 的 `AnonymousUser` 没有 `is_regular_user` 方法(`account/models.py:53` 定义在自定义 `User` 上),匿名请求走到这行会抛 `AttributeError` → 500。旧后端在这个配置下**绝不可能**把列表交出去。
**实跑证据**(临时插入 `options_sysoptions` 行,测完已 DELETE 还原):
```
site.submissionListShowAll = false
ANON /submissions?limit=2 -> total=7 n=2 ← 未登录,拿到全部 7 条
REGULAR logged-in /submissions?limit=2 -> total=0 n=0 ← 登录学生,拿到 0 条
```
匿名响应含每条提交的 `username`、题号、语言、结果、耗时。
**复现**:把 `options_sysoptions``submission_list_show_all` 置为 `false`,然后不带 Cookie 请求 `GET /api/submissions?limit=10`
**影响**:该开关的唯一用途就是"考试期间不让学生互相看提交"。现在学生只要开一个隐私窗口(或直接删 Cookie)就能绕过,开关等于失效。
---
### Important
---
#### I1 — 判题机共享密钥的默认值硬编码在仓库里(fail-open)
**位置**`apps/api/src/config.ts:8`
```ts
judgeServerToken: process.env.JUDGE_SERVER_TOKEN ?? "oj2-dev-token",
```
`.env.example:4` 同样写着 `JUDGE_SERVER_TOKEN=oj2-dev-token`
**旧后端**`OnlineJudge/options/options.py:92-94`
```python
def default_token():
token = os.environ.get("JUDGE_SERVER_TOKEN")
return token if token else rand_str() # ← 没配就随机,fail-safe
```
生产 compose`OnlineJudge/docker-compose.yml:44,65`)显式注入 `_Mam^^1rvC86Qko2d0`。也就是说旧后端**漏配环境变量的后果是判题机连不上(立刻暴露)**,新后端**漏配的后果是任何人都能通过心跳校验(静默)**。
**实跑证据**
```
no-token -> 403 {"error":{"code":"invalid-judge-token",...}} ← 校验存在,符合旧行为
default-dev-token -> 200 {"error":null,"data":null} ← 用仓库里的常量即通过
```
(本机确实跑在默认 token 下。测试插入的 `hostname='pwned-by-audit'` 行已删除。)
**当前实际影响有限**`judge/run.ts:69` 派发判题用的是 `config.judgeServerUrl`(环境变量),**不读** DB 里的 `service_url`,所以伪造心跳无法把判题流量劫走,只能污染 `judge_server` 表 / 管理端仪表盘。但这是运气好,不是设计使然——`service_url` 字段被写进了库(judge-server.ts:63),一旦将来 admin 侧或调度器改成读它,同一个洞就升级为 Critical。
**建议**`JUDGE_SERVER_TOKEN` 缺失时启动即 fail(或生成随机值并打日志),不要留可猜的默认值。
---
#### I2 — `POST /submissions` 丢失了提交限流
**位置**`apps/api/src/routes/submission.ts:52`(只有 `requireAuth`
**旧后端**`OnlineJudge/submission/views/oj.py:35-42``:68-70`
```python
def throttling(self, request):
user_bucket = TokenBucket(key=str(request.user.id), redis_conn=cache,
**SysOptions.throttling["user"])
can_consume, wait = user_bucket.consume()
if not can_consume:
return "Please wait %d seconds" % (int(wait))
```
旧配置 `{"capacity": 20, "fill_rate": 0.03}` ≈ 每用户约 2 提交/分钟。新后端没有任何等价物,任一登录学生可以无限速向 `judgeQueue` 灌任务。这不是读权限问题,但它是旧后端里唯一挡住"一个学生打爆判题沙箱"的机制,重写时整体丢失了,归入本报告以免遗漏。
---
### Minor
**M1 — `isAdminRole` 从白名单退化为黑名单**
`routes/helpers.ts:31-33``Boolean(user && user.adminType !== "Regular User")`
旧:`account/models.py:65-70` 显式列举 `[STUDENT_ADMIN, TEACHER_ADMIN, SUPER_ADMIN]`
当前四种角色下两者等价,但将来新增任何角色(如"助教""家长")都会**默认获得 admin 权限**,包括 `canViewSubmission` 里的"看所有人代码"。建议改回白名单。
**M2 — 比赛权限判断没有中间件兜底**
`canAccessContest``contest.ts:116/144/193``submission.ts:63/253` 共 5 处手工调用。旧后端用 `@check_contest_permission` 装饰器,漏挂会很显眼;新后端漏调一次就是静默放行,且 `GET /contests/:id/problems` 这类路由挂的是 `optionalAuth`(本身不拦人),从中间件列表上完全看不出它受保护。目前 5 处都调对了,属可维护性风险。
**M3 — `blog` / `github` 从 URLField 降级为自由字符串**
`packages/contract/src/account.ts:15,17``z.string().max(256)`;旧 `account/serializers.py:125,127``serializers.URLField`。现在 `PUT /me/profile` 可以写入 `javascript:alert(1)` 之类的值,是否可利用取决于前端如何渲染这两个字段。
**M4 — `GET /dev/problems` 仍在线且无鉴权**
`routes/problem.ts:231`。内容本身是公开题目摘要,无实际泄露,但它是阶段 1 的临时端点,`phase3-coverage.md` 第 120 行已标注应删除,至今还挂在生产路由树上。
**(文档纠错,非安全问题)** `phase3-coverage.md:96``PUT submissions/:id` 描述为"判题结果写回"。实际实现(`submission.ts:301-316`)是**提交分享开关**,对应旧 `SubmissionAPI.put` + `ShareSubmissionSerializer`。判题结果写回走的是内部 worker,不经 HTTP。这条描述会让人误以为存在一个需要判题机凭据的写入端点,建议改正。
---
## 4. 核实过、确认没有问题的项
以下是逐条读过源码(多数还实跑过)、确认**新后端权限要求与旧后端等价或更严**的部分。
**四个预先怀疑点里的三个是误判:**
- `GET /contests/:id`contest.ts:78)——旧 `ContestAPI.get``contest/views/oj.py:38-51`)本来就**没有任何装饰器**,公开可读。新后端行为一致,且 `serializeContest` 不下发 `password`(旧 `ContestSerializer``exclude`)。**真正受保护的比赛内容(题目、榜单、提交)全部走 `canAccessContest`,实跑验证如下:**
```
ANON /contests/5 200 ← 与旧后端一致
ANON /contests/5/problems 401 login-required
ANON /contests/5/rank 401 login-required
ANON /contests/5/submissions 401 login-required
ANON /contests/5/access 401 login-required
AUTHnopw /contests/5/problems 403 wrong-password ← 登录但没过密码,正确拦截
AUTHnopw /contests/5/rank 403 wrong-password
```
(测试用的密码保护比赛 id=5 已删除。)
- `GET /classes/:className/usernames`site.ts:36)——旧 `ClassUsernamesAPI``conf/views.py:237-243`)无装饰器,公开。新后端一致,且**多加了** `^\d{3,4}$` 格式校验。属旧后端遗留的开放面,不是本次重写引入的(但确实是 C1 的放大器,值得单独排期收口)。
- `GET /users/:id/metrics`account.ts:140)——旧 `Metrics``account/views/oj.py:66-83`)无装饰器,按 `?userid=` 任取,也不限制只查自己。新后端一致。同上,属遗留开放面。
- `POST /judge-server/heartbeat`——token 校验**存在**且用了 `timingSafeEqual`,比旧后端的裸字符串比较更好;问题只在默认值(见 I1)。
**比赛权限(最复杂的一块,逐条对齐):**
- `canAccessContest``services/contest.ts:45-63`)与 `check_contest_permission._check_access``account/decorators.py:122-136`)逐行等价:未登录 → login-requiredcontest admin 豁免;密码保护校验 session 内存的密码;`check_type != "details"` 时拦截未开始的比赛。
- `checkContestPassword``services/contest.ts:28-37`)完整复刻了 `sig#timestamp` 形式(`decorators.py:87-110`),含 sha256 前 8 位与过期判断。
- `contestDetailsAllowed``services/contest.ts:24-26`= `Contest.problem_details_permission``contest/models.py:42-43`)。
- 比赛未结束时对非管理员脱敏的字段集完全一致:`difficulty`、`submissionNumber`、`acceptedNumber`、`statisticInfo` 被置空 —— 正是旧 `ProblemSafeSerializer``problem/serializers.py:211-228`exclude 的那几个(`answers` 新后端从不下发)。
- 比赛榜单 `realName` 正确地由 `admin ? realName : null` 控制(contest.ts:209),对齐 `ACMContestRankSerializer(is_contest_admin=...)`。
- `POST /submissions` 的比赛分支完整保留了"比赛已结束禁止提交"和 `allowedIpRanges` 白名单(submission.ts:65-68 vs `submission/views/oj.py:47-53`);`ipAllowed`services/contest.ts:71-85)的 CIDR 计算正确,空列表放行、无 IP 拒绝。
- `GET/POST /contests/:id/access` 均要求登录且比赛必须设了密码,与旧 `ContestAccessAPI` / `ContestPasswordVerifyAPI`(均 `@login_required`)一致。
**提交与流程图的所有者判断:**
- `canViewSubmission`submission.ts:166-178)与 `Submission.check_user_permission``submission/models.py:47-56`)等价,且新增了 `if (!user) return false` —— 更严。
- `check_share=False` 语义(用于分享开关和 `canUnshare`)正确传递。
- `GET /submissions/:id` 用 `requireAuth`,无权限时返回 404 而非 403,不泄露存在性 —— 比旧的 "No permission for this submission" 更严。
- `PUT /submissions/:id`(分享开关)保留了"比赛进行中不得分享"。
- 流程图 `canView`flowchart.ts:24-26= `FlowchartSubmission.check_user_permission``flowchart/models.py:59-63`);列表的 `myself` / `username` / 普通用户默认只看自己 三段逻辑与 `flowchart/views/oj.py:78-83` 一致;`current` / `history` 强制 `userId = 自己`。
**角色与身份:**
- `isRegularUser` / `isAdminRole` / `isTeacherOrAbove` / `isSuperAdmin`helpers.ts:27-41)与 `account/models.py:53-73` 语义一致(`isAdminRole` 的黑名单写法见 M1)。
- `isContestAdmin`services/contest.ts:20-22= `User.is_contest_admin``account/models.py:78-79`)。
- 禁用账号:`getUserByToken`auth/session.ts:105-108)发现 `isDisabled` 立即删除 session 并返回 null,比旧后端"每个装饰器里各判一次"更彻底。
- Session token 用 `randomBytes(32)` + RedisCookie `httpOnly` + `SameSite=Lax`,登出正确删 Redis key 与 Cookie。
**逐条确认权限一致的端点:**
- `POST /auth/login`、`DELETE /auth/session`、`POST /users`(注册)、`GET /me` —— 均与旧后端一致(`GET /me` 匿名返回 null,对应旧 `UserProfileAPI` 匿名返回空)。注册同样受 `allow_register` 控制;`rawPassword` 明文留存是旧 `User.set_password``account/models.py:81-83`)就有的行为,非新增。
- `PUT /me/profile` 可写字段集与旧 `EditUserProfileSerializer` **完全相同**(8 个字段,无 `acceptedNumber` 等统计字段),无字段注入面。
- `POST /me/avatar`、`POST /me/problem-display-ids/refresh`、`GET /problems/:displayId/rank`、`GET /me/class-rank` —— `requireAuth`,且只操作 `c.get("user")!.id`,无越权参数。
- 成就四条(achievement.ts:39/75/103/111)—— 均 `requireAuth``?username=` 允许查他人,**与旧 `_resolve_user``achievement/views/oj.py:10-15``?name=`)完全一致**,不是新放宽;`pending` / `pending/read` 强制本人。隐藏成就的掩码逻辑也保留了(不下发 metric/threshold/progress)。
- AI 九条 —— 全部 `requireAuth``targetUser`ai.ts:45-59)正确复刻了"仅 teacher_or_above 才能用 `?username=` 查他人"`ai/views/oj.py:239-244`);`/ai/class-pk-analysis` 保留 `isTeacherOrAbove`(对齐 `@teacher_admin_required`);`/ai/hint` 强制提交必须属于本人,且系统提示词明确禁止透露参考答案。
- `GET /messages` 只查 `recipientId = 自己``POST /messages` handler 内 `isSuperAdmin` 判断,对齐 `@super_admin_required`。
- 表情 `GET/POST /problems/:id/reaction` —— `requireAuth`,且 POST 要求该题有 AC 记录才能表态。
- 题单:`/problem-sets*` 全部过滤 `visible=true AND status != 'draft'``POST/PUT /problem-set-progress` 只改自己的进度,且 PUT 校验 submission 归属本人 + 已 AC + 题目确在题单内;`/problem-sets/:id/user-progress` 角色判断与 `@teacher_admin_required` 一致。
- 公开内容(`GET /announcements*`、`/tutorials*`、`/problem-tags`、`/problems/random`、`/problem-authors`、`/problems/:displayId/yearly-ac`、`/problem-sets/:id/badges`、`/site`、`/quotes/random`、`/submissions/today-count`、`/rankings/activity`、`/rankings/classes`、`/classes/comparison`)—— 旧后端对应视图**同样无装饰器**,一致。`/problem-authors?all=1` 能看到不可见题目的作者名,旧 `ProblemAuthorAPI``problem/views/oj.py:248`)行为相同。
- 公告 / 教程 / 题单 / 题目查询一律带 `visible` / `is_public` / `status != draft` 过滤,无隐藏内容泄露。
- WebSocket `/ws/submissions`index.ts:71-83、websocket.ts)—— 升级前强制校验 session;订阅 topic 按 `userId` 隔离;`subscribe` 消息查库时带 `eq(userId, ws.data.userId)`,无法订阅他人提交;推送前二次校验账号未禁用。**无越权面。**
- 头像静态服务(index.ts:53-68)对路径做了 basename 校验,无目录穿越。
**Hono 路由注册顺序**已核:`/problems/random`、`/problems/:id/beat-count`、`/submissions/today-count` 等具体路径均注册在同前缀的 `:param` 路由之前,不存在鉴权路由被无鉴权通配路由抢先匹配的情况。
---
## 5. 测试期间对本机数据库的改动(均已还原)
| 操作 | 还原情况 |
|---|---|
| `user_profile.real_name` 设为 `'张三(审计测试)'`user_id=4)、`'李老师(审计)'`author | 已置回 NULL / 原值 |
| `options_sysoptions` 插入 `submission_list_show_all=false` | 已 DELETE(该键原本不存在) |
| `judge_server` 插入 `hostname='pwned-by-audit'` | 已 DELETE |
| `contest` 插入密码保护比赛(id=5) | 已 DELETE,验证过 0 行 |
未执行任何 `DROP` / `TRUNCATE`;未修改 `OnlineJudge/`、`ojnext/` 或 OJ2 的任何源码;未改动 git 状态。
-583
View File
@@ -1,583 +0,0 @@
# 阶段 3 评审:响应数据泄露
日期:2026-08-07
受审对象:`apps/api/src`Hono + Drizzleoj 侧 65 条端点)
参照基准:`OnlineJudge/<app>/serializers.py` 的 DRF serializer 白名单(只读,未改动)
方法:静态扫描 + 实跑取真实响应逐字段比对
---
## 1. 结论摘要
**Critical0 条。Important3 条。Minor3 条。**
最重要的一条是 **`GET /api/profiles/:username` 允许匿名访问并返回 `user.email`**
`routes/account.ts:101` + `services/profile.ts:19`)。旧后端 `UserProfileAPI.get`
第一件事就是 `if not user.is_authenticated: return self.success()`,匿名拿不到任何东西;
新后端挂的是 `optionalAuth`,不带任何 cookie 就能按用户名遍历全校学生邮箱。
好消息(**已逐条实证,不是推断**):
- **`user.raw_password`(明文密码列)从未出现在任何 oj 侧响应里。** 全量响应体里
grep `Test123456` / `student123` / `devonly` / `Probe123456` —— **0 命中**
- `password` / `auth_token` / `open_api_appkey` / `session_keys` 这四个字段名
**在全部响应里一次都没出现过**(对 111 次请求的响应做了递归字段名收集后统一 grep)。
- `problem.answers`(库里存的是**完整参考解代码**,C + Python)、`test_case_id`
`test_case_score``ast_rules` **全部没有出现在题目相关响应里**
- `problem.template` 只返回 `//TEMPLATE BEGIN/END` 区间,`//PREPEND` / `//APPEND`
隐藏区被正确剥掉(与旧 `parse_problem_template` 行为一致)。
- `contest.password` **没有泄露**`GET /api/contests` 那句 `db.select()`(选全部列)
后面接的是显式字段映射,password 只被用来算 `contestType`
- 他人代码:`GET /api/submissions/:id` 的权限判定与旧 `check_user_permission` 等价,
未 share 且题目未开 `share_submission` 时返回 404。
也就是说:**旧后端 serializer 挡住的"泄题类"字段,新后端一条都没漏。**
本次发现的问题集中在 **PII(邮箱、真实姓名)****判题细节(`info` / `ip`** 两类。
---
## 2. 扫描方法与覆盖面
### 2.1 静态扫描
`apps/api/src` 下检索四种"选全部列"写法:
| 写法 | 命中数 | 结论 |
|---|---|---|
| `db.select()` 后不跟对象字面量 | 11 处 | 见第 4 节,**全部无泄露** |
| `db.query.xxx.findMany/findFirst``columns:` | 0 处 | 新后端完全没用 relational query API |
| `...row` / `...user` 整行展开进响应 | 1 处 | `problem.ts:228`,展开的是聚合查询结果(`total`/`accepted`),非表行 |
| `c.json(row)` 直接丢数据库行 | 0 处 | 全部响应走 `success(c, ...)` + Zod `.parse()` |
额外检查 `packages/contract`**没有任何 `.passthrough()` / `z.looseObject` / `.catchall()`**
Zod v4 object 默认 strip 未知键,因此"多选了列"本身不会自动变成泄露 ——
只有被显式写进 `.parse({...})` 入参的字段才会出去。这是新后端的第二道防线,实测有效。
`z.record(z.string(), z.unknown())` 有 16 处(`statisticInfo``exercise.data`
`flowchartData``sqlConfig``acmProblemsStatus``submissionInfo`),
这些是 jsonb 原样透传,是唯一绕过 Zod 裁剪的通道。逐个核对过,见第 4/5 节。
### 2.2 动态扫描
API 跑在 `http://localhost:3000`。用三个身份 + 匿名共四种视角:
| 身份 | 说明 |
|---|---|
| `e2etest`(user 4) | 普通学生,主视角 |
| `leakprobe2`user 5 | 现场用 `POST /api/users` 注册的第二个学生,验"看他人数据" |
| `student`(user 2) | 已有学生,作为"被看的人"(有提交、有 `real_name` |
| 匿名(不带 cookie) | 验未登录可见面 |
因为库里 `contest` / `problemset` / `tutorial` / `exercise` / `announcement` /
`message` / `achievement` / `flowchart_submission` / `acm_contest_rank` 都是**空表**
先造了带标记值的测试数据再打:
- 带密码的比赛(`password = 'SUPERSECRET-CONTEST-PW'`+ 一道比赛题
- 题单 + 题单题(`hint = 'HINT-SECRET'`+ 徽章 + 进度
- 教程 + 练习(`data.answer` / `data.explanation = 'SECRET-ANSWER-EXPLANATION'`
- 隐藏成就(`name` / `description` 带标记)
- 他人的两条提交:一条 `shared = true`、一条 `shared = false``code` 带标记
- 题目 `template` 塞进 `//PREPEND BEGIN\nSECRET-PREPEND\n//PREPEND END` 等隐藏区
- 题目 `ast_rules` / `answers` 塞标记值
脚本对每个响应**递归收集全部字段路径**(含数组元素、最深 9 层),
再对字段名做敏感词匹配,同时对响应原文做标记值 grep(这一条能抓到"字段名没问题但值是别人的"的情况)。
**覆盖:111 次请求,65 条 oj 路由中 61 条拿到了 2xx 响应。**
未取得 2xx 的 4 条及原因:
| 路由 | 状态 | 原因 |
|---|---|---|
| `POST /api/judge-server/heartbeat` | 403 | 需要 judge server token,非 oj 学生面 |
| `POST /api/messages` | 403 | 仅 super admin 可调(与旧后端一致) |
| `POST /api/flowcharts` | 400 | 需要真实流程图 payload`GET` 系列已覆盖同一序列化路径 |
| `POST /api/code/format` | 500 | 本机没装 `ruff` / `clang-format`;响应体无数据字段 |
原始响应留档:
`/tmp/claude-1000/-home-xuyue-Projects-OJ/ab9e12e4-.../scratchpad/{probe2,probe3,probe4}.txt`
### 2.3 环境还原
评审过程中造的数据**已全部清理**,改过的既有行已还原(见第 6 节的一条例外,需要你确认)。
临时脚本已从仓库删除,`git status` 干净。**未修改 OJ2 任何业务代码,未动 OnlineJudge / ojnext。**
---
## 3. Findings
### Important
---
#### I-1. `GET /api/profiles/:username` 匿名可访问,且返回 `user.email`
**位置**`apps/api/src/routes/account.ts:101``apps/api/src/services/profile.ts:19`
```ts
// account.ts:101
accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
```
```ts
// services/profile.ts:19
email: row.user.email,
```
**旧后端**`account/views/oj.py:36``UserProfileAPI.get`):
```python
async def get(self, request, **kwargs):
user = request.user
if not user.is_authenticated:
return self.success() # ← 匿名直接返回空
```
**实际响应****完全不带 cookie**):
```
$ GET /api/profiles/student (no cookie)
[200]
{"data":{"id":1,"user":{"id":2,"username":"student",
"email":"student@example.test", ← 学生邮箱,匿名可见
"adminType":"Regular User","problemPermission":"None",
"createTime":"2026-08-07 04:27:24.688+00",
"lastLogin":"2026-08-07 07:29:46.337+00", ← 上次登录时间,匿名可见
"openApi":false,"isDisabled":false,"className":null},
"realName":null,"acmProblemsStatus":{...},"avatar":"/public/avatar/default.png",
"mood":null,...,"acceptedNumber":1,"submissionNumber":5}}
```
**影响**:用户名在本站是公开的(`GET /api/rankings/users``GET /api/classes/:className/usernames`
都匿名可拿全量用户名),所以这等于**任何人都能离线遍历出全校学生的邮箱 + 上次登录时间**。
中职学生的邮箱多为学号/姓名拼音派生,属实名可关联数据。
**注意区分**:登录用户看他人 email 是**旧后端也有的行为**(旧 `UserSerializer.Meta.fields`
里就有 `email``UserProfileSerializer` 用的就是它)。**回归点只在"匿名也能看"**。
修的时候只需把 `optionalAuth` 换成 `requireAuth` 即可对齐旧行为;
如果想顺手收紧"登录用户看他人 email",那是超出对齐范围的改进,需另行决定。
---
#### I-2. `realName`(学生真实姓名)在 8 处 `createdBy` / `user` 内嵌里未做门控
**位置**(每处都是把 `schema.userProfile.realName` 原样塞进响应):
| 文件:行 | 端点 | 谁的真名 |
|---|---|---|
| `routes/account.ts:168` | `GET /api/rankings/users` | **学生本人**(匿名可见) |
| `routes/content.ts:85` | `GET /api/messages` | 消息发送者 |
| `routes/problemset.ts:398` | `GET /api/problem-sets/:id/user-progress` | **学生本人**(教师端) |
| `routes/problem.ts:86` | `GET /api/problems``GET /api/problems/:displayId` | 出题人 |
| `routes/content.ts:43` | `GET /api/announcements` | 公告作者 |
| `routes/content.ts:64` | `GET /api/announcements/:id` | 公告作者 |
| `routes/content.ts:196` | `GET /api/tutorials/:id` | 教程作者 |
| `routes/problemset.ts:169` | `GET /api/problem-sets/:id/problems` | 出题人 |
| `routes/contest.ts:131``contest.ts:177` | `GET /api/contests/:id/problems(/:displayId)` | 出题人 |
**旧后端**`utils/api/_serializers.py:4`):
```python
class UsernameSerializer(serializers.Serializer):
id = serializers.IntegerField()
username = serializers.CharField()
real_name = serializers.SerializerMethodField()
def __init__(self, *args, **kwargs):
self.need_real_name = kwargs.pop("need_real_name", False) # ← 默认 False
super().__init__(*args, **kwargs)
def get_real_name(self, obj):
return obj.userprofile.real_name if self.need_real_name else None
```
旧后端**只有比赛管理员**这一个调用点传 `need_real_name=True`
`contest/serializers.py``ACMContestRankSerializer.get_user`)。
排行榜走的 `RankInfoSerializer` 用的是裸 `UsernameSerializer()``real_name` 恒为 `null`
**实际响应****完全不带 cookie**;测试前给 user 4 设了 `real_name = 'REALNAME-SECRET-E2E'`):
```
$ GET /api/rankings/users?limit=20 (no cookie)
[200]
{"data":{"results":[
{"id":2,"user":{"id":4,"username":"e2etest",
"realName":"REALNAME-SECRET-E2E"}, ← 真名,匿名可见
"acceptedNumber":1,"submissionNumber":2,"mood":"probe"},
{"id":1,"user":{"id":2,"username":"student",
"realName":"Phase 2 Student"}, ← 真名,匿名可见
"acceptedNumber":1,"submissionNumber":5,"mood":null},
...],"total":5}}
```
```
$ GET /api/messages (cookie: e2etest)
[200]
{"data":{"results":[{"id":1,
"sender":{"id":2,"username":"student","realName":"Phase 2 Student"}, ← 旧后端此处为 null
...}]}}
```
**影响**`GET /api/rankings/users` 这一条最严重 —— **匿名 + 一个 GET 就能拿到
"用户名 ↔ 真实姓名"的全量映射表**,把原本半匿名的排行榜变成了实名榜。
中职学生属未成年人,这是实打实的 PII 外泄面扩大。
**做对了的反例**(说明这不是"整体没设计",而是漏了):
`routes/contest.ts:209` 是**唯一**做了门控的地方,与旧后端语义一致:
```ts
user: { id: user.id, username: user.username, realName: admin ? realName : null },
```
`routes/problem.ts:333``routes/account.ts:94``routes/contest.ts:34`
`routes/problemset.ts:62` 硬编码 `realName: null`,也是对的。
所以修复方式很清楚:把上表 8 处对齐成 `contest.ts:209` 那种写法。
---
#### I-3. 本人提交详情返回 `info`(逐测试点判题明细)与 `ip`,旧后端对所有非管理员都隐藏
**位置**`apps/api/src/routes/submission.ts:187``:195``:199`
```ts
// submission.ts:187
const full = isAdminRole(user) || row.submission.userId === user.id
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 旧后端没有这个分支
return submissionDetailSchema.parse({
...
info: full ? row.submission.info : {}, // :195
ip: full ? row.submission.ip : null, // :199
```
**旧后端**`submission/views/oj.py:96`):
```python
if request.user.is_admin_role():
submission_data = await self.async_serialize_data(SubmissionModelSerializer, submission)
else:
submission_data = await self.async_serialize_data(SubmissionSafeModelSerializer, submission)
```
`SubmissionSafeModelSerializer``submission/serializers.py`):
```python
class Meta:
model = Submission
exclude = ("info", "contest", "ip")
```
判定条件是 **`is_admin_role()`**,不是"是不是自己的提交"。
所以旧后端下,学生看**自己的**提交也拿不到 `info``ip`
**实际响应**`student` 看自己的提交):
```
$ GET /api/submissions/LEAKPROBE-SUB-PRIVATE (cookie: student,本人)
[200]
{"data":{"id":"LEAKPROBE-SUB-PRIVATE","userId":2,"username":"student",
"code":"...","result":0,
"info":{"err":null,"data":[
{"error":0,"memory":7991296,"output":null,"result":0,"signal":0,
"cpu_time":3,"exit_code":0,"real_time":6,
"test_case":"1", ← 测试点编号
"output_md5":"caf1a3dfb505ffed0d024130f58c5cfa"}, ← 输出指纹
{..."test_case":"2","output_md5":"248e844336797ec98478f85e7626de4a"},
{..."test_case":"3","output_md5":"ab233b682ec355648e7891e66c54191b"},
{..."test_case":"4","output_md5":"cfee398643cbc3dc5eefc89334cacdc1"},
{..."test_case":"5","output_md5":"bc6dc48b743dc5d013b1abaebd2faed2"}]},
"ip":"10.9.8.7", ← 旧后端不返回
...}}
```
**影响**:学生能看到本题**测试点总数、每个测试点的编号、单点耗时/内存/退出码,
以及自己输出的 md5**。这不等于直接泄题(`output` 字段是空的,且 md5 是学生自己程序的输出,
不是标准答案),但它把判题内部结构暴露给了学生:
反复提交 + 比对 `output_md5` 可以**逐测试点二分定位哪一个点挂了**,
这正是旧后端把 `info` 关掉想避免的事。
**分级说明**:按你给的口径,"测试点/答案泄露给学生 → Critical"。
我定 Important 而非 Critical,理由是:泄露的是**测试点元信息 + 学生自己输出的指纹**,
不含标准答案、不含测试点输入输出内容,也不含 `test_case_id`(拿不到测试数据目录)。
但它确实越过了旧白名单,且方向是"学生本可以不知道的判题内部",
所以我建议按 Critical 的优先级排期修,只是不按 Critical 定级。
**已做对的部分**:看**他人**提交时 `full` 为 false`info` 返回 `{}``ip` 返回 `null` ——
实测确认(见下),这部分与旧后端一致。
```
$ GET /api/submissions/LEAKPROBE-SUB-SHARED (cookie: leakprobe2,他人)
[200]
{"data":{...,"userId":2,"username":"student",
"code":"SECRET-CODE-OF-OTHER-USER-SHARED", ← shared=true,旧后端同样返回,属预期
"info":{},"ip":null, ← 正确置空
"shared":true,...}}
```
---
### Minor
---
#### M-1. `GET /api/messages` 的 `submission` 内嵌多出 `info` / `ip` 两个字段名(值恒为空)
**位置**`apps/api/src/routes/content.ts:96``:100`
```ts
info: {}, // content.ts:96
ip: null, // content.ts:100
```
`MessageSerializer.submission` 用的是 `SubmissionSafeModelSerializer`
`info` / `contest` / `ip` 三个键**根本不出现**。新后端硬编码成空值后仍把键留在响应里。
**实际响应**
```
$ GET /api/messages (cookie: e2etest)
{"data":{"results":[{"id":1,"sender":{...},"message":"LEAKPROBE msg",
"submission":{"id":"2e16...","userId":2,"username":"student",
"code":"n = int(input())\nprint(...)",
"info":{}, ← 键存在,值为空
"ip":null, ← 键存在,值为空
...}}],"total":1}}
```
**无数据泄露**(值恒为空,写死在代码里,不读数据库)。仅为白名单形状不一致。
之所以还是列出来,是因为它和 I-3 共用 `submissionDetailSchema` ——
如果将来有人"顺手"把这里改成传真实值,就会变成真泄露。
`submission.code` 出现在这里是**旧后端也有的**:旧 `SubmissionSafeModelSerializer`
`exclude` 不含 `code`。消息由 super admin 发出,指向收件人自己的提交,属预期。)
---
#### M-2. `GET /api/problem-sets/:id/user-progress` 返回学生 `realName`
**位置**`apps/api/src/routes/problemset.ts:398`
`ProblemSetProgressSerializer.user = UsernameSerializer()``problemset/serializers.py:248`
`real_name` 恒为 `null`。新后端返回真实值。
单列为 Minor 而不并入 I-2,是因为**该端点已由 `isTeacherOrAbove(user)` 门控**
`problemset.ts``if (!isTeacherOrAbove(user)) return failure(c, 403, ...)`
实测普通学生调用返回 403),受众只有教师。给教师看学生真名大概率是**产品上想要的**。
所以:**先确认这是不是有意为之**,如果是,就别跟着 I-2 一起改,
否则会把教师端的功能删掉。
---
#### M-3. `GET /api/tutorials/:id/exercises` 返回练习答案 —— 与旧后端一致,**不是回归**
**位置**`apps/api/src/routes/content.ts:207`
```ts
const rows = await db.select().from(schema.exercise).where(...)
return success(c, rows.map((row) => exerciseSchema.parse({
id: row.id, type: row.type, data: objectValue(row.data), order: row.order,
})))
```
`data` 是 jsonb 原样透传(`contract/src/content.ts:72`
`z.record(z.string(), z.unknown())`,不裁剪内容)。
**实际响应****匿名也能拿**):
```
$ GET /api/tutorials/1/exercises (no cookie)
[200]
{"data":[{"id":1,"type":"mcq","data":{
"answer":1, ← 正确答案
"options":["1","2"],"question":"1+1?",
"explanation":"SECRET-ANSWER-EXPLANATION"}, ← 答案解析
"order":1}]}
```
**旧后端完全一样**`tutorial/serializers.py`):
```python
class ExerciseSerializer(serializers.ModelSerializer):
class Meta:
model = Exercise
fields = ["id", "type", "data", "order"] # data 整个 jsonb 出去
```
且旧 `ExerciseAPI``tutorial/views/oj.py`)也没有 `@login_required`
**结论:行为一致,不算本次重写引入的问题。** 之所以写进报告,是因为这是教程练习的
**前端判题设计**(答案下发到浏览器、客户端比对),学生 F12 就能看到答案。
迁移不需要动它,但如果哪天想改判题方式,这是已知的口子 —— 属于遗留设计债,不是回归。
---
## 4. 静态检查结果:11 处"选全部列"逐条核实
`db.select()`(不跟对象字面量,等价 `SELECT *`)全部命中如下。
**结论:11 处全部无泄露** —— 每一处的查询结果都只是中间变量,
真正进响应的是后面手写的显式字段映射 + Zod `.parse()`
| # | 文件:行 | 选了哪张表 | 表里有敏感列吗 | 是否泄露 | 依据 |
|---|---|---|---|---|---|
| 1 | `routes/contest.ts:70` | `contest` | **有 `password`** | **否** | 结果传给 `serializeContest()``contest.ts:37`),只挑 11 个字段;`password` 仅用于 `contestType: contest.password ? "Password Protected" : "Public"``:49`)。实测 `GET /api/contests` 响应无 `password` 键 |
| 2 | `routes/content.ts:207` | `exercise` | `data` 含答案 | 否(但见 M-3) | 只取 `id/type/data/order`,与旧 `ExerciseSerializer` 字段完全一致 |
| 3 | `routes/problemset.ts:85` | `problemset_progress` | 无 | 否 | 结果只用于取 `completedProblemsCount` / 算 `progressSummary()` |
| 4 | `routes/problemset.ts:86` | `problemset_badge` | 无 | 否 | 结果传给 `badgeData()``problemset.ts:66`),显式挑 8 个字段 |
| 5 | `routes/problemset.ts:123` | `problemset` | 无 | 否 | 传给 `serializeProblemSet()`;旧 `ProblemSetListSerializer` 也含 `visible`,属对齐 |
| 6 | `routes/problemset.ts:133` | `problemset` | 无 | 否 | 同上 |
| 7 | `routes/problemset.ts:361` | `problemset_badge` | 无 | 否 | 传给 `badgeData()` |
| 8 | `routes/achievement.ts:43` | `achievement` | 隐藏成就的 `name`/`description`/`threshold` | **否** | `achievement.ts` 里按 `masked = achievement.hidden && !record``name` 换成 `"???"``description` 换成 `"达成条件保密"``metric`/`operator`/`threshold`/`progress` 全置 `null` |
| 9 | `routes/achievement.ts:44` | `user_achievement` | 无 | 否 | 只用来建 `Map` 判断是否已解锁 |
| 10 | `services/contest.ts:40` | `contest` | **有 `password`** | **否** | `findVisibleContest()` 是内部 helper,返回值不直接进响应;调用方(`contest.ts:79/85/97/114`)都再走 `serializeContest()` |
| 11 | `services/achievements.ts:27` | `achievement` | 同 #8 | 否 | 后台解锁判定用,不进任何 HTTP 响应 |
**#8 的实证**(隐藏成就掩码生效):
```
$ GET /api/achievements (cookie: e2etest)
{"data":{"username":"e2etest","achievements":[
{"id":1,"name":"LEAKPROBE ach","description":"d",...,"unlocked":true,...},
{"id":2,"name":"???","description":"达成条件保密", ← 掩码生效
"icon":"noto:red-question-mark","rarity":"gold","hidden":true,
"metric":null,"operator":null,"threshold":null, ← 达成条件全部置空
"unlocked":false,"progress":null,"unlockRate":0}]}}
```
(造数据时该成就的真实 `name``LEAKPROBE hidden SECRETNAME`
`description``SECRET-CONDITION`,两个标记值在响应里 grep 均为 0 命中。)
**#1 / #10 的实证**(比赛密码不泄露):
```
$ GET /api/contests?limit=50 (no cookie)
{"data":{"results":[
{"id":3,"title":"LEAKPROBE contest","description":"desc","tag":"probe",
"startTime":"...","endTime":"...","createTime":"...","lastUpdateTime":"...",
"createdBy":{"id":1,"username":"devadmin","realName":null},
"status":"0","contestType":"Password Protected"}, ← 只暴露"有没有密码"
{"id":4,...,"contestType":"Public"}],"total":2}}
```
真实密码 `SUPERSECRET-CONTEST-PW``GET /api/contests``GET /api/contests/:id`
`GET /api/contests/:id/access``POST /api/contests/:id/access`(成功与失败两种)
五个响应里 grep 均为 **0 命中**。未通过密码校验时 `/problems``/rank``/submissions`
全部 403,通过后才放行 —— 与旧后端 `@check_contest_permission` 行为一致。
**唯一的整行展开**`routes/problem.ts:228`):
```ts
return success(c, rows.map((row) => yearlyAcSchema.parse({ ...row, acRate: ... })))
```
`rows` 来自 `db.select({ year: ..., total: count(), accepted: ... })` 的显式聚合投影,
不是表行,`...row` 只有 3 个数值键。`yearlyAcSchema` 又会 strip 掉多余键。**无泄露。**
---
## 5. 核实过、确认没问题的端点
以下端点的**每一个字段**都比对过旧 serializer 白名单,无越界、无敏感值。
**题目类**(旧基准:`ProblemListSerializer` / `ProblemSerializer` / `ProblemSafeSerializer`
- `GET /api/problems``GET /api/problems/:displayId`
—— 无 `answers` / `astRules` / `testCaseId` / `testCaseScore``hasAstRules` 只给布尔值。
实测拿真实题目 `1002`(其 `answers` 列存着完整 C + Python 参考解)验证:
响应里 grep `#include<stdio.h>` / `scanf(` / `printf(` / `//PREPEND` / `//APPEND`
**全部 0 命中**`template` 只有 `//TEMPLATE` 区间的内容。
顺带一提:旧 `ProblemSerializer.Meta.exclude` **没有**排掉 `ast_rules`
也就是旧后端其实会把 AST 规则发给学生 —— **新后端这里比旧的更严,是改进,不是问题**
- `GET /api/problems/random``/similar``/yearly-ac``/rank``/:id/beat-count``/:id/reaction`
- `GET /api/problem-tags``GET /api/problem-authors`
- `GET /api/contests/:id/problems``GET /api/contests/:id/problems/:displayId`
—— 比赛进行中正确按 `ProblemSafeSerializer` 语义把 `difficulty``""`
`submissionNumber` / `acceptedNumber``0``statisticInfo``{}`
**提交类**(旧基准:`SubmissionListSerializer`
- `GET /api/submissions``GET /api/submissions/today-count`
—— 列表无 `code` / `info` / `ip`,与旧 `exclude = ("info", "contest", "code", "ip")` 一致。
- `GET /api/submissions/:id` 的**权限判定**:未 share 且题目未开 `share_submission` → 404
匿名 → 401;开了 `share_submission``shared=true` → 放行。与旧 `check_user_permission` 等价。
- `GET /api/contests/:contestId/submissions`
**比赛类**`GET /api/contests``/:id``/:id/access``/:id/rank``POST /:id/access`
`/:id/rank``realName` 已正确门控,见 I-2 反例)
**题单类**`GET /api/problem-sets``/:id``/:id/problems``/:id/badges`
`/:id/user-progress`(见 M-2)、`GET /api/users/:username/badges`
`POST|PUT /api/problem-set-progress`
**成就类**`GET /api/achievements``/summary``/pending``POST /achievements/pending/read`
**班级/排行类**`GET /api/rankings/activity``/rankings/classes``GET /api/me/class-rank`
`GET /api/classes/:className/usernames``POST /api/classes/comparison`
—— 只返回用户名和聚合统计,无 `realName` / `email`。匿名可访问,
但旧 `ClassRankAPI` / `ClassPKAPI` 同样没有 `@login_required`**属对齐**。
**AI 类**`GET /api/ai/detail``/duration``/heatmap``/login-summary``/pinned`
—— 另外确认:带 `?username=student` 请求 `/ai/detail`,响应里的 `"user"` 仍是 `"e2etest"`
说明 `targetUser()` 对普通学生忽略了该参数,**没有越权看他人 AI 报告**。
**内容类**`GET /api/announcements``/:id``GET /api/tutorials``/:id`
`GET /api/messages`(见 I-2 / M-1
**流程图类**`GET /api/flowcharts``/:id``GET /api/problems/:id/flowchart/current``/history`
—— 他人流程图返回 404(用 `leakprobe2` 实测)。
**账号/站点类**
- `POST /api/auth/login``{"data":{"ok":true}}`(不回显用户信息)
- `POST /api/users`(注册)→ `{"data":{"ok":true}}`
- `DELETE /api/auth/session``POST /api/me/avatar``POST /api/me/problem-display-ids/refresh`
- `GET /api/me` → 返回自己的 `email` / `realName`**正常**(旧后端 `show_real_name=True`
- `PUT /api/me/profile` → 同 `GET /api/me`**正常**(旧 `ProfileAPI.put` 也是 `show_real_name=True`
- `GET /api/site` → 只有 `websiteBaseUrl` / `websiteName` / `websiteNameShortcut` /
`websiteFooter` / `allowRegister` / `submissionListShowAll` / `classList` / `enableMaxkb`
无 SMTP、无 judge token
- `GET /api/quotes/random``GET /api/users/:id/metrics`
### 顺带提一句:新后端比旧后端裁剪更严的地方
按你的要求,这些不算 finding,但列出来供你确认前端会不会缺字段:
1. **`problem.ast_rules`**:旧 `ProblemSerializer` 没排掉它,学生能拿到 AST 规则原文;
新后端只给 `hasAstRules: boolean`。**新的更安全**,但如果 ojnext 有地方读
`ast_rules` 的具体内容(比如提示"必须用 for 循环"),需要补个专门的字段。
2. **`GET /api/tutorials/:id``createdBy`**:旧 `TutorialSerializer` 用的是
`UserSerializer`**会带 `email`**;新后端只给 `{id, username, realName}`
**新的更安全**,前端应该用不到教程作者的邮箱。
---
## 6. 需要你确认的一件事(环境影响)
评审造数据时,脚本的 `select * from problem order by id limit 1` 选中了
**题目 id=2`_id` 1001"🐟三天打鱼两天晒网")**,并覆写了它的
`ast_rules``answers``test_case_score``template` 四列,用来验证这些字段会不会外泄。
**原值没有备份,已丢失。**
清理时按同类题目的形状还原成了:
```
template = {} (与覆写前实测值一致,这个是准的)
ast_rules = null (id 3/4/5 等题目均为 null)
answers = []
test_case_score = [{"score":20,"input_name":"1.in","output_name":"1.out"}, ... 共 5 项]
(照抄 id=4/5 的形状;该题 test_case_id 目录下无 info 文件可查真实点数)
```
其余改动**已完整还原**`problem 5.share_submission``false`
user 2/4 的 `class_name``null`、user 4 的 `real_name``null`
所有 `submission.ip``null`。造的行(比赛/题单/教程/练习/公告/成就/消息/
流程图/比赛排名/两条 LEAKPROBE 提交/三个 leakprobe 账号)**已全部删除**
最终库状态:20 道题、8 条提交、3 个用户(devadmin / student / e2etest)、其余业务表为空。
`answers``test_case_score` 影响的是**评测与题解**,不影响本次结论。
但**题目 1001 是学生做的第一题**,如果这台机器上的数据之后还要用,
建议从生产重新导一次该题,或至少确认 1001 的测试点数确实是 5。
---
## 7. 修复优先级建议
| 优先级 | Finding | 一句话改法 |
|---|---|---|
| 1 | I-1 | `routes/account.ts:101``optionalAuth``requireAuth` |
| 2 | I-2 | 上表 8 处按 `contest.ts:209` 的写法补门控;`rankings/users` 最急 |
| 3 | I-3 | `submission.ts:187``full` 去掉 `\|\| row.submission.userId === user.id`,只留 `isAdminRole(user)` |
| 4 | M-1 | 给 `submissionDetailSchema` 拆一个不含 `info` / `ip` 的 safe 变体,`content.ts` 的 messages 用它 |
| 5 | M-2 | 先确认是不是产品有意为之,是就不动 |
| — | M-3 | 与旧后端一致,本次迁移不动 |
-153
View File
@@ -1,153 +0,0 @@
# 阶段 3 修复复评
日期:2026-08-07
复评范围:commit `9c04b00..f548aef`4 个提交),对照 `docs/specs/phase3-fix-list.md` 的 6 条 findings + 2 条收尾修复(F4b、F5b)。
方法:读 diff + 读源码 + 独立实跑(不复用实施者的证据,除非明确说明)。所有测试数据均已清理并核对还原。
---
## 逐条核验
### F1 —— 匿名可读任意用户完整档案【Critical】→ **ADDRESSED**
`apps/api/src/routes/account.ts:101``optionalAuth` 之后立即 `if (!c.get("user")) return success(c, null)`,早于任何数据库查询。
独立实跑:
```
匿名 GET /api/profiles/e2etest -> 200 {"data":null}
```
`services/profile.ts``showRealName` 参数链路未动,登录看自己档案不受影响(未重复验证,逻辑未改,风险低)。
前端 `apps/web/src/shared/api.ts:28` 已有 `data === null` 分支,兼容确认(读码确认,未跑前端)。
### F2 —— 学生真名无条件下发【Critical】→ **ADDRESSED**(默认关闭开关,非逐处删字段)
见下方「重点判断 1」,结论:14 个下发点全部经过统一的 `sampleUser()`,默认 `realName: null`,唯一显式打开的是 `contest.ts:209`(比赛榜单,且区分 admin/非 admin)。
独立实跑(与 diff 里的证据不同的端点,避免只是复读实施者的话):
```
匿名 GET /api/rankings/users?limit=2 -> [{"username":"student","realName":null},{"username":"e2etest","realName":null}]
匿名 GET /api/problems?limit=2 createdBy -> {"id":1,"username":"devadmin","realName":null} (x2)
匿名 GET /api/problems/1002 createdBy -> {"id":1,"username":"devadmin","realName":null}
```
`grep -rn "realName" apps/api/src --include=*.ts` 复查:除 `sampleUser` 内部实现、`db.select()` 取列、`account.ts:94`(注册写库常量 null)、`services/profile.ts:29`(F1 已核实的独立开关)外,没有任何路由手写 `{ ..., realName }` 对象绕过 `sampleUser()`
### F3 —— 匿名绕过提交可见性守卫【Critical】→ **ADDRESSED**
`submission.ts:227``!(await getBooleanOption(...)) && !isAdminRole(user)``isAdminRole(null)` 返回 `false``!false = true`,匿名正确落入受限分支。
独立实跑(临时把 `submission_list_show_all` 写为 `false`,测后 DELETE 该行,确认恢复为 `[]`):
```
匿名 GET /api/submissions -> total = 0
登录学生 GET /api/submissions -> total = 0
```
两者一致地受限,F3 描述的「匿名权限大于登录用户」的错位已消除。
### F4 —— 提交详情返回 info/ip【Important】→ **ADDRESSED**
`submission.ts:199``const full = isAdminRole(user)`,不再以 `row.submission.userId === user.id` 放行。`/submissions/:id``requireAuth`,匿名根本到不了这里,不存在新的匿名向量。
### F4b —— contestId 未脱敏 → **ADDRESSED,已用真实比赛提交独立验证**(原报告只有代码审查,本次补上实跑)
`submission.ts:214``contestId: full ? row.submission.contestId : null`
独立实跑(造了一场临时比赛 + 一条挂在该比赛下的真实提交,验证后已删除,见下方清理记录):
```
DB 实际存储:{"contestId":7,"ip":"9.9.9.9"}
提交所有者 (Regular User) GET /api/submissions/<id> ->
{"info":{},"ip":null,"contestId":null, ...} ← 三个字段全部脱敏
```
`contestId` 确实是被脱敏成 `null`,而不是碰巧本来就是 `null`(数据库里明确存的是 `7`)。控制方文档里点名"没验充分"的这一条已经补齐。
### F5 —— 判题机 token 弱默认值【Important】→ **ADDRESSED**
`config.ts``judgeServerToken()`env 存在则用 env;不存在则 `randomBytes(32)` + `console.warn``docker/compose.dev.yml``${OJ2_JUDGE_TOKEN:?...}` 缺失时 compose 直接报错退出(未重新实跑 compose,读码 + 报告证据一致,逻辑简单,风险低)。
### F5b —— 仓库根 .env 读不到 → **ADDRESSED,加载器安全**
见下方「重点判断 3」,独立做了三组隔离测试(shell env 优先级、cwd .env 优先级、缺文件不崩、畸形行不崩),并额外做了一次端到端真实提交(问题 1002,Python3),确认判题机用当前 `.env` 里的 token 正常认证、提交离开 PENDING/JUDGING(拿到 result=-2,非 PENDING/JUDGING,证明判题机-后端握手成功)。测试提交与计数器已回滚,见清理记录。
### F6 —— 提交接口缺限流【Important】→ **ADDRESSED,参数与旧后端逐字对齐(独立验证)**
见下方「重点判断 4」。独立绕过 HTTP 层直接调用 `consumeToken()` 12 次(避免污染 submission 计数):
```
#1~#10 allowed:true
#11 allowed:false, wait≈33.32s
#12 allowed:false, wait≈33.32s
```
`wait = (1 - 0) / 0.03 ≈ 33.33`,与旧后端 `fill_rate=0.03` 精确吻合,`default_capacity=10` 也吻合(第 11 次才被挡)。
---
## 四个重点判断
### 1. F2 是否真的做成了「默认关闭的开关」?
**是。** `apps/api/src/routes/helpers.ts``sampleUser()` 是唯一入口,`options.includeRealName === true` 才下发真名,默认 `false`。核对了全仓 14 个下发点(比 findings 文档的 13 处多一个,多出的是 `problem.ts:333`,原先就写死 `null`,现在统一走 `sampleUser` 入口,不影响结论):
| 下发点 | 状态 |
|---|---|
| rankings/users、announcements(x2)、messages、tutorials/:id、problems 列表(x2)、problem-sets(x2)、problemset progress、contest creator(x2)、contest problems(x2) | 全部 `sampleUser(user, realName)`,默认关 |
| `contest.ts:209`(比赛榜单) | 唯一 `{ includeRealName: admin }`,对齐旧后端 `contest/serializers.py:84` |
`grep -rn "realName:" apps/api/src/routes/*.ts` 复查无遗漏(本次独立复跑,非照抄报告)。**不是逐处删字段**——`sampleUser()` 是统一的序列化函数,下次新增端点如果照抄现有写法(调用 `sampleUser`)默认就是关的,不会重犯。唯一的隐患是「有人手写字面量绕过 `sampleUser`」,diff 里已经全部清干净,长期靠代码评审维持(无法用类型系统强制,值得记一条范围外观察)。
### 2. `isRegularUser` 是否真的只有一处调用?删除后是否有其它同类空值陷阱?
**只有一处,属实。** `grep -rn "isRegularUser" --include=*.ts .`(排除 node_modules,覆盖整个仓库而非只有 apps/、packages/)只命中 `helpers.ts` 里的警示注释,无任何遗留调用点。
**搜了其它同类模式**(对 `null` 用户取 `adminType` 做权限判断),命中 `apps/api/src/routes/flowchart.ts:98`
```ts
if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) ...
```
这处**不是**同类陷阱:该路由挂在 `flowchartRoutes.get("/flowcharts", requireAuth, ...)``user``c.get("user")!` 取得,`requireAuth` 保证非空,匿名到不了这行。这是「Regular User 专属限制」的合法写法(限制普通用户只看自己的,不限制教师/管理员),语义与 F3 的场景不同——F3 的路由是 `optionalAuth`,匿名 `user` 可能为 `null`。**未在 diff 内,非本次改动引入,仅作范围外观察记录**,不计入 findings。
其余 `adminType` 使用点(`classroom.ts``account.ts``contest.ts``inArray`/`sql` 过滤,`services/contest.ts``isContestAdmin`)均为「构造 SQL 过滤条件」或「非 null 保证下的角色判断」,没有第二个「匿名反而权限更大」的实例。
### 3. F5b 的 `.env` 加载器是否安全?
**安全,三点分别独立验证:**
- **真实 env 优先于仓库根 `.env`**`JUDGE_SERVER_TOKEN=from-shell-env bun -e '...'` 得到 `from-shell-env`,不是 `.env` 里的值。
- **cwd 下的 `.env`Bun 自动加载)优先于仓库根 `.env`**`bun --env-file=.env.test-cwd` 模拟 cwd 优先加载后,`loadRepoRootEnv()` 因为 `process.env[key] !== undefined` 而跳过,结果仍是 cwd 的值。
- **解析不会被畸形行搞崩**:构造了空值(`=novalue`)、无等号行、前导空格键、单/双引号、值里带等号、`export FOO=bar` 语法、行内 `#` 注释、CRLF 结尾等混合样本喂给等价解析逻辑,全部正常跳过或按字面处理,无异常抛出。(`export FOO=bar` 会被解析成键名 `"export FOO"`,即该变量实际上不会被正确加载——是一个小的解析局限,不是崩溃,本仓库的 `.env`/`.env.example` 都不用这种写法,记为范围外观察。)
- **根目录无 `.env` 时静默跳过**`readFileSync``ENOENT``catch {}` 吞掉,不影响启动——这是生产场景(真实环境变量注入)的必经路径,逻辑上有覆盖(未在容器里额外验证,风险低,纯 try/catch 结构)。
- **端到端**:仓库根 `.env` 当前有真实 `JUDGE_SERVER_TOKEN`,直接提交一条真实代码(问题 1002/Python3),判题机在数秒内返回非 PENDING/JUDGING 结果,证明 token 握手成功、判题闭环工作,不是只停留在配置读取层面。
### 4. 限流参数是否真的对齐旧后端?
**是,非拍脑袋。** 对照 `OnlineJudge/options/options.py:120`
```python
throttling = {"ip": {"capacity": 100, "fill_rate": 0.1, "default_capacity": 50},
"user": {"capacity": 20, "fill_rate": 0.03, "default_capacity": 10}}
```
`apps/api/src/services/throttling.ts``throttlingDefaults` 逐字段相同。
**挂点位置**对照 `OnlineJudge/submission/views/oj.py:68``SubmissionAPI.post`):`throttling()``check_contest_permission`(比赛权限校验)之后、取 `Problem` 之前调用——`apps/api/src/routes/submission.ts` 的挂点(比赛权限校验后、`db.select(schema.problem)` 前)位置一致。
**独立数值验证**:绕开 HTTP 直接调用 `consumeToken()` 12 次,第 11 次起被拒,`wait≈33.32s`,与 `(1 token 缺口) / (fill_rate=0.03) ≈ 33.33s` 吻合,`default_capacity=10` 与观察到的"第 11 次才拒绝"一致。参数和算法都对得上,不是抄了个数字但算法跑偏。
(Lua 脚本把旧实现「非线程安全」的读改写做成了原子操作,这是双方都认可的合理增强,不是风险点。)
---
## 修复 diff 内新引入的破坏
**无。**
- `bunx tsc --noEmit` 独立重跑,0 错误。
- 独立冒烟测试 `/api/contests``/api/problems``/api/problem-sets``/api/rankings/users``/api/announcements``/api/submissions``/api/problems/1002` 等端点,均 200`sampleUser()` 各调用点(含 `row ?? { id, username: "" }` 的 null 分支、`{ id: row.creatorId, username: row.creatorUsername }` 的裸对象分支)未见运行时异常。
- `apps/web` 零改动(diff stat 确认),F1/F4 对前端的潜在影响(`data:null` 分支、`info.data` 表格不渲染)均有既存代码兜底或本就是回归到旧后端行为,读码确认不炸。
- F4b 的 `contestId` 收口用真实比赛提交实测通过,未见回归。
## 范围外观察(仅记录,不阻塞)
1. `apps/api/src/routes/flowchart.ts:98` 有一处外观相似的 `user.adminType === "Regular User"` 判断,但路由挂 `requireAuth`,不构成 F3 类陷阱。建议后续如果这条路由改成 `optionalAuth`,需要一并检查。
2. `.env` 解析器不支持 `export KEY=value` 语法(会把 `export KEY` 当整个键名),本仓库当前 `.env`/`.env.example` 不用这种写法,暂无影响。
3. F2 的「默认关闭」防线目前只靠约定(大家都调用 `sampleUser()`),没有类型系统强制。长期看这是 admin 侧 45 个端点铺开前值得补一道 lint/测试的地方,但不属于本次 findings。
4. 限流的 429 是新引入的 HTTP 语义(旧后端全走 200+error 信封),前端目前没有针对 429 的专门处理(报告里已自述,非新发现)。
## 结论
**All findings addressed: Yes**
F1 / F2 / F3 / F4 / F4b / F5 / F5b / F6 —— 8 项全部 ADDRESSED,均有独立实跑或读码验证支持,diff 范围内未发现新引入的破坏。
-496
View File
@@ -1,496 +0,0 @@
# 阶段 4 后台接口权限边界评审
评审对象:`apps/api/src/routes/admin/*.ts`10 个文件、86 个 handler+ 3 个挂在 `/admin` 之外但对应旧后端 admin 视图的端点。
参照物:`OnlineJudge/` 各 app 的 `views/admin.py`,装饰器定义在 `account/decorators.py`
评审范围:**只看角色守卫与归属校验**,不评审风格 / 性能 / 可维护性。
- 评审人:独立安全评审
- 日期:2026-08-07
- 实跑环境:`http://localhost:3000`postgres 5433 / redis 6380
- 实跑账号:`student`(id=2)、`e2etest`(id=4),测试期间临时提权,**结束后已全部还原为 Regular User / None**;造的 6 道题、3 场比赛、2 个题单、1 个奖章、1 个标签已全部删除,`problem` 表回到 20 行
---
> **修复记录(2026-08-07,本文档之后)**
>
> C1、C2、I1、I2、I3、I4 **六条全部已修**,并用两个教师账号(互相不可见的题单/比赛)
> 实跑复验通过:跨题单删奖章 → 404 且对方奖章还在;跨租户 make-public → 404
> from-public 拖别人的赛题 → 400 `not-a-public-problem`;克隆比赛回传 `password: null`
> 分析接口移到 `/admin/problem-analytics/*` 后不再被 `/problems/:id` 遮蔽;
> upload-image 守卫收回 `requireAdmin`。
>
> Minor 四条未修:M1 已按「有意偏离」保留,M2/M3/M4 留待阶段 5。
> **更正(2026-08-08):本报告有一处对旧后端的判断是错的。**
>
> 文末对照表原写「`DashboardInfoAPI` / `RandomUsernameAPI` 旧后端任何人可读,
> 含班级用户名枚举」。**不成立** —— 这两条都挂在 `/api/admin/` 下,而
> `account/middleware.py:36` 的 `AdminRoleRequiredMiddleware` 在中间件层就要求
> 登录且 `is_admin_role()`。「无装饰器」是真的,「任何人可读」不是。
>
> 差别很大:真实缺口只是「任何管理员可读,而非仅超管」。查旧后端的权限时,
> 别只看装饰器,那个中间件是所有 `/api/admin/` 的地板。
## 结论速览
| 级别 | 数量 | 条目 |
|---|---|---|
| Critical | 2 | C1 跨题单删奖章会连带删掉别人的 user_badgeC2 make-public 无归属校验且回传完整题面 |
| Important | 4 | I1 两个 requireTeacher 端点被 `/problems/:id` 路由遮蔽;I2 from-public 不校验源题归属;I3 克隆比赛不校验归属且回传明文密码;I4 upload-image 守卫比旧后端严过头,教师/学生管理员写题面时会 403 |
| Minor | 4 | M1 visibility 的归属规则与 canEdit 不一致;M2 from-public 的错误码构成比赛存在性预言机;M3 三个 admin 端点的守卫写在 handler 里而非注册行;M4 禁用账号落到 401 而不是「账号已禁用」 |
守卫覆盖:**86 个后台 handler 全部挂了守卫,没有一个漏挂**。角色档位与旧后端装饰器逐条比对后全部一致(见文末对照表)。问题全部集中在**对象级归属校验**这一层。
---
## Critical
### C1 — 跨题单删奖章:奖章保住了,但别人的 user_badge 被真删了
**文件**`apps/api/src/routes/admin/problemset.ts:365-378`
```ts
const deleted = await db.transaction(async (tx) => {
await tx.delete(schema.userBadge).where(eq(schema.userBadge.badgeId, badgeId)) // ← 先删,没带题单条件
return tx.delete(schema.problemsetBadge).where(and(
eq(schema.problemsetBadge.id, badgeId),
eq(schema.problemsetBadge.problemsetId, row.id), // ← 后校验
)).returning({ id: schema.problemsetBadge.id })
})
if (deleted.length === 0) return failure(c, 404, "badge-not-found", "奖章不存在")
```
父子归属校验放在了子表清理**之后**,而且 `if (deleted.length === 0)` 的 404 是在事务**提交之后**才返回的 —— 回调正常结束,事务照常 COMMIT。于是「奖章没删成」和「奖章的获得记录已经删光了」同时成立。
这正是任务里点名要查的场景:**A 题单的 id + B 题单的奖章 id**。
**旧后端对应行为**`problemset/views/admin.py:287-301`,先 `ProblemSetBadge.objects.get(id=badge_id, problemset=problem_set)`,取不到直接 `return self.error("奖章不存在")``badge.delete()` 根本不会执行,user_badge 一行不动。
**实跑验证**
前置:`student`(A) 与 `e2etest`(B) 均为 Teacher Admin/Own。A 建题单 2,B 建题单 3 并在其下建奖章 2。手工插入两行 user_badge
```
docker exec oj2-postgres psql ... -tAc "insert into user_badge (user_id,badge_id,earned_time) values (4,2,now()),(2,2,now()); select id,user_id,badge_id from user_badge"
INSERT 0 2
2|4|2
3|2|2
```
A(只拥有题单 2)打 B 的奖章 2:
```
DELETE /api/admin/problem-sets/2/badges/2 (cookie = A)
→ 404 {"error":{"code":"badge-not-found","message":"奖章不存在"}}
```
看起来被挡住了。查库:
```
select count(*) from user_badge; → 0
select id,problemset_id,name from problemset_badge; → 2|3|REVIEW-B-BADGE
```
奖章还在,**两行 user_badge 全没了**。
对照:同一路由的 PUT 是安全的(校验和写在同一条 `and()` 里):
```
PUT /api/admin/problem-sets/2/badges/2 body={name:"HACKED",...} (cookie = A)
→ 404 badge-not-found
GET /api/admin/problem-sets/3/badges (cookie = B)
→ 200 [{"id":2,"name":"REVIEW-B-BADGE","conditionValue":1,...}] # 未被篡改
```
**风险场景**:教师 A 在自己题单页面点删除奖章时,前端只要拼错 badgeId(或 A 手工构造请求),就能把教师 B 班上全体学生已获得的奖章记录一次性抹掉,而 A 自己看到的是「奖章不存在」——**破坏是静默的,没有任何一方会收到提示**。奖章记录没有别处备份,`recalculateBadge` 只在改奖章时触发,B 不改奖章就永远恢复不了 earnedTime。
**修法**:把 `userBadge` 的清理条件收进子查询,或把归属校验提到事务最前面并在不匹配时 `throw` 触发回滚。
---
### C2 — `make-public` 完全不校验归属,且把整份题面回传给越权者
**文件**`apps/api/src/routes/admin/problem.ts:491-533`
```ts
adminProblemRoutes.post("/problems/:id/make-public", requireProblemPermission, async (c) => {
...
const [problem] = await db.select().from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!problem.contestId || problem.isPublic) { ... }
// ↑ 这里之后直接开事务,没有 canEdit / ownedBy
...
return success(c, await serialize(created), 201) // ← 回传完整题面
})
```
同一文件里 `GET /problems/:id`(275)、`PUT`(323)、`DELETE`(371)、`/test-cases`(602)、`/sql-scripts`(628) 全都过了 `canEdit`,唯独这一条没有。
**旧后端对应行为**`problem/views/admin.py:456-483``MakeContestProblemPublicAPIView` 同样没有 `ensure_created_by` —— **这个洞是从旧后端继承来的**。但旧后端第 483 行是 `return self.success()`,**不回传任何数据**;转出来的公开题 `created_by` 仍是原作者且 `visible=False`,越权者拿不到内容。新后端第 532 行 `return success(c, await serialize(created), 201)` 把 description、samples、testCaseId、**answers(标准答案)** 一起吐回去,**泄露面严格大于旧后端**。
**实跑验证**
A 建比赛 3(A 所有)并在其中建比赛题 38。B 直接读该题:
```
GET /api/admin/problems/38 (cookie = B)
→ 404 {"error":{"code":"problem-not-found","message":"Problem does not exist"}} # 归属校验生效
```
B 换成 make-public
```
POST /api/admin/problems/38/make-public body={"displayId":"STOLEN1"} (cookie = B)
→ 201 {"data":{"id":39,"_id":"STOLEN1","title":"REVIEW A","description":"<p>d</p>",
"inputDescription":"i","outputDescription":"o","samples":[{"input":"1","output":"1"}],
"testCaseId":"reviewdummy00000000000000000000",
"testCaseScore":[{"score":100,"input_name":"1.in","output_name":"1.out"}],
"hint":"","languages":["Python3"],"template":...
```
同一个人、同一道题,读接口 404、写接口 201 并把题面全给了。副作用还有两条:A 的比赛题被改成 `isPublic=true`(A 之后再想正常转公开会被 409 "Already be a public problem" 挡住),以及库里多出一道 A 名下的公开题。
**风险场景**:比赛开始前,任意持有 problem_permission 的管理员(含 Student Admin)遍历 problem id 就能把别人未开赛比赛的题面连同标准答案全部拖走。中职学校场景下这就是考前泄题。
**修法**:在 496 行之后补 `if (!(await canEdit(user, problem)))` → 404;顺带把 `problem.contestId` 分支的成功响应改成不回传题面(或至少 omit `answers`)。
---
## Important
### I1 — `/problems/stuck` 与 `/problems/ac-trend` 被 `/problems/:id` 遮蔽,两个 requireTeacher 端点实际不可达
**文件**`apps/api/src/routes/admin/tag.ts:190``tag.ts:215`(被 `apps/api/src/routes/admin/problem.ts:275` 遮蔽);成因在 `apps/api/src/routes/admin/index.ts:30-32``adminProblemRoutes` 先于 `adminTagRoutes` 注册)
Hono 按注册顺序匹配,`GET /problems/:id``:id` 会吃掉 `stuck` / `ac-trend` 这两个字面段。于是:
- 生效的守卫是 `requireProblemPermission`problem.ts:275),**不是** tag.ts 上写的 `requireTeacher`
- handler 也是 problem.ts 的,`queryInteger("stuck", 0, {min:1})` 回落到 0,查不到题 → 404
- tag.ts:190 / 215 的两个 handler **一次都没被执行过**
**旧后端对应行为**`problem/views/admin.py:638-640``StuckProblemsAPI.get` `@teacher_admin_required`)与 `675-677``TopACTrendAPI.get` `@teacher_admin_required`),两条独立 URL,正常可达。
**实跑验证**(用 problem_permission 做判别,因为两个守卫的档位不同)
`student` = Teacher Admin + `problem_permission='None'`。按 tag.ts 写的 `requireTeacher` 应当放行;按 problem.ts 的 `requireProblemPermission` 应当 403
```
GET /api/admin/problems/stuck → 403 {"error":{"code":"permission-denied","message":"权限不足"}}
GET /api/admin/problems/ac-trend → 403 {"error":{"code":"permission-denied","message":"权限不足"}}
GET /api/admin/contests → 200 {"data":{"results":[],"total":0}} # requireTeacher 确实放行
```
判定:跑的是 `requireProblemPermission`,即 problem.ts:275。
把权限补回 `Own`、再提到 Super Admin,两条依旧是 problem.ts 的错误体:
```
GET /api/admin/problems/stuck (Teacher/Own) → 404 {"code":"problem-not-found","message":"Problem does not exist"}
GET /api/admin/problems/ac-trend (Teacher/Own) → 404 {"code":"problem-not-found","message":"Problem does not exist"}
GET /api/admin/problems/stuck (Super Admin) → 404 {"code":"problem-not-found","message":"Problem does not exist"}
```
**风险场景**:当前只是「两个教师功能死了 + 挂的守卫不是实际生效的守卫」。真正的隐患在于**注册行上写的守卫和实际执行的守卫不一致**——`index.ts:15-21` 的注释明确说「不在总入口兜一层,就是为了让守卫从注册行上看得出来」,这条遮蔽正好打破了那个前提。将来若有人放宽 `GET /problems/:id`,会连带把两个 teacher-only 的统计接口一起放宽,而 tag.ts 上的 `requireTeacher` 看着还是对的。
**修法**:把 tag.ts 的两条静态路由挪到 problem.ts 的 `/problems/:id` 之前注册(调整 index.ts 顺序,或把它们改成 `/problem-stats/stuck` 这类不冲突的路径)。
---
### I2 — `from-public` 只校验目标比赛归属,不校验源题归属,可把别人的比赛题拖进自己的比赛
**文件**`apps/api/src/routes/admin/problem.ts:535-580`
```ts
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
const [problem] = await db.select().from(schema.problem)
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1) // ← 任意 id,无过滤
if (!contest || !problem) return failure(c, 404, "not-found", ...)
if (user.adminType !== "Super Admin" && contest.createdById !== user.id) { // ← 只校验比赛
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
```
源题既不校验 `createdById`,也不校验 `visible`,更不限制 `contestId is null`(尽管路由叫 from-**public**)。拷贝出来的题落在调用者自己的比赛里,之后 `canEdit` 走「比赛题看比赛创建者」分支 → 调用者对它有完全读写权。
**旧后端对应行为**`problem/views/admin.py:486-511``AddContestProblemAPI.post` —— **一个装饰器都没有,也没有任何 ensure_created_by**,比新后端还宽(连管理员身份都不要求)。新后端补上了 `requireProblemPermission` + 比赛归属,是净收紧;但对象级的源题归属仍然缺失。
**实跑验证**
A 的公开题 37(A 所有)、A 的比赛题 38(在 A 的比赛 3 里)。B 建自己的比赛 5:
```
POST /api/admin/contests/5/problems/from-public {"problemId":37,"displayId":"X1"} (cookie = B)
→ 201 {"data":{"id":40,"_id":"X1","title":"REVIEW REVIEWA1","description":"<p>d</p>",...}}
POST /api/admin/contests/5/problems/from-public {"problemId":38,"displayId":"X2"} (cookie = B)
→ 201 {"data":{"id":41,"_id":"X2","title":"REVIEW A","description":"<p>d</p>",
"testCaseId":"reviewdummy00000000000000000000",...}}
GET /api/admin/problems/41 (cookie = B)
→ 200 {"data":{"id":41,"_id":"X2","title":"REVIEW A",...}} # 复制品 B 完全可读可改
```
问题 38 是 A 的**比赛题**,B 直读它是 404(见 C2 实跑),经这条路径拷一份就拿到了。
**风险场景**:与 C2 同源 —— 考前拖走别人比赛的题面与标准答案,只是入口换成了「往自己比赛里加题」。
**修法**:源题至少要求 `contestId is null`(名副其实的 public);理想情况再叠一层 `canEdit(user, problem) || problem.visible`
---
### I3 — 克隆比赛不校验归属,且响应里回传原比赛的明文密码
**文件**`apps/api/src/routes/admin/contest.ts:172-233``password: original.contest.password` 在 192 行,`serialize` 下发 `password` 在 49 行)
```ts
adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
const [original] = await selectContest(id)
// 克隆不要求 ownedBy:旧后端这里也没有 ensure_created_by,教师可以拿别人的比赛做模板。
// 克隆出来的归调用者所有、且默认不可见,所以不构成越权修改。
if (!original) return failure(c, 404, "contest-not-found", "Contest does not exist")
```
注释对「不构成越权**修改**」的判断是对的,但漏了越权**读取**:克隆会把原比赛的全部题目连同 `password` 一起复制进调用者名下的新比赛,调用者随后对这些题有完整读写权。
**旧后端对应行为**`contest/views/admin.py:265-301``ContestCloneAPI.post``@teacher_admin_required`,确实没有 `ensure_created_by`,也照样 `password=original.password`。**行为等价**;但旧的 `ContestAdminSerializer` 同样下发 password,所以泄露面一致,属于原样继承的洞。
**实跑验证**
A 建密码保护赛 3password=`secret123`visible=true)。B 直读被拦:
```
GET /api/admin/contests/3 (cookie = B) → 404 contest-not-found
PUT /api/admin/contests/3 (cookie = B) → 404 contest-not-found
GET /api/admin/contests/3/problems (cookie = B) → 404 contest-not-found
GET /api/admin/contests/3/acm-helper (cookie=B) → 404 contest-not-found
```
B 克隆:
```
POST /api/admin/contests/3/clone (cookie = B)
→ 201 {"data":{"id":4,"title":"REVIEW-CONTEST-A","description":"d","tag":"t",
"startTime":"2026-08-07 23:19:29.475+00","endTime":"2026-08-08 00:19:29.475+00",
...,"password":"secret123"
```
四条读接口全 404,克隆一次全拿到,外加明文密码。
**风险场景**:任一 Teacher Admin 可在开赛前克隆同事的比赛,读全部题面,并拿到密码直接以学生身份进原赛。
**修法**:至少不要把 `password` 复制到克隆件、也不要在克隆响应里回传;更彻底的做法是给 clone 加 `ownedBy`(会改变旧行为,需要产品确认「拿别人比赛当模板」是不是真需求)。
---
### I4 — `POST /upload-image` 收成 Super Admin,教师/学生管理员写题面时会 403
**文件**`apps/api/src/routes/admin/conf.ts:206`
```ts
adminConfRoutes.post("/upload-image", requireSuperAdmin, async (c) => {
```
**旧后端对应行为**`utils/views.py:13-16``SimditorImageUploadAPIView.post` —— **完全没有装饰器**`utils/urls.py:6` 挂在 `api/admin/upload_image`),任何人包括匿名都能传图。新后端收成 Super Admin 是必要的修补,但收过头了。
前端 `ojnext/src/admin/api.ts:159-163``uploadImage``MarkdownEditor.vue` / `TextEditor.vue` 调用,而这两个编辑器出现在:
- `src/admin/problem/detail.vue` —— 题目编辑页,`requireProblemPermission` 档位,Student Admin 就能进
- `src/admin/contest/detail.vue` —— 比赛编辑页,`requireTeacher` 档位
- `src/admin/announcement/detail.vue``src/admin/tutorial/detail.vue` —— 这两个确实是 Super Admin
**实跑验证**`e2etest` = Teacher Admin/Own
```
POST /api/admin/upload-image (cookie = Teacher Admin)
→ 403 {"error":{"code":"permission-denied","message":"权限不足"}}
```
**风险场景**:不是越权,是**可用性回归**——教师往比赛描述里插图、学生管理员往题面里插图,都会静默失败。这类问题在权限评审里要一起提,因为「修法」是调守卫档位。
**修法**:改成 `requireAdmin`(三种管理员均可)。上传目录是服务端生成文件名、限了后缀和 10MB,风险可控。
---
## Minor
### M1 — `PUT /problems/:id/visibility` 的归属规则与 `canEdit` 不一致,比赛题上会反转
**文件**`apps/api/src/routes/admin/tag.ts:173-186`
```ts
if (!canManageAllProblems(user) && problem.createdById !== user.id) {
return failure(c, 404, "problem-not-found", "题目不存在")
}
```
用的是**公开题**的规则(看 `problem.createdById`),而 `problem.ts:44-52``canEdit` 对比赛题走的是「看比赛创建者」。结果在比赛题上两套规则给出相反答案。
**旧后端对应行为**`problem/views/admin.py:600-610``ProblemVisibleAPI.put` **完全没有归属校验**(而且 605-607 的 `self.error` 漏写 return,题不存在会 500)。新后端补了校验,是净收紧;这里记的是新后端内部两套规则不自洽。
**实跑验证**
题 41 位于 B 的比赛 5 里,但 `created_by_id = 2`Afrom-public 复制时继承的):
```
PUT /api/admin/problems/41/visibility (cookie = B,比赛所有者) → 404 {"code":"problem-not-found","message":"题目不存在"}
PUT /api/admin/problems/41/visibility (cookie = A,非比赛所有者) → 200 {"data":{"visible":false}}
GET /api/admin/problems/41 (cookie = A) → 404 # canEdit 说 A 无权
GET /api/admin/problems/41 (cookie = B) → 200 # canEdit 说 B 有权
```
同一道题,`canEdit` 判 B 能改、A 不能;`visibility` 判 A 能改、B 不能。A 能把 B 比赛里的题隐藏掉,B 自己反而改不回来。
**修法**`visibility` 改用 `canEdit`
### M2 — `from-public` 的错误码构成比赛存在性预言机
**文件**`apps/api/src/routes/admin/problem.ts:544` vs `546-548`
比赛不存在返回 `not-found`,比赛存在但不属于你返回 `contest-not-found`。攻击者带一个已知有效的 `problemId`,就能靠错误码区分「这个 contestId 不存在」和「存在但是别人的」。其余所有跨租户路径都统一成了同一个码(已实跑确认:contest 系列全是 `contest-not-found`、problemset 系列全是 `problem-set-not-found`、problem 系列全是 `problem-not-found`),只有这一处破例。
**旧后端对应行为**`problem/views/admin.py:493-494` 统一返回 `"Contest or Problem does not exist"`,不区分。
(仅静态判断 + 上文 I2 实跑观察到的错误码差异,未专门构造预言机实验。)
### M3 — 三个 admin 端点的守卫写在 handler 体内,不在注册行
**文件**`apps/api/src/routes/submission.ts:221-224``submission.ts:337-340``apps/api/src/routes/flowchart.ts:142-145`
```ts
submissionRoutes.get("/submissions/statistics", requireAuth, async (c) => {
if (!isTeacherOrAbove(c.get("user"))) return failure(c, 403, "permission-denied", ...)
submissionRoutes.post("/submissions/:id/rejudge", requireAuth, async (c) => {
if (!isSuperAdmin(c.get("user"))) return failure(c, 403, "permission-denied", ...)
flowchartRoutes.get("/flowcharts/statistics", requireAuth, async (c) => {
if (!isTeacherOrAbove(c.get("user"))) return failure(c, 403, "permission-denied", ...)
```
**档位本身正确**,与 `submission/views/admin.py:14``@super_admin_required`)、`submission/views/admin.py:31``@teacher_admin_required`)、`flowchart/views/admin.py:69``@teacher_admin_required`)逐条对应。问题只是写法违背了 `routes/admin/index.ts:15-21` 立下的约定(「守卫要从注册行上看得出来」),下一个人加同类端点时容易漏掉那个 if。建议改用 `requireTeacher` / `requireSuperAdmin` 中间件。
(仅静态判断,未实跑 —— 这三条不在阶段 4 的 admin 目录里。)
### M4 — 禁用账号落到 401 `login-required`,与旧后端的「账号已禁用」不同
**文件**`apps/api/src/auth/middleware.ts:32-34`(注释)、`41`
`getSessionUser` 对禁用用户返回 null → 401 `login-required`。旧 `account/decorators.py:36-37` 是先过权限检查再报 `permission-denied` + 「账号已禁用」。
方向上更安全(提前一步拦),但前端拦截器对 `login-required` 是弹登录框:一个会话中途被禁用的用户会陷入「弹登录框 → 登录 → 又被弹」的循环,而不是看到「账号已禁用」。属于可用性 / 可诊断性问题,不是越权。
(仅静态判断。)
---
## 有意偏离的独立判断
| 偏离 | 判断 | 依据 |
|---|---|---|
| 删除不存在的记录返回 404 而非静默成功 | **合理** | 旧 `announcement/views/admin.py:57-61``tutorial/views/admin.py:62-66``achievement/views/admin.py:72-78` 都是 `filter().delete()` 静默成功,后台点了删除却没删掉是真的会误导人。这些资源全在 Super Admin 档位,404 不构成枚举风险。**唯一例外是奖章删除**——那里的 404 和真实副作用不一致,见 C1,问题不在 404 本身而在事务顺序。 |
| 题单后台列表不按 visible 过滤 | **合理,且是必要修复** | 旧 `problemset/views/admin.py:35` 写死 `filter(visible=True)`,同时 `ProblemSetVisibleAPI`(356-372) 又提供切换可见性——设成不可见后题单从后台列表消失,界面上再也改不回来。新写法仍保留 `createdById` 过滤(`problemset.ts:80`),越权面没有变化。已实跑确认 A 看不到 B 的题单(`GET /problem-sets` 返回 `{"results":[],"total":0}`)。 |
| ACM 核查校验 rank 归属 | **合理,且修掉了旧后端一个真洞** | 旧 `contest/views/admin.py:205` 只按 `pk=data["rank_id"]` 取,任一 Teacher Admin 带任意 rank_id 就能改别的比赛的核查标记。新 `contest.ts:298-302``and(id, contestId)` 收口,父子归属正确。(静态判断 + `GET /contests/3/acm-helper` 的跨租户 404 实跑,PUT 分支因需要真实 ACM 排名数据未实跑。) |
| 题目删除统一成一条路由 | **合理** | 旧的两条(`ProblemAPI.delete` 332 行 `contest_id__isnull=True` + `ContestProblemAPI.delete` 443 行 `contest_id__isnull=False`)只是按 contestId 分流,比赛是从题目推导出来的,前端根本不需要传 contestId。新 `problem.ts:371-379``canEdit` 已经把两种归属规则都覆盖了:公开题看题目创建者、比赛题看比赛创建者,与旧的 `ensure_created_by(problem)` / `ensure_created_by(problem.contest)` 一一对应。附带把「有提交记录不能删」从只对比赛题生效(旧 447 行)推广到公开题,是收紧不是放松。 |
---
## 核实过、确认没问题的部分
这一节界定覆盖面,与上面的问题清单同等重要。
**角色守卫档位(86/86 逐条比对,全部一致,无一漏挂)**
| 新后端路由组 | 守卫 | 旧后端装饰器 | 结论 |
|---|---|---|---|
| `/users*`6 条) | requireSuperAdmin | `account/views/admin.py:43,76,125,159,244` `@super_admin_required` | 一致 |
| `/announcements*`5 条) | requireSuperAdmin | `announcement/views/admin.py:13,23,40,57` | 一致 |
| `/tutorials*` `/exercises*`10 条) | requireSuperAdmin | `tutorial/views/admin.py:17,27,44,62,70,90,106,119,127` | 一致 |
| `/achievements*` `/achievement-metrics`6 条) | requireSuperAdmin | `achievement/views/admin.py:10,21,43,72,82` | 一致 |
| `/website` `/judge-servers*` `/orphan-test-cases*` `/dashboard` `/random-usernames`9 条) | requireSuperAdmin | `conf/views.py:49,66,76,84,148,162``DashboardInfoAPI`(187) 与 `RandomUsernameAPI`(216) 旧后端**无装饰器** | **收紧**(后两条旧后端任何管理员可读,不限超管) |
| `/contests*`5 条) | requireTeacher | `contest/views/admin.py:33,53,78,267` `@teacher_admin_required` | 一致 |
| `/contests/:id/acm-helper`2 条) | requireTeacher | `contest/views/admin.py:167,200` | 一致 |
| `/problem-sets*`15 条) | requireTeacher | `problemset/views/admin.py` 全部 `@teacher_admin_required` | 一致 |
| `/ai/reports*`3 条) | requireTeacher | `ai/views/admin.py:9,31` `@teacher_admin_required` | 一致 |
| `/problems/stuck` `/problems/ac-trend` | requireTeacher(写了但被遮蔽) | `problem/views/admin.py:639,676` | 档位对,见 I1 |
| `/problems*` `/contests/:id/problems*` `/test-cases` `/sql-test-cases/*`14 条) | requireProblemPermission | `problem/views/admin.py:237,259,294,326,458,757,798``TestCaseAPI`(143)、`ContestProblemAPI`(343) 旧后端**无装饰器**、`AddContestProblemAPI`(486) 亦无 | 一致或**收紧** |
| `/problem-tags*` `/problems/batch-tag` `/problems/:id/visibility` `/problems/flowchart`5 条) | requireProblemPermission | `problem/views/admin.py:515,524,554,570,601,614` | 一致 |
**中间件语义**`auth/middleware.ts:48-67`):`ADMIN_ROLES` / `TEACHER_ROLES` 白名单与 `account/models.py:65-73``is_admin_role()` / `is_teacher_or_above()` 逐项一致;`requireProblemPermission` = 管理员 ∧ `problemPermission !== "None"`,与 `decorators.py:78-84` 一致。已实跑确认 Teacher Admin + `problem_permission='None'``/problems/stuck` 上得到 403,在 `/contests` 上得到 200。
**跨租户读写隔离(实跑,全部 404、不泄露存在性)**
```
(cookie = B, 全部指向 A 的资源)
GET /api/admin/problem-sets/2 → 404 problem-set-not-found
GET /api/admin/problem-sets/2/badges → 404 problem-set-not-found
DELETE /api/admin/problem-sets/3/progress/4 → 404 problem-set-not-found (A 打 B)
PUT /api/admin/problem-sets/2/problems/1 → 404 problem-not-in-set (跨题单 itemId)
GET /api/admin/contests/3 → 404 contest-not-found
PUT /api/admin/contests/3 → 404 contest-not-found
GET /api/admin/contests/3/problems → 404 contest-not-found
GET /api/admin/contests/3/acm-helper → 404 contest-not-found
GET /api/admin/problems/37 → 404 problem-not-found
DELETE /api/admin/problems/37 → 404 problem-not-found
GET /api/admin/problems/37/test-cases → 404 problem-not-found
PUT /api/admin/problems/37/visibility → 404 problem-not-found
GET /api/admin/problems → 200 {"results":[],"total":0} # 列表按 createdById 过滤
POST /api/admin/problems/batch-tag {problemIds:[40]} → 404 no-problems # 非本人题目被过滤掉
```
错误消息统一是「不存在」,没有出现「无权限」。**归属校验的正向行为是对的,问题只出在 C1/C2/I2 三条例外路径上。**
**超管旁路(实跑)**`student` 提为 Super Admin 后,`GET /problem-sets/3``GET /contests/5``GET /problems/41` 全部 200`ownedBy` / `canEdit` 的超管分支正确。
**其他确认无误的点**
- `canEdit``problem.ts:44-52`)对「比赛题看比赛创建者、公开题看题目创建者」的区分实现正确,且比赛题分支**不**给 `problemPermission=All` 旁路 —— 与旧 `ensure_created_by` 对非 Problem 实例只认 `created_by`/超管的语义一致
- `PUT /problem-sets/:id/problems/:itemId``DELETE /problem-sets/:id/problems/:itemId``PUT /problem-sets/:id/badges/:badgeId``DELETE /problem-sets/:id/progress/:userId` 的父子归属都写在同一条 `and()` 里,无 TOCTOU,已实跑确认(**只有 badges 的 DELETE 例外,见 C1**
- `DELETE /problem-tags/:id``tag.ts:92-102`)的事务里先删 `problemTags` 再删 `problemTag`,但删的是同一个 id,标签不存在时前一句是空操作,**不存在 C1 那种连带破坏**
- `DELETE /users``account.ts:237-257`)禁止删除自己,已实跑确认返回 400 `cannot-delete-self`
- `PUT /users/:id``normalizePermission``account.ts:49-53`)与 `account/views/admin.py:98-105` 的归一逻辑一致,降级超管会同步清掉 All
- 真名下发受控:`sampleUser``routes/helpers.ts:15-25`)默认 `realName: null`,只有 `acm-helper``contest.ts:272`)显式下发,在 requireTeacher + 归属校验之后
> **2026-08-31 订正**:本条原先还写了「题单进度(`problemset.ts:426`)」,与代码不符 ——
> 学生端那条 `GET /problem-sets/:id/user-progress` 走的是 `sampleUser(progressUser, realName)`
> 没传 `includeRealName``realName` 恒为 `null`SQL 里那次 `leftJoin userProfile` 是白查的)。
> 真正显式下发真名的是**后台**那条 `GET /admin/problem-sets/:id/progress`,它手写
> `adminProblemSetProgressSchema`,在 requireTeacher + `loadOwned` 归属校验之后,结论仍成立。
>
> 另外当时漏了一条:学生端那条 `user-progress` 只有 `requireTeacher`、**没有归属校验**
> 任何 Teacher Admin 都能读到别人建的题单的学生名单与进度(不含真名)。已补上归属校验,
> 口径与后台的 `loadOwned` 一致,越权报 404。
- `GET /ai/reports/:id``ai.ts:70-83`)不下发 `data` / `systemPrompt` / `userPrompt`
- `GET /judge-servers``conf.ts:90-100`)下发 judge token,但在 requireSuperAdmin 之后,与旧 `conf/views.py:66-74` 一致
- `DELETE /orphan-test-cases``conf.ts:147-160`)对指定 id 也先确认是孤儿,比旧 `conf/views.py:162-171` 严 —— 合理收紧
---
## 覆盖率
| 项 | 数 |
|---|---|
| `routes/admin/*.ts` 中的 handler | 86 |
| 逐条静态比对旧后端装饰器的 handler | 86100% |
| `/admin` 之外但对应旧 admin 视图的 handler | 3submission statistics / rejudge、flowchart statistics |
| **合计评审端点** | **89** |
| 实跑验证的端点 | **46**52% |
| 实跑覆盖的角色组合 | Regular User(隐含匿名 401)、Student Admin+Own、Teacher Admin+None、Teacher Admin+Own、Super Admin+All |
| 实跑的跨租户攻击路径 | 18 条(14 条被正确拦截、4 条成功越权) |
未实跑的 43 条主要是:只在 Super Admin 档位、无归属概念的 CRUDtutorials/exercises/achievements/announcements 的写操作),以及需要真实测试点文件或真实 ACM 排名数据的端点(`/test-cases` 上传、`/sql-scripts``/sql-test-cases/*``acm-helper` 的 PUT、`/problems/flowchart` 需要 AI 服务)。这些的守卫档位已逐条静态核对,均与旧后端一致。
## 环境还原确认
```
select 'problem',count(*) from problem → 20 (与评审开始时一致)
select 'contest',count(*) from contest → 0
select 'problemset',count(*) from problemset → 0
select 'badge',count(*) from problemset_badge → 0
select 'user_badge',count(*) from user_badge → 0
select count(*) from problem_tag where id=95 → 0 (测试标签已删)
id|username|admin_type |problem_permission
1 |devadmin|Super Admin |All
2 |student |Regular User |None
4 |e2etest |Regular User |None
```
`e2etest` 的密码在测试中被 reset-password 接口改掉,已通过 `PUT /users/4` 改回 `Test123456` 并重新登录验证成功;`user_profile.real_name` 也已还原为 NULL。未执行任何 drop / truncate。
-150
View File
@@ -1,150 +0,0 @@
# Phase 4 安全评审 — SQL 判题引擎沙箱逃逸与资源耗尽
评审对象:`apps/api/src/judge/sql/`engine.ts / child.ts / index.ts+ 判题接线 `judge/run.ts:judgeSqlSubmission` + 测试点处理 `services/test-case.ts`
参照旧实现:`OnlineJudge/judge/sql_runner.py` / `sql_dispatcher.py`Python sqlite3 + authorizer / progress_handler / setlimit)。
**评审方式**:全部实跑。快速迭代喂 `child.ts` JSON 作业;关键结论走完整提交链路(建 SQL 题 → 提交 → 判题结果)与父进程 `index.ts` 逻辑。测试环境已还原(题目/提交/测试点目录已删,`student` 已改回 Regular User / None,无残留进程)。
> **修复记录(2026-08-07,本文档之后)**
>
> - **I-1 已修**`runStudent` 加逐语句守卫,学生 SQL 里的 PRAGMA 一律拒(两种题型都拒,
> 对齐旧实现 authorizer 的 `_DENIED_ALWAYS`)。关键字判定用 `sqlite3_normalized_sql`
> 注释/大小写/空白由 SQLite 自己抹平。另加兜底:每条语句前重放 `query_only` 与
> `max_page_count`,即便判定漏了也关不掉只读。**M-1 的 `max_page_count` 可调大部分一并修掉。**
> - **I-2 已修**:子进程用 stderr 报阶段(`prepare` / `student` / `display`),父进程边读边换
> 兜底时限 —— 一进学生 SQL 就收到「题目时限 + 2s」。1s 限的题跑飞语句实测 **26s → 3.06s**。
> 顺带修正归因:卡在受信脚本(出题人的初始化/标准答案)现在报 SYSTEM_ERROR,不再甩给学生 TLE。
> **补充(2026-08-08Minor 收尾)**
>
> - **M-1 已全部修完。** 第二半(`max_page_count` 学生可调大)在 I-1 拦 PRAGMA 时就一并
> 修掉了。第一半改成引擎侧按字节记账(`ByteBudget`):单值超题目内存限、或结果集
> 累计超限,都按 MLE 拒。旧实现的 `setlimit(SQLITE_LIMIT_LENGTH)` 复刻不了 ——
> sql.js 的 wasm 没导出 `sqlite3_limit`。实测**同一句 SQL 在 4MB 的题上被拦、
> 在 64MB 的题上放行**,题目的 `memoryLimit` 现在对学生是真约束。
> (测试尺寸特意选在 8MB 而不是 100MB:wasm 堆触顶时的兜底报的是同一句错误信息,
> 用大值测根本分不清是谁拦的 —— 第一版测试就是这么误判的。)
> - **M-3 已由阶段 5 的分阶段超时顺带解决**:预览从 25s 降到 **13005ms** 实测。
> - **M-2 不修**(见下)。
> **M-2 的处理:不修,理由写在这里**
>
> 要做「强制两个测试点的期望结果不同」,得在建题时把每个测试点都跑一遍标准答案再比对。
> 技术上可行,但它会把一类**合法出题**也挡掉(比如刻意用两组不同数据验证同一个边界、
> 结果恰好相同),而代价是老师建题时被一条看不懂的错误拦住。评审也确认这与旧实现行为
> 一致、非回归。
>
> 更合适的做法是「提示」而不是「拒绝」,但那要动契约和后台前端。留给以后,
> 现在记在这里,免得下次评审又把它当成新发现。
> - `engine.ts` 头部的防护对照表已按实测重写,两处削弱写在正文里。
> 另记:stock sql.js 的 wasm **没有导出** `sqlite3_progress_handler` / `sqlite3_interrupt` /
> `sqlite3_set_authorizer` / `sqlite3_limit`(已核对导出表),要用得自己编 wasm。
## 结论速览
| # | 威胁 | 结果 | 级别 |
|---|---|---|---|
| 1 | 文件系统逃逸(ATTACH 等) | **打不穿**。WASM 无宿主 FS 绑定,结构性隔离,比旧实现更强 | — |
| 2 | 超时挂住判题进程 | 单条长语句引擎拦不到,只靠父进程 25s 后 SIGKILL1s 限的题实测 26s 才 TLE,25x 放大 + 判题池仅 2 并发 → 可拒绝服务 | **Important** |
| 3 | 内存耗尽 | 单值/瞬时内存被固定 512MB `ulimit -d` 兜住(已验证生效),但与题目 `memoryLimit` 脱钩,且 `max_page_count` 学生可自行调大 | **Minor** |
| 4 | 查询题只读绕过 | **破防**`PRAGMA query_only=0` 一句即可恢复写权限,实测查询题用 DML 拿到 Accepted | **Important** |
| 5 | 硬编码常量作弊 | 多测试点防线成立(前提:测试点数据确实不同,代码只强制"≥2 个"不强制"数据不同" | **Minor** |
| 6 | 出题人侧预览/生成 | 与学生同隔离,无宿主 FS,比旧实现更强;教师可让预览请求挂 25s | **Minor** |
对照表逐条核对结果(实现者声明 vs 实测):
| 旧防护 | 新做法 | 核对结果 |
|---|---|---|
| authorizer 禁 ATTACH | WASM 无宿主 FS,结构上够不到 | ✅ **成立且更强** |
| authorizer 白名单让查询题只读 | `PRAGMA query_only=1` | ❌ **不成立**:学生可 `PRAGMA query_only=0` 反手关掉 |
| progress_handler 墙钟超时 | 子进程外部 SIGKILL | ⚠️ **部分成立**:只在父进程路径生效且慢 25x;单条长语句引擎内拦不到 |
| `setlimit(LIMIT_LENGTH)` | 子进程 `ulimit -d` | ⚠️ **部分成立**`-d` 确实作用到最终进程,但固定 512MB、与题目内存限脱钩 |
---
## Important
### I-1 查询题只读可被 `PRAGMA query_only=0` 一句绕过
- **位置**`apps/api/src/judge/sql/engine.ts:199-200``if (mode === "query") db.run("PRAGMA query_only=1")`),学生 SQL 随后在 `engine.ts:202 → executeStatements` 无任何语句级过滤地执行。
- **根因**:新实现放弃了 authorizer,学生 SQL 的 PRAGMA 完全不设防。`query_only` 只是一个可写开关,学生自己就能把它关回去。旧实现把 `SQLITE_PRAGMA` 放进 `_DENIED_ALWAYS`,学生连一条 PRAGMA 都跑不了(`sql_runner.py` `_run_student` authorizer)。
- **攻击手法**:查询题提交 `PRAGMA query_only=0; <任意 DML/DDL>; <正确 SELECT>`
- **实测(完整提交链路,query 题 id=43,标准答案 `SELECT a FROM t ORDER BY a`**
攻击提交:
```sql
PRAGMA query_only=0; UPDATE t SET a=a; SELECT a FROM t ORDER BY a;
```
→ `RESULT 0`**ACCEPTED**)—— UPDATE 成功执行,只读约束失效。
对照提交(不带 PRAGMA):
```sql
UPDATE t SET a=a; SELECT a FROM t ORDER BY a;
```
→ `RESULT 4`RUNTIME_ERROR`err_info: 本题为查询题,禁止修改数据或表结构…`
child.ts 层复现同样区分:带 PRAGMA 的 `DELETE/INSERT` 走到 WA(写入生效),不带的报 readonly。
- **旧实现表现**`PRAGMA query_only=0` 会被 authorizer 直接 DENY`not authorized`),学生无法改动只读态;且白名单模式下任何非 SELECT 授权码都拒绝。
- **实际危害边界**:每个测试点都是独立的内存库、无宿主 FS、跨测试点不持久,因此**不构成沙箱逃逸,也不构成作弊**(学生仍需对所有测试点产出正确结果集,DML 帮不上)。危害限于:查询题的"只读"教学约束被破坏,学生可用增删改而非查询"混"过题目。但它直接证伪了对照表第 2 行的安全声明,且利用成本为零,故列 Important。
- **修复方向**:学生 SQL 执行前用 `iterateStatements` 预扫,遇到 PRAGMA(或至少 `query_only` / `max_page_count` / `writable_schema` 等敏感 PRAGMA)即拒;或在只读模式下用不可翻转的手段(如整库以 immutable/只读方式打开)替代可写开关。
### I-2 单条长语句超时只能靠父进程 25s 后 SIGKILL,放大 25x 且可拖垮判题池
- **位置**`engine.ts:116-149``executeStatements` 的 deadline 检查在 **`engine.ts:126`**,只在语句之间触发);`index.ts:45``setTimeout(kill, budgetMs + HARD_TIMEOUT_SLACK_MS)`);`index.ts:80``runSqlCase` budget = `max(timeLimitMs*5, 10_000)`);`index.ts:25``HARD_TIMEOUT_SLACK_MS = 15_000`)。
- **根因**:引擎的墙钟检查只发生在 `iterateStatements` 的每条语句开头。**一条长时间运行的语句(递归 CTE 死循环、大 CROSS JOIN 喂聚合)在单次 `step()` 内部执行,`step()` 期间不检查 deadline**,引擎无法中断。旧实现的 `progress_handler` 每 1000 条 VM 指令回调一次,在语句执行**过程中**就能按墙钟中断,约 `timeLimitMs`1s)即杀。
- **攻击手法**`WITH RECURSIVE r(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM r) SELECT count(*) FROM r;`(聚合,不向外层吐行,`ROW_LIMIT` 也拦不到)。
- **实测**
- 直喂 child.ts(无父进程):`timeout 12` 强杀,**子进程 12s 内从不自终**(引擎 deadline 对单语句无效)。
- 走父进程 `index.ts` `runSqlCase`timeLimitMs=1000):`elapsed_ms=25005`,返回 `{"ok":false,"result":1,"message":"SQL 执行超时"}`。
- **完整提交链路**(1s 限的 query 题):从提交到出 `RESULT 1`TLE)实测 **26 秒**。
- **判题池影响**`config.ts:68` `judgeConcurrency` 默认 **2**;SQL 判题作业与所有语言共用同一 BullMQ 判题 worker。用户级限流 `capacity=20``throttling.ts:24`)。单个学生一次性提交 20 条死循环 SQL ≈ `20×25s / 2 = 250s`,**两个判题槽被占满约 4 分钟**,期间全体学生(含 Python/C)提交排队。考试/比赛期这是可用性风险。
- **旧实现表现**`progress_handler` 在 ~1s`timeLimitMs`)即中断,worker 几乎立刻释放。新实现慢 25 倍且完全依赖父进程(子进程独立运行时永不自终)。
- **修复方向**:把 `HARD_TIMEOUT_SLACK_MS` 从 15s 收紧、`runSqlCase` budget 别乘 5;或给 sql.js 编译进 `sqlite3_progress_handler` 等价的中断回调,在语句内按墙钟中断。
---
## Minor
### M-1 学生内存受固定 512MB `ulimit -d` 约束,与题目 `memoryLimit` 脱钩;`max_page_count` 学生可调大
- **位置**`index.ts:23``CHILD_DATA_LIMIT_KB = 512 * 1024`**硬编码**,不读题目 `memoryLimit`);`index.ts:38-41``sh -c "ulimit -d …; exec …"`);`engine.ts:101-108``newDatabase``max_page_count = memoryLimitMb*256`)。
- **核对"`ulimit -d` 是否真作用到最终 bun 进程"(重点关注项)**:**成立**。通过同款 `sh -c "ulimit -d 524288; exec bun …"` 包装起子进程后读 `/proc/self/limits`
```
Max data size 536870912 536870912 bytes
```
`exec` 保留 rlimit`-d` 确实落到最终 bun 进程。`hex(zeroblob(3e8))` 实测被拦,返回 `RESULT 3`MEMORY_LIMIT_EXCEEDED"单个数据值超出内存限制"),完整链路复现一致。所以 `-d` 而非 `-v` 的选型与"作用到最终进程"的声明都成立。
- **不足**
1. 512MB 是**全局固定值**,与题目 `memoryLimit`(如 64MB)无关。旧实现 `setlimit(SQLITE_LIMIT_LENGTH, memory_limit_mb*1MB)` 把单值上限贴着题目内存限(64MB)。新实现下 64MB 的题,学生单值/瞬时内存可到 ~512MB 才报 MLE8x)。
2. 题目内存限唯一的落点 `max_page_count` 是一条 **PRAGMA,学生可自行 `PRAGMA max_page_count=1000000` 调大**(同 I-1 根因:PRAGMA 不设防)。实测该 PRAGMA 被接受、无 authorizer 拒绝。因此题目 `memoryLimit` 对学生**不是权威约束**,真正的硬顶只有那 512MB。
- **危害边界**:512MB 仍能兜住机器(单进程封顶、`ROW_LIMIT=10000` 兜结果集行数),不至于拖垮宿主;只是"每题内存限"名不副实。故 Minor。
### M-2 测试点只强制"≥2 个",不强制"数据不同";作弊防线依赖出题人
- **位置**`services/test-case.ts:102-105``if (options.sql && selected.length < 2) throw …`)。
- **核对作弊假设(题目要求至少 2 个数据不同的测试点,硬编码常量过不了)**:**假设成立,但仅在测试点数据确实不同的前提下**。
- 实测(TC1 数据 `1,2,3`TC2 数据 `10,20,30,40`):提交硬编码 `SELECT 1 UNION SELECT 2 UNION SELECT 3;` → `RESULT -1`WRONG_ANSWER)。多测试点防线有效。
- 但代码只校验 `.sql` 文件个数 ≥2,**不校验两个测试点跑出的期望结果是否不同**。题目页展示的是**测试点 1** 的期望结果(`buildDisplay` 取 test case 1`admin/problem.ts:160-173`),学生能看到 TC1 的答案。若出题人不慎让 TC2 的数据产出与 TC1 相同的结果集,硬编码即可 AC。
- **旧实现表现**:同样基于"跑标准答案 + 多测试点比对",无"数据必须不同"的强校验,行为一致(非回归)。故 Minor,属出题人操作风险。
### M-3 出题人侧预览可挂 25s(教师自伤,有限)
- **位置**`admin/problem.ts:653``/sql-test-cases/preview` → `buildSqlDisplay`),走同一子进程隔离,budget 10s + slack 15s。
- **实测**:教师 `refSql` 放死循环递归 CTE → 预览 HTTP 请求阻塞 **25005ms** 后返回 400 `生成展示数据超时或内存超限`。占用一个 HTTP 处理 25s。教师半可信、且限于自身请求,Minor。
---
## 试过但没打穿(覆盖面界定)
- **ATTACH 写宿主文件**query & modify & 受信 init 三种上下文):`ATTACH DATABASE '/tmp/…/evil.db' AS evil; CREATE TABLE evil.x…` → `unable to open database`,宿主上**无文件生成**。WASMsql.js)默认 MEMFS,无 NODEFS 绑定,结构性够不到宿主 FS。
- **ATTACH 读已存在的宿主 sqlite 文件**(modify 模式,目标是真实 `secret.db`):同样 `unable to open database`,读不到。
- **受信脚本(出题人 init)里的 ATTACH**:预览接口同样 `unable to open database`。旧实现靠 `_trusted_authorizer` 拒 ATTACH 且跑在有宿主 FS 的 Django 进程里;新实现结构性隔离,**更强**。
- **超大单值撑爆内存**`hex(zeroblob(3e8))` → 被 512MB `ulimit -d` / WASM 堆拦,MEMORY_LIMIT_EXCEEDED,未 OOM 宿主。
- **结果集撑爆**`ROW_LIMIT=10000` 在 `step()` 循环内计数,超限即 MLE`engine.ts:136-138`)。
- **硬编码常量答案**(跨数据不同的 2 测试点):WRONG_ANSWER。
- **多条语句的循环/超时**:语句之间的 `Date.now() > deadline``engine.ts:126`)能在 ~timeLimit 处以 `interrupted` → TLE 拦下(仅**单条**长语句拦不到,见 I-2)。
- **子进程 `finish()` 的 SIGKILL 自杀导致结果丢失/截断**:未复现。`child.ts:47-51` 用 `writeSync(1,…)` 循环写满全部字节再 `SIGKILL`;父进程 `index.ts:49-52` 并发 `new Response(child.stdout).text()` 持续排空管道。判题结果 payload 仅数百字节,全部实测被完整解析。理论残余风险:若 stdout payload 超过管道缓冲(64KB)且父进程未及时排空、fd 1 为非阻塞,`writeSync` 可能 EAGAIN 抛错丢输出——但当前 payload 体量下不触发,且父进程始终并发排空,判定低风险。设计中"跳过 Bun teardown 避免 `ulimit` 下 SIGILL panic 误判超时"的推理合理。
- **空 stdout → 超时误判**:父进程 `index.ts:58-66` 在子进程被 SIGKILLstdout 空)时按 `@phase` 标记区分:卡在 display 报 SYSTEM_ERROR,卡在 judge 报 CPU_TIME_LIMIT_EXCEEDED。递归 CTE 死循环实测正确落到 TLE。
## 备注
- 未发现真正的沙箱逃逸(文件/进程/宿主)或能让错误答案判 Accepted 的作弊路径。最接近"严重"的是 I-2 的判题池拒绝服务(可用性),其次 I-1 的只读破防(安全控制失效但危害受限)。
- 环境已还原:题目 id=43 及其 5 条提交、`problem_tags` 关联、两个测试点目录(`d6sps…`、`v3h9…`)已删;`data/test_case/` 仅剩原有 `79343208b704f6e75b4b6d885285280e``student` 已改回 Regular User / None;无残留 `child.ts`/攻击进程。未改动任何 OJ2 / OnlineJudge / ojnext 源码(两旧仓 `git status` 干净)。
-597
View File
@@ -1,597 +0,0 @@
# 阶段 5:切换手册与演练报告
演练日期:2026-08-08
演练用快照:`db_backup_2026_08_07_10_39_19.sql`pg_dumpall 集群备份,230MB2026-08-07 10:39
演练方式:本机 Docker,用 `docker/compose.debian.yml` **本身**跑,不是简化版。
真实数据量:**1710 用户 / 956 题 / 123140 提交 / 64 比赛 / 16 题单 / 38 成就**。
---
## 一、演练结论
### 出口标准:30 分钟内完成,回滚路径已验证
| 项 | 实测 |
|---|---|
| 停旧栈 | 11s |
| 起新栈(镜像已构建) | 34s;再次 start 6s |
| 数据迁移 | **0,不需要** —— 见下 |
| 全链路验证 | 约 2 分钟 |
| **合计** | **不到 1 分钟的停机**,远在 30 分钟内 |
真正的时间风险不在切换本身,而在**构建镜像**:首次构建约 5 分钟(依赖下载占大头)。
**镜像必须在停机窗口之前就构建好**,见下面的检查清单。
### 两个原本以为要做、实测不需要做的事
**1. 切换本身不需要执行任何 DDL。** 把生产 dump 的表结构和新后端在用的库逐列对比:
两边都是 **278 列,完全一致**,没有新增、没有删除、没有新表。新后端直接跑在现有结构上。
> 后来追加了一个**性能索引**(提交列表默认视图从全表扫变成索引扫),并且改用
> drizzle migration 管理,见 `apps/api/src/db/0001_add_submission_public_create_time_idx.sql`。
> 它**不属于切换流程**,切换前后任何时候单独跑一次即可,跑早点更好,
> **别塞进停机窗口**(12.3 万行实测建索引 74ms,但没必要占用窗口时间)。
>
> 对生产库第一次执行前,**必须先手插 `__drizzle_migrations` 的基线行**,否则 migrate
> 会从 `0000` 的完整建表跑起、撞表回滚,而且**失败时不打印任何错误**。
> 基线 SQL 和完整说明见 `CLAUDE.md` 的「改 schema 走 drizzle migration」。执行:
>
> ```bash
> # 1) 先插基线行(见 CLAUDE.md
> # 2) 再跑
> DATABASE_URL=<生产库> bun run db:migrate
> ```
>
> 这会在生产库里新建一个 `drizzle` schema 和 `__drizzle_migrations` 表。
**2. 不需要重置序列。** 我在 `phase3-coverage.md` 里记过一条「切换必做:重置各表 sequence」,
**那条是错的** —— 它来自我手工造的本地库(用显式 id 导入、没有 setval)。真实的
pg_dumpall 备份带 30 条 `setval`,而且直接查生产快照里所有序列 vs `max(id)`
**错位 0 个**。切换当天不用管序列。
### 回滚保证(已实测)
> ⚠️ **2026-08-26:本节已作废,只作历史记录。** 旧 Django 后端确认不再使用,
> `0002_drop_django_leftovers` 删掉了它的 7 张框架表,**该迁移已在生产库执行完毕**。
> 旧栈现在起不来了:本节和第七节描述的「停新栈起旧栈」「把 NPM 上游改回 8080」
> 全部失效,**唯一退路是从数据库备份恢复**。
新栈跑完登录、提交、判题之后,再和生产 dump 的结构比一次:**逐列一致,零差异**。
新后端不会给旧后端留下任何它不认识的东西。
加上数据目录布局照抄旧后端(`test_case``public/upload``public/avatar` 原样不动),
**回滚 = 停新栈 + 起旧栈,约 20 秒,不涉及任何数据操作。**
> 未实测的部分:本机没有构建旧后端的 Django 镜像,所以「起旧栈」这一步本身没跑过。
> 但旧栈在服务器上一直是跑着的,它的镜像和 compose 都没动过。
> **⚠️ 2026-08-16 补:上面这句「零差异」只成立在表结构层面,不成立在语义层面。**
>
> 新后端原本会在登录成功时把 Django 的 pbkdf2 哈希升级成 argon2id 写回 `user.password`。
> 升级过的账号**旧后端再也验不了**(格式不同,而且旧后端连 argon2-cffi 都没装),
> 于是「登录过新站的学生,回滚之后登不上旧站」—— 一道结构比对发现不了的单向门。
> 试跑第一天就撞上了,现象是旧站登录失败 + WS 连不上(Channels 认 session)。
>
> 当时的修法是加 `PASSWORD_HASH_UPGRADE` 开关、默认关闭。
>
> **⚠️ 2026-08-26 更新:开关已经删掉,现在无条件写 argon2。**
>
> 两个原因。**一是那个开关一直是假的**:新后端写密码的地方有五个(登录时自动
> 升级、注册、管理员改密码、批量导入用户、**重置密码**),开关只管住了第一处,
> 后面四处照写 argon2 不误 —— 也就是说开关关得好好的,老师给学生点一次
> 「重置密码」,那个账号照样回不去旧站,而这正是老师天天在用的功能。
>
> **二是旧站已经下线**,没有回滚路径要照顾了。五处现在统一走
> `auth/password.ts` 的 `hashPassword()`,只有一个地方写密码。
>
> 真要把旧站拉回来,正经退路是下面「万一已经改坏了」那节的脚本 —— 它拿
> `raw_password` 重算 Django 的 `make_password`,能修**已经**变成 argon2 的账号。
> 开关只能拦住将来,修不了已经发生的,这也是它不值得留的原因。
>
> `verifyPassword` 的 pbkdf2 分支**永远保留**:生产库 1710 个账号全是 Django 写的
> pbkdf2(迭代次数 1200001200000),它们只会在各自下次登录时才迁移成 argon2。
---
## 二、演练验证了什么
全部通过真实生产数据、经 Caddy、在容器里跑:
| 检查项 | 结果 |
|---|---|
| 首页 SPA | 200Caddy 从镜像里伺服 |
| 站点配置 `/api/site` | 200,读出真实站点名「判题狗」 |
| 题目列表 | 200,首条是 `SQL09` |
| 题目标签 / 公告 | 200 |
| 未登录访问后台 | 401 `login-required` |
| 登录 | 200argon2 新哈希 + Django pbkdf2 旧哈希都支持) |
| 个人主页 / 排行榜 | 200,真实学生数据 |
| 后台首页 / 题目 / 判题机 | 200,`userCount: 1711` |
| **判题机心跳** | 判题容器自行注册成功(新路径 `/api/judge-server/heartbeat` |
| **完整判题** | 提交 Python A+B → **AC1.2 秒**,两个测试点全过 |
### WebSocket 实时推送(经 Caddy
学生盯着「判题中…」变成结果就靠这条路,而它经过 Caddy 的 `handle /ws/*`
是配置最容易写错、又只在生产才暴露的一段。单独验过:
```
WS 经 Caddy upgrade: 已连接
收到 2 条推送,301ms
→ {"type":"submission_update","result":7,"status":"judging"}
→ {"type":"submission_update","result":0,"status":"finished","time_cost":4,...}
```
### 机房那套(`compose.school.yml`
这套配置和服务器那套差别不小(没有 postgres、连远程库、端口 81、
`COOKIE_SECURE=false`),单独跑过一遍:留下 `oj-postgres` 当「远程库」,
其余容器换成 school 栈,`DB_HOST` 指向宿主机 IP。
| 检查项 | 结果 |
|---|---|
| 连上「远程」库 | oj-api healthy,日志零错误 |
| 首页 / 题目列表 | 200,数据来自远程库 |
| 登录 | 200 |
| **Cookie 没带 Secure** | ✓ —— 带了的话机房(http 直连 IP)会「登录成功又立刻变未登录」 |
| WS + 完整判题 | 连接成功,300ms 内 judging → finishedAC |
也就是说机房用的是**本地 Redis + 本地判题沙箱 + 远程库**,判题不跨公网,
只有数据库查询走公网。
---
## 三、切换前准备(停机窗口之前做完)
### ⚠️ 演练没暴露的一个坑:两套 compose 的数据目录不是同一个地方
演练时是把生产 dump 恢复进 `OJ2/data/postgres` 的(见第十节),所以这件事被盖住了。
真实部署目录 `/root/OJDeploy` 的实际情况:
| | 旧栈(`docker-compose.yml` | 新栈默认值 |
|---|---|---|
| 库 | `/root/OJDeploy/data/postgres` | `/root/OJDeploy/OJ2/data/postgres` |
| 测试点 / 上传 / 头像 | `/root/OJDeploy/data/backend/` | `/root/OJDeploy/OJ2/data/backend/` |
新 compose 在 `OJ2/docker/` 下,`../data` 解析到的是 `OJ2/data`。**照默认值切过去,
postgres 会在一个空目录上初始化一个全新的空库** —— 站点能起来,但没有用户、没有题、
判题全挂、题面图片 404。旧数据完好无损(回滚正常),但当天会白吓一场。
因此 `compose.debian.yml` / `compose.school.yml` 加了 `DATA_DIR` 等三组变量,
**下面的流程按「只换前后端」形态写**:旧栈的 postgres / redis 容器继续跑,
新栈只起 api / worker / web / judge。数据库进程根本不重启,库和文件一个字节都不用挪。
### 已经做掉的(2026-08-16
- **`docker/.env.school` 已写好**`.gitignore` 排除,不进版本库):判题 token 新生成一条、
`JUDGE_CONCURRENCY=4``DB_HOST=150.158.29.156``DB_PORT=5445``COOKIE_SECURE=false`
`docker compose config` 验过插值正确。**还差两个值**,见下面第 1 步。
- **构建复验通过。** 演练之后又改过两次前端(`/api2` 前缀漏改 5 处、接回配置推送与
MaxKB),在本机重新构建:`oj2-web` 75MB 构建通过、单起容器首页 200;`oj2-api`
完全命中缓存,说明后端源码在演练之后没动过。
这只证明「还能构建出来」—— 镜像不走镜像仓库,是各站点本地构建的,
**服务器和机房当地各自还要 build 一次**
- **「只换前后端」形态本机实跑验过。** 用 `docs/specs/schema.sql` 起了一个发布在宿主机
5445 的 postgres 冒充旧栈,新栈按下面的 env 起来:4 个容器(没有 postgres / redis)、
`oj-api` healthy、首页与 `/api/site` `/api/problems` 200、未登录进后台 401。
读写两个方向都验了 —— 那个库的 `pg_stat_activity` 里有一条来自 172.17.0.1 的
`postgres.js` 连接,`judge_server` 表里也出现了新判题机写进去的心跳行。
### 1. 填两份 env(各站一份,都不进版本库)
服务器 `docker/.env`(照 `docker/.env.example` 拷一份再填):
| 变量 | 填什么 |
|---|---|
| `POSTGRES_PASSWORD` | **生产库现有的口令**。库是原地不动的、不是新建的,这个值改不了(旧 compose 里是 `onlinejudge` |
| `DATA_DIR` | `/root/OJDeploy/data` —— **旧数据目录的绝对路径**。不填就是上面那个空数据坑 |
| `DB_HOST` / `DB_PORT` | `host.docker.internal` / `5445` —— 旧 postgres 已经把 5445 发布在宿主机上,走 host-gateway 过去,不出本机 |
| `REDIS_HOST` / `REDIS_PORT` | `host.docker.internal` / `5446` —— 同理,旧 redis 发布的是 5446 |
| `OJ2_JUDGE_TOKEN` | 可以换新的,`openssl rand -hex 32`;后端和判题机读同一个变量,一起换即可 |
| `JUDGE_CONCURRENCY` | 服务器 `2` |
| `AI_KEY` | 服务器那把 DeepSeek key |
机房 `docker/.env.school`:已写好,只差三个 ——
| 变量 | 填什么 |
|---|---|
| `POSTGRES_PASSWORD` | 同上,**和服务器那份一模一样**(连的是同一个库) |
| `DATA_DIR` | 机房那台的旧数据目录绝对路径。**机房也有测试点和上传文件**,同样不能用默认值 |
| `AI_KEY` | 机房那把,**和服务器不是同一把,别填串** |
漏填 `POSTGRES_PASSWORD` 不会静默起一个坏服务:compose 里写的是 `${POSTGRES_PASSWORD:?}`
没填直接报错退出。**但 `DATA_DIR` 漏填不会报错**,它有默认值 —— 这条只能靠人盯。
> 想让新栈自带 postgres / redis(本机开发、或将来旧栈彻底拆掉):不设 `DB_HOST`
> `REDIS_HOST` `DATA_DIR`,起栈时加 `--profile local-data` 即可。
### 2. 先把镜像构建好(**别在停机窗口里干这件事**)
服务器:
```bash
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env build
```
机房:
```bash
docker compose -f OJ2/docker/compose.school.yml --env-file OJ2/docker/.env.school build
```
首次约 5 分钟,依赖下载占大头。构建完确认两个镜像都在:
```bash
docker images | grep oj2-
# oj2-api latest 487MB
# oj2-web latest 75MB
```
### 3. 兜底备份
```bash
docker exec oj-postgres pg_dumpall -U onlinejudge > ~/oj-before-cutover-$(date +%F).sql
```
**不是为了迁移**(不需要 DDL、不需要迁数据),是为了出事有退路。真要用它重建,
先读第八节第 1 条 —— 这份备份会把角色口令覆盖回备份当时的值。
### 4. 确认 `DATA_DIR` 指对了
```bash
ls /root/OJDeploy/data/backend/test_case \
/root/OJDeploy/data/backend/public/upload \
/root/OJDeploy/data/backend/public/avatar
```
判题测试点、题面图片、头像都在这三个目录里。**这个路径必须和 env 里的 `DATA_DIR` 一致。**
再用 compose 自己确认一遍解析结果,别靠脑补:
```bash
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env config | grep source:
# 每一条都应该在 /root/OJDeploy/data 下,出现 OJ2/data 就是 DATA_DIR 没生效
```
### 5. 出发前对一遍
- [ ] 两份 env 都填完了,两边的 `POSTGRES_PASSWORD` 一致
- [ ] **两边的 `DATA_DIR` 都指向旧数据目录**`config | grep source:` 核对过
- [ ] 两边镜像都构建完了
- [ ] 备份做了
- [ ] 决定好走哪条路:先并行试跑(第四节,推荐),还是直接切(第五节)
## 四、并行试跑(oj2.xuyue.cc
正式切换之前,先让新栈挂在一个独立域名上跑几天,**旧站原样不动、一个容器都不用停**。
这是双跑 —— 设计文档原本明确否掉过(「不双跑不灰度」)。改主意的理由是:
「只换前后端」形态下新栈本来就不碰数据库进程,双跑的增量风险只剩「两个后端同时写同一个库」
这一条;而换来的是**正式切换退化成改一行 NPM 上游**,反而比停机切换更稳。
代价见下面「试跑期间要知道的」,不是零。
### 1. env 比「只换前后端」多两个
```
WEB_PORT=8090
JUDGE_STATE_DIR=/root/OJDeploy/data/judge_server_oj2
```
`WEB_PORT` 是因为 8080 还被旧 backend 占着。`JUDGE_STATE_DIR` 是因为**两个判题机不能
共用运行目录** —— 不设的话新旧两个 judger 会同时往 `data/judge_server/{run,log}` 里写。
`test_case``public/upload` 仍然共享,**那是故意的**:测试点和题面图片两边必须看到同一份。
### 2. 起
有脚本,**在服务器上**跑:
```bash
cd /root/OJDeploy/OJ2
docker/deploy.sh # 自检 → 构建 → 起栈 → 冒烟
docker/deploy.sh --check # 只自检,只读,不动任何容器
docker/deploy.sh --no-build # 只改了 env / compose 时跳过构建
```
代码怎么上到服务器不归它管(rsync 或以后的 `git pull`,命令在脚本头部注释里)。
起栈前有五道自检,前两道正是这次在服务器上真撞到的:
| 自检 | 拦什么 |
|---|---|
| compose 版本 | `depends_on.required` 要 ≥ 2.20,老版本解析就会失败 |
| `DATA_DIR` | 卷指向 `OJ2/data` → 中止(空数据,静默) |
| `DB_HOST` | `DATABASE_URL` 还指着 `oj-postgres` → 中止(试跑形态下它不存在) |
| `JUDGE_STATE_DIR` | 没设的话新旧两个判题机共用运行目录 |
| 旧栈 | `oj-postgres` / `oj-redis` 得还活着,新栈连的就是它们 |
起完等 `oj-api` healthy,再跑四条冒烟;题目数是 0 也中止 —— 那意味着连错库了。
手动等价于:
```bash
mkdir -p /root/OJDeploy/data/judge_server_oj2/log /root/OJDeploy/data/judge_server_oj2/run
cd /root/OJDeploy
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env up -d
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8180/ # 期望 200
```
旧站这时候完全没受影响,8080 上照常服务。
### 3. NPM 加一台 proxy host
| 项 | 值 |
|---|---|
| Domain | `oj2.xuyue.cc`(先把 DNS 解析加上) |
| Forward | `<宿主机 IP>` : `8090` |
| **Websockets Support** | **必须打开** |
| SSL | 签一张证书,`COOKIE_SECURE=true` 依赖 https |
| client_max_body_size | `200M`,和 Caddyfile 里的 `200MiB` 对齐(上传测试用例压缩包) |
WebSocket 那个开关是双跑最容易漏的一格:漏了的话页面一切正常,唯独学生盯着的
「判题中…」永远不动 —— 而这恰恰是最不容易在自测里发现的一条,因为刷新一下结果就出来了。
### 4. 试跑期间要知道的
- **两边登录态不互通**。旧站是 Django session,新站是 Redis opaque token。
学生到 oj2 要重新登录一次,这不是 bug。
- **在 oj2 登录过、注册的、被改过或重置过密码的账号,哈希会变成 argon2,
回旧站就登不上了** —— 试跑第一天真撞过,现象是旧站登录失败 + WS 连不上。
2026-08-26 起这是无条件行为(`PASSWORD_HASH_UPGRADE` 开关已删,见上面那条
补充说明为什么它本来就拦不住)。修复见下面的「万一已经改坏了」。
- **同一个库,双写**。结构完全兼容不会写坏,但提交、统计、成就都是**真实数据**,
不是沙盒。别拿它做破坏性试验。
- 后台判题机列表会出现**两台**(新旧各自心跳),正常。
- 比赛排名等缓存两边各存各的 Redis,可能短暂不一致。
**试跑期间不要在 oj2 上办正式比赛。**
### 5. 试跑要盯的是这些
都是只有真实数据 + 真实浏览器才暴露的:
- 机房那种 **Chrome < 94** 打开正常(`mermaid-legacy` 这条 fallback 只在老浏览器上生效)
- 带图片的题面(`/public/upload/*` 走的是反代,不是 Caddy 直接读盘)
- AI 分析的 **SSE 流式输出**经过 NPM 之后还是不是逐块下发(这一层最容易被缓冲住)
- WebSocket 在 NPM 后面长时间挂着稳不稳(超时断连会不会自动重连)
- 后台**上传测试用例压缩包**这条 200MB 的路
- 老师日常用的后台:出题、建比赛、题单
## 五、正式切换
**两个站点必须同一天切。** 机房那套连的是服务器的库,只切一边的话,另一边的旧后端
还在读写同一个库。
### 试跑过了 —— 那就只是改一行上游
新栈已经在 8090 上跑着、验过了,切换不需要重启任何容器:
1. NPM 里把 `xuyue.cc` 那台 proxy host 的上游从 `8080` 改成 `8090`**Websockets Support 同样要开**
2. 看一眼首页和一次提交,正常
3. 确认没问题之后再停旧栈:`docker compose -f docker-compose.yml stop oj-backend oj-judge`
**停机时间约等于零**,回滚就是把 NPM 那一行改回 `8080` —— 旧栈这时候都还没停。
这正是设计文档最初写的那个回滚故事:「改一行上游」。
`oj2.xuyue.cc` 可以留着,它和 `xuyue.cc` 指向同一个新栈,没有坏处。
### 没试跑、直接切 —— 走下面这套
服务器(`/root/OJDeploy`):
```bash
cd /root/OJDeploy
# 只停应用,postgres / redis 留着继续跑 —— 新栈接着用它们
docker compose -f docker-compose.yml stop oj-backend oj-judge
docker compose -f docker-compose.yml ps # 确认 oj-postgres / oj-redis 还在 Up
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env up -d
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env ps
# 应该正好 4 个:oj-api / oj-worker / oj-web / oj-judge,等 oj-api 变 healthy
```
旧判题机必须一起停:它和新判题机会争同一个 `data/judge_server/run`
旧 backend 也必须停:8080 端口要交给 `oj-web`
机房:
```bash
cd <机房部署目录>
docker compose -f docker-compose.yml stop oj-backend oj-judge
docker compose -f OJ2/docker/compose.school.yml --env-file OJ2/docker/.env.school up -d
docker compose -f OJ2/docker/compose.school.yml --env-file OJ2/docker/.env.school ps
```
(机房本来就没有 postgres;它的 redis 是新栈自带的,和旧 redis 不冲突 ——
旧的那个没往宿主机发布端口。)
起不来先看这两条日志,绝大多数问题在里面直说了:
```bash
docker logs oj-api --tail 50 # 连不上库、token 不对都在这里
docker logs oj-judge --tail 20 # 判题机注册不上看这条
```
## 六、切换后验证
这一节试跑刚起来时也照着跑一遍,只是端口换成试跑用的(`WEB_PORT`,例如 8090)。
先用命令快速过一遍(服务器 8080,机房 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
```
前三条 200、第四条 401 才算过。两个失败模式各有各的症状,别搞混:
- **题目列表 `"total":0`** → 连错库了(`DB_HOST` 没设,或误加了 `--profile local-data`
起了个自带的空 postgres)。立刻停下来查,别往下走。
- **库是对的,但判题全错、题面图片 404** → `DATA_DIR` 指错了,挂上去一堆空目录。
然后照着点一遍 —— 下面这几步是命令测不到的:
1. 打开首页,能看到题目列表
2. 用一个学生账号登录,看得到自己的提交历史
3. 提交一道题,**看判题结果是否实时刷出来**(这一步同时验证了 WebSocket
4. 后台 → 判题机列表,确认判题机在线(心跳走 `/api/judge-server/heartbeat`
5. 后台 → 题目列表能翻页
6. 题面里带图片的题,图片能显示(`/public/upload/*`
机房那边额外确认一条:**登录之后刷新页面还是登录态**。如果「登录成功又立刻变未登录」,
就是 `COOKIE_SECURE` 没设成 false。
## 七、回滚
> ⚠️ **执行 `0002_drop_django_leftovers` 之后本节作废**,理由见上面「回滚保证」一节:
> 旧 Django 后端的表被删除后旧栈起不来,退路只剩「从备份恢复数据库」,不是秒级操作。
> 下面内容保留作历史记录。
**试跑之后切的**(推荐路径):NPM 里把 `xuyue.cc` 的上游从 `8090` 改回 `8080`
旧栈这时候还跑着,**秒级生效,什么都不用停不用起**。等确认稳定了再决定何时收掉新栈。
**直接切的**
```bash
cd /root/OJDeploy
docker compose -f OJ2/docker/compose.debian.yml --env-file OJ2/docker/.env down
docker compose -f docker-compose.yml start oj-backend oj-judge
```
机房同理,换成 `compose.school.yml`
约 20 秒,比演练时还快一点 —— **postgres / redis 全程没停过**,回滚只是把旧的
backend 和判题机再 start 起来。**不需要恢复数据库,不需要动任何文件。**
这也是「只换前后端」形态的主要好处:切换和回滚都不碰数据库进程,
库出问题的可能性从流程里被整个拿掉了。
### ⚠️ 回滚要额外处理密码
「不动任何数据」不适用于 `user.password` 这一列。在新站登录过、注册的、被老师
改过或重置过密码的账号,哈希已经是 argon2,旧后端验不了 —— 回滚之后那些学生
登不上。(2026-08-26 起这是无条件行为,`PASSWORD_HASH_UPGRADE` 开关已删。)
所以回滚流程里**必须**加一步:按下面那节把 argon2 的账号用 `raw_password`
重算回 Django 的 pbkdf2。
### 万一已经改坏了
先看范围(`$argon2` 开头的就是被改过的):
```bash
docker exec oj-postgres psql -U onlinejudge -d onlinejudge -c \
"select id, username, raw_password is not null as 有明文 from \"user\" where password like '\$argon2%'"
```
用**旧后端自己**改回 pbkdf2 —— `raw_password` 那个明文列(老师查学生密码用的)
正好派上用场:
```bash
docker exec -i <旧 backend 容器> python manage.py shell <<'PYEOF'
from django.contrib.auth.hashers import make_password
from account.models import User
for u in User.objects.filter(password__startswith='$argon2'):
if u.raw_password:
u.password = make_password(u.raw_password)
u.save(update_fields=['password'])
print('已修复', u.username)
else:
print('没有明文密码,需要手动改:', u.username)
PYEOF
```
没有 `raw_password` 的(多半是超管账号)用 `python manage.py changepassword <用户名>`
改回 pbkdf2 之后两边都认:新后端本来就支持 Django 的 pbkdf2。
---
## 八、演练中踩到的坑(写下来是因为它们只在容器里出现)
### 1. pg_dumpall 备份会覆盖数据库口令 ⚠️
演练时恢复完快照,新后端立刻报:
```
PostgresError: password authentication failed for user "onlinejudge"
```
原因:`pg_dumpall` 的集群备份里带
```sql
ALTER ROLE onlinejudge WITH SUPERUSER ... PASSWORD 'md5……';
```
**恢复这份备份,会把角色口令覆盖成备份时生产的那个口令**compose 里的
`POSTGRES_PASSWORD` 就对不上了。
- 正常切换:**不受影响**,因为根本不恢复备份。
- 灾难恢复(真要从备份重建):恢复之后要么把 `POSTGRES_PASSWORD` 设成生产的口令,
要么恢复后手动 `ALTER ROLE onlinejudge PASSWORD '<新口令>'`
这一条不写下来,恢复现场会被一个看起来毫不相干的报错卡住。
(另记:该哈希是 md5PG16 默认已是 scram-sha-256。不影响切换,但值得择日换掉。)
### 2. 恢复备份前必须先停应用
应用连着库时,dump 里的 `DROP DATABASE` 会失败:
```
ERROR: database "onlinejudge" is being accessed by other users
```
演练时因为目标库本来是空的,数据照样灌进去了 —— 那是运气。目标库有数据的话,
接下来就是满屏主键冲突。灾难恢复流程:**先停 oj-api / oj-worker,再恢复。**
### 3. 「本地能过、容器里过不了」的四个坑(构建期)
都已修好并写进 Dockerfile 的注释,这里只留索引:
- 构建上下文吸进 `data/`,判题沙箱用别的 uid 建的目录 docker 连 stat 都做不了 → `.dockerignore`
- `mermaid@9.4.3`(机房老 Chrome 的 legacy 依赖)从容器里连 npmjs 稳定失败 → 换 npmmirror + 重试
- 容器里 bun 用 isolated 布局、本地是扁平的,靠「提升」解析的包在容器里一律找不到
→ 把真正直接 import 的 4 个包补成直接依赖
- **`apt-get update` 在服务器上卡死**(2026-08-16 试跑时撞上,本机构建从来没事)
→ 换清华源。两个细节:trixie 的源是 deb822 格式、在
`/etc/apt/sources.list.d/debian.sources`(老的 `sources.list` 在这个基底里是空文件);
**只能换主机名、必须保持 http** —— ca-certificates 正是这一步要装的,
换成 https 会在没有根证书的情况下证书校验失败。`ARG APT_MIRROR` 可覆盖。
---
## 九、镜像体积(没达到设计文档的预期,说明原因)
| 镜像 | 体积 |
|---|---|
| `oj2-api` | **487MB** |
| `oj2-web` | 75MB |
设计文档写的是「降至数十 MB」,**没做到**。拆开看:
| 层 | 体积 |
|---|---|
| `clang-format`apt | **269MB**,其中 `libLLVM.so.19.1` 一个 124MB |
| `oj2-api` 二进制 | 112MBBun 运行时 + 内嵌的 wasm/原生模块/4.8MB 词典) |
| `ruff` | 28MB |
| debian-trixie-slim 基底 | 79MB |
也就是说**一半以上是 clang-format 拖进来的 LLVM**。旧的 Python 镜像同样装了
clang-format,再加整个 Python 运行时和 Django 依赖,所以新镜像仍然明显更小,
但「数十 MB」是当初没把 clang-format 算进去。
想再瘦下来只有一条路:换成静态链接的 clang-format 独立二进制(PyPI 的
`clang-format` wheel 里就是),能砍掉约 265MB。没做,因为镜像是各站点本地构建的、
不走镜像仓库,磁盘不是瓶颈;等哪天真嫌大了再说。
---
## 十、演练产生的临时数据(已清理)
演练在 `OJ2/data/postgres` 下留下了**一份完整的生产数据副本**,其中包含 1710 名
学生的 `raw_password` 明文列。演练结束后已删除该目录。
以后再演练记得同样处理 —— 那不是测试数据,是真实学生数据。
-19
View File
@@ -1,19 +0,0 @@
-- 阶段 1 本地题目样本导入脚本。
--
-- /tmp/problems.csv 从生产库 problem 表导出,只取 contest_id IS NULL、按 id
-- 排序的前 20 道题,避免为本地列表验收额外复制比赛数据。
-- /tmp/tags.csv 从生产库 problem_tag 表导出。两份 CSV 均不包含用户表数据,
-- 仅保留在本机并由 .gitignore 排除;devadmin 只是满足外键的本地占位用户。
\set ON_ERROR_STOP on
BEGIN;
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
COMMIT;
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +0,0 @@
import { Language, Parser } from "web-tree-sitter"
// 复刻 ast_checker 的 C_MAPPING 片段
const C_MAPPING: Record<string, string> = {
for_loop: "for_statement",
while_loop: "while_statement",
function_definition: "function_definition",
include: "preproc_include",
}
await Parser.init()
const parser = new Parser()
const cLang = await Language.load("./node_modules/tree-sitter-c/tree-sitter-c.wasm")
parser.setLanguage(cLang)
const code = `#include <stdio.h>
int main() {
int sum = 0;
for (int i = 1; i <= 100; i++) {
sum += i;
}
printf("%d\\n", sum);
return 0;
}`
const t0 = performance.now()
const tree = parser.parse(code)!
const parseMs = performance.now() - t0
// 数各类节点出现次数(等价于 ast_checker 的 engine.check 遍历)
const counts: Record<string, number> = {}
const walk = (n: any) => {
counts[n.type] = (counts[n.type] ?? 0) + 1
for (let i = 0; i < n.childCount; i++) walk(n.child(i))
}
walk(tree.rootNode)
console.log("解析耗时:", parseMs.toFixed(2), "ms")
for (const [label, tsType] of Object.entries(C_MAPPING)) {
console.log(` 规则 ${label.padEnd(20)} -> ${tsType.padEnd(20)} 命中 ${counts[tsType] ?? 0}`)
}
// Python grammar 也验一下
const pyParser = new Parser()
pyParser.setLanguage(await Language.load("./node_modules/tree-sitter-python/tree-sitter-python.wasm"))
const pyTree = pyParser.parse("for i in range(10):\n print(i)")!
console.log("\nPython 根节点:", pyTree.rootNode.type, "| 首个子节点:", pyTree.rootNode.child(0)?.type)
-105
View File
@@ -1,105 +0,0 @@
#!/usr/bin/env bun
// 把 docs/specs/schema.sql(生产库的 pg_dump --schema-only)转成可执行的基线迁移
// apps/api/src/db/0000_crazy_gateway.sql。
//
// 这是**一次性**的转换,产物已经入库。留着它是为了说清 0000 的出处、以及日后万一要
// 从一份新的生产 dump 重做基线时不用从头想一遍规则。日常改 schema 不要碰这里,
// 走 `bun run db:generate`。
//
// 跑法(仓库根):
// bun docs/spikes/build-baseline-migration.ts
//
// 产物是确定性的:同一份 schema.sql 跑出来的字节完全一样,改完可以直接 git diff 看。
import { readFileSync, writeFileSync } from "node:fs"
const SOURCE = "docs/specs/schema.sql"
const TARGET = "apps/api/src/db/0000_crazy_gateway.sql"
// drizzle 的语句分隔标记。故意不写成字面量常量之外的形式:`readMigrationFiles` 是纯文本
// 切分,这个串出现在哪里都会切,所以生成出来的文件的**注释里**绝不能带上它。
const BREAKPOINT = "--> statement-breakpoint"
// 判断一条语句是不是 Django 遗留物:只看它引用了哪些**对象**(`public.X` 形式),
// 不看语句里有没有出现这些字样。
//
// 踩过的坑:一开始扫整条语句里的 `auth_` / `django_` 字样,结果把 `user` 表整个滤掉了 ——
// 它有一列叫 `auth_token`。列名不带 `public.` 前缀,按对象引用来判就不会误伤。
//
// 覆盖到的形式:CREATE TABLE/SEQUENCE public.X、CREATE INDEX ... ON public.X、
// ALTER TABLE [ONLY] public.X、ALTER SEQUENCE public.X OWNED BY public.Y.id、
// 以及外键里的 REFERENCES public.Y —— 对象名全都跟在 `public.` 后面。
const OBJECT_REF = /public\."?([a-z_]+)"?/g
const DJANGO_PREFIX = ["auth_", "django_"]
function isDjango(stmt: string) {
return [...stmt.matchAll(OBJECT_REF)].some(([, name]) => DJANGO_PREFIX.some((p) => name.startsWith(p)))
}
const HEADER = `-- OJ2 的基线迁移:把一个空库建成新后端要的结构。
--
-- **** \`drizzle-kit generate\` 的产物,也不该由它重新生成。原本这里是
-- \`drizzle-kit pull\` 吐出来的东西,整份被 /* */ 包着、一条可执行语句都没有,于是
-- psql schema.sql
--
-- \`docs/specs/schema.sql\`(生产库 2026-08-07 的 pg_dump --schema-only
-- psql \\restrict / SET / set_config 7 Django
-- drizzle
-- \`docs/spikes/build-baseline-migration.ts\`
--
-- \`readMigrationFiles\` 不管它出现在哪里 —— 写进注释里
--
-- syntax error at or near "。"
--
-- Django 7 \`meta/0000_snapshot.json\` 从来就没有它们(pull 当时用
-- tablesFilter 0002 DROP IF EXISTS
--
--
-- ** schema ** \`bun run db:generate\` 生成新的迁移。
-- 0000 migrator created_at hash
--
`
const statements: string[] = []
let buffer: string[] = []
let droppedDjango = 0
let droppedNoise = 0
for (const line of readFileSync(SOURCE, "utf8").split("\n")) {
const trimmed = line.trim()
// 语句之外的行:空行、注释、psql 元命令(\restrict)直接跳过;
// SET / set_config 是 pg_dump 给自己用的会话设置,迁移里不需要。
if (buffer.length === 0) {
if (trimmed === "" || trimmed.startsWith("--") || trimmed.startsWith("\\")) continue
if (trimmed.startsWith("SET ") || trimmed.startsWith("SELECT pg_catalog.set_config")) {
droppedNoise++
continue
}
}
buffer.push(line)
// 按行尾分号断句。schema.sql 里句中出现分号的只有注释行,而注释行进不到这儿。
if (!trimmed.endsWith(";")) continue
const stmt = buffer.join("\n").trim()
buffer = []
if (isDjango(stmt)) {
droppedDjango++
continue
}
if (/^ALTER TABLE .* OWNER TO /.test(stmt) || stmt.startsWith("COMMENT ON")) {
droppedNoise++
continue
}
statements.push(stmt)
}
if (buffer.length > 0) throw new Error(`有没闭合的语句:${buffer[0]}`)
const body = statements.map((s) => s.replace(/;+$/, "")).join(`;\n${BREAKPOINT}\n`)
writeFileSync(TARGET, `${HEADER}\n${body};\n`)
console.log(`保留 ${statements.length} 条语句 | 滤掉 Django 相关 ${droppedDjango} 条、噪音 ${droppedNoise}`)
console.log(`已写入 ${TARGET}`)
-67
View File
@@ -1,67 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"dependencies": {
"@node-rs/jieba": "^2.0.1",
"tree-sitter-c": "^0.24.1",
"tree-sitter-python": "^0.25.0",
"web-tree-sitter": "^0.26.11",
},
},
},
"packages": {
"@emnapi/core": ["@emnapi/core@1.11.3", "https://registry.npmjs.com/@emnapi/core/-/core-1.11.3.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "https://registry.npmjs.com/@emnapi/runtime/-/runtime-1.11.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "https://registry.npmjs.com/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "https://registry.npmjs.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@node-rs/jieba": ["@node-rs/jieba@2.0.1", "https://registry.npmjs.com/@node-rs/jieba/-/jieba-2.0.1.tgz", { "optionalDependencies": { "@node-rs/jieba-android-arm-eabi": "2.0.1", "@node-rs/jieba-android-arm64": "2.0.1", "@node-rs/jieba-darwin-arm64": "2.0.1", "@node-rs/jieba-darwin-x64": "2.0.1", "@node-rs/jieba-freebsd-x64": "2.0.1", "@node-rs/jieba-linux-arm-gnueabihf": "2.0.1", "@node-rs/jieba-linux-arm64-gnu": "2.0.1", "@node-rs/jieba-linux-arm64-musl": "2.0.1", "@node-rs/jieba-linux-x64-gnu": "2.0.1", "@node-rs/jieba-linux-x64-musl": "2.0.1", "@node-rs/jieba-wasm32-wasi": "2.0.1", "@node-rs/jieba-win32-arm64-msvc": "2.0.1", "@node-rs/jieba-win32-ia32-msvc": "2.0.1", "@node-rs/jieba-win32-x64-msvc": "2.0.1" } }, "sha512-tnfzXOMqzVQF2dSKMhPC9HrHzzWmN6KheL/zYtGenhOpq/bCKHJWVASSggEnHlkmHgXGeIJHR2N/IuPzewz1BQ=="],
"@node-rs/jieba-android-arm-eabi": ["@node-rs/jieba-android-arm-eabi@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-android-arm-eabi/-/jieba-android-arm-eabi-2.0.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-tavsIaxybnlA9tRbJ+oc3NW3zhx0d5rNiCGdpIdGWjflwS7HyeUTVAZmAFDlg58Mc6EjTdVKZH+RolBbAJtgcQ=="],
"@node-rs/jieba-android-arm64": ["@node-rs/jieba-android-arm64@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-android-arm64/-/jieba-android-arm64-2.0.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-AwdyqKvVNuSDnDq3anUfq+nJ5J/kzXjkfbr/1WY6TfaAlTNuuGVskuQv72/wIx/jn7NoXfm/UPuJrWYG16NC6w=="],
"@node-rs/jieba-darwin-arm64": ["@node-rs/jieba-darwin-arm64@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-darwin-arm64/-/jieba-darwin-arm64-2.0.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-10+nwGQ6KzXXJlIL/sELA6Fi6m7eJ7xJksBiKuw1kxKUgaJwtVfAG0iqRF+NRQv0Sdq7r3k5ew9K9y0+IYaEcA=="],
"@node-rs/jieba-darwin-x64": ["@node-rs/jieba-darwin-x64@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-darwin-x64/-/jieba-darwin-x64-2.0.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-IJ5RK0X/uPQa1XRmTvwKSieya+w1IJeiKLw0EekoBFJKybXQdvo8/uqM/8z2eVJ8vQxW9X6K2vkVGFvYQa9dYA=="],
"@node-rs/jieba-freebsd-x64": ["@node-rs/jieba-freebsd-x64@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-freebsd-x64/-/jieba-freebsd-x64-2.0.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-yg7vyhqzP2weJu5DJ3q9q4pb0b4GWWRwcv54zK7MSSA6KNJ/uQv2a4R9/qmptLU/fZv14gWuJBEMFdL7y1Dv2w=="],
"@node-rs/jieba-linux-arm-gnueabihf": ["@node-rs/jieba-linux-arm-gnueabihf@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-linux-arm-gnueabihf/-/jieba-linux-arm-gnueabihf-2.0.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-fxQYunS7w2tv8XV9GigkWJPzHnbcw6tjrUdDu5/qU0FdQVEzGuEYG85DjlNf8lZTDGSUKHBVyAQs7bBIvq8yqg=="],
"@node-rs/jieba-linux-arm64-gnu": ["@node-rs/jieba-linux-arm64-gnu@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-linux-arm64-gnu/-/jieba-linux-arm64-gnu-2.0.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-VnLU630hQIyO/fwyxh2vqZi72mO+hXkVUC3jVLPfOAlppinmsGX9N81tpTPUK3840hbV8WLtbYTWN1XodI38eg=="],
"@node-rs/jieba-linux-arm64-musl": ["@node-rs/jieba-linux-arm64-musl@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-linux-arm64-musl/-/jieba-linux-arm64-musl-2.0.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-K4EDyNixSLVdTNYnHwD+7I/ytvzpo7tt+vdCLqwQViiek2PMpL/FFRvA39uU2tk99jXIxvkczdxARG20BRZppg=="],
"@node-rs/jieba-linux-x64-gnu": ["@node-rs/jieba-linux-x64-gnu@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-linux-x64-gnu/-/jieba-linux-x64-gnu-2.0.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-sq3J6L2ANTE25I9eVFq/nb57OtXcvUIeUD1CTKJxwgTKIVmcB2LyOZpWf20AjHRUfbMER9Klqg5dgyyO+Six+w=="],
"@node-rs/jieba-linux-x64-musl": ["@node-rs/jieba-linux-x64-musl@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-linux-x64-musl/-/jieba-linux-x64-musl-2.0.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-0zfP9Qy68yEXrhBFknfhF6WUJDPU/8eRuyIrkMGdMjfRpxhpSbr2fMfnsqhOQLvhuK4w3iDFvTy4t5d0s6JKMA=="],
"@node-rs/jieba-wasm32-wasi": ["@node-rs/jieba-wasm32-wasi@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-wasm32-wasi/-/jieba-wasm32-wasi-2.0.1.tgz", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.5" }, "cpu": "none" }, "sha512-7I5rJya5rlQNJIhv8PvPzIVT1/gVc0vFzHmlfRGwCPGDJ3tHVxkSPW34dDx3OgDmbIeadNpmgIyC1RaS9djPJg=="],
"@node-rs/jieba-win32-arm64-msvc": ["@node-rs/jieba-win32-arm64-msvc@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-win32-arm64-msvc/-/jieba-win32-arm64-msvc-2.0.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-Aj/2EwYSaPgAbKnSl+vKM/2kOaZNMZWnShiZzbSNyzlLy3eIOyOYVLbYRDno4547KngRxer8uzROhIQIwXwkvw=="],
"@node-rs/jieba-win32-ia32-msvc": ["@node-rs/jieba-win32-ia32-msvc@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-win32-ia32-msvc/-/jieba-win32-ia32-msvc-2.0.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-tpJt3uuBlGrcOInQLTYvcgamQgfadl5cwExLYU+CX9rXKpXLDO31dIujUDBgNWoiQq3tOiU1/AKbT7ZdNd4lBQ=="],
"@node-rs/jieba-win32-x64-msvc": ["@node-rs/jieba-win32-x64-msvc@2.0.1", "https://registry.npmjs.com/@node-rs/jieba-win32-x64-msvc/-/jieba-win32-x64-msvc-2.0.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-LDOyo2/2CO8UnpSGLJdgqtH8mOnsABPhNxkfIky7UT9cyLEzOaU44nbA5YzPGpBI3qzMbWcwJYQsjBcgK2VqAg=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "https://registry.npmjs.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="],
"node-addon-api": ["node-addon-api@8.9.1", "", {}, "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg=="],
"node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="],
"tree-sitter-c": ["tree-sitter-c@0.24.1", "", { "dependencies": { "node-addon-api": "^8.3.1", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.22.4" }, "optionalPeers": ["tree-sitter"] }, "sha512-lkYwWN3SRecpvaeqmFKkuPNR3ZbtnvHU+4XAEEkJdrp3JfSp2pBrhXOtvfsENUneye76g889Y0ddF2DM0gEDpA=="],
"tree-sitter-python": ["tree-sitter-python@0.25.0", "", { "dependencies": { "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-eCmJx6zQa35GxaCtQD+wXHOhYqBxEL+bp71W/s3fcDMu06MrtzkVXR437dRrCrbrDbyLuUDJpAgycs7ncngLXw=="],
"tslib": ["tslib@2.8.1", "https://registry.npmjs.com/tslib/-/tslib-2.8.1.tgz", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"web-tree-sitter": ["web-tree-sitter@0.26.11", "", {}, "sha512-Q5Dm3YTIXSXuH6FxX6RuzX2Qwpc4DPGiYMU87Wg5Z8OIStiQFiUex4zMDc0vBTw78EphaYJacncJghCHzbZptg=="],
}
}
-98
View File
@@ -1,98 +0,0 @@
#!/usr/bin/env bun
// 从 Django urls/*.py 提取后端端点全集
import { readFileSync } from "node:fs"
import { join } from "node:path"
const ROOT = process.argv[2] ?? "../../OnlineJudge"
type Endpoint = { app: string; side: "oj" | "admin"; pattern: string; view: string; name: string; deprecated: boolean }
// 唯一入口是 oj/urls.py —— 它才是 Django 真实的挂载表。
//
// 不要按文件名白名单(oj.py / admin.py)去猜 urls 文件:那样会漏掉两类真实存在的形态,
// 1. urls/ 目录里文件名不叫 oj/admin 的,如 tutorial/urls/tutorial.py3 个端点,前端在用)
// 2. 根本没有 urls/ 目录、直接是模块文件的,如 utils/urls.py(2 个端点,其一前端在用)
// 历史上这两类共 5 个端点被静默漏掉,其中 4 个前端在用。
// 现在改为解析 oj/urls.py 的 include 清单,挂载前缀直接取自这里,不再从文件名反推 side。
const INCLUDE_RE = /path\(\s*["'](api\/(?:admin\/)?)["']\s*,\s*include\(\s*["']([\w.]+)["']\s*\)/g
type Mount = { prefix: string; module: string; file: string; app: string; side: "oj" | "admin" }
function findMounts(root: string): Mount[] {
const src = readFileSync(join(root, "oj", "urls.py"), "utf8")
const mounts: Mount[] = []
INCLUDE_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = INCLUDE_RE.exec(src)) !== null) {
const prefix = m[1]
const mod = m[2]
// Python 模块名 → 文件路径。"tutorial.urls.tutorial" → tutorial/urls/tutorial.py
// "utils.urls" → utils/urls.py。app 一律取模块名首段,不依赖目录层数。
mounts.push({
prefix,
module: mod,
file: join(root, ...mod.split(".")) + ".py",
app: mod.split(".")[0],
side: prefix === "api/admin/" ? "admin" : "oj",
})
}
return mounts
}
// 必须跨行匹配:18 处 path( 的参数换行写,按行扫会漏
// path("problem/", ProblemAPI.as_view(), name="problem_api"),
// path(
// "problemset/visible",
// ProblemsetVisibleAPI.as_view(),
// ),
const PATH_RE = /path\(\s*r?["']([^"']*)["']\s*,\s*([A-Za-z_][\w.]*)/g
const NAME_RE = /name\s*=\s*["']([^"']+)["']/
const endpoints: Endpoint[] = []
const mounts = findMounts(ROOT)
for (const { file, app, side, prefix } of mounts) {
const src = readFileSync(file, "utf8")
// 按括号深度找出每个 path(...) 的完整片段,再连同该行剩余部分(行尾注释可能带
// DEPRECATED 标记)一起分析。单纯用正则切片会在 .as_view() 的右括号处截断。
for (let i = src.indexOf("path("); i !== -1; i = src.indexOf("path(", i + 1)) {
if (i > 0 && /[\w.]/.test(src[i - 1])) continue // 跳过 re_path( 等
let depth = 0
let end = i
for (let j = i + 4; j < src.length; j++) {
if (src[j] === "(") depth++
else if (src[j] === ")" && --depth === 0) {
end = j + 1
break
}
}
const lineEnd = src.indexOf("\n", end)
const chunk = src.slice(i, lineEnd === -1 ? end : lineEnd)
// 注释可能出现在 path( 之后、字符串之后、逗号之后的任意位置,先整体剥掉再匹配;
// DEPRECATED 的判定仍用未剥离的 chunk。
const stripped = chunk.replace(/#[^\n]*/g, "")
PATH_RE.lastIndex = 0
const m = PATH_RE.exec(stripped)
if (!m) {
console.warn(` ⚠ 无法解析: ${chunk.split("\n")[0].trim()}`)
continue
}
endpoints.push({
app,
side,
pattern: "/" + prefix + m[1],
view: m[2],
name: chunk.match(NAME_RE)?.[1] ?? "",
deprecated: /DEPRECATED/.test(chunk),
})
}
}
const oj = endpoints.filter((e) => e.side === "oj").length
const dep = endpoints.filter((e) => e.deprecated).length
console.log(`挂载点 ${mounts.length} 个(来自 oj/urls.py 的 include`)
console.log(`后端端点合计 ${endpoints.length} (oj ${oj} / admin ${endpoints.length - oj})`)
console.log(`其中已标 DEPRECATED: ${dep}`)
await Bun.write("endpoints-backend.json", JSON.stringify(endpoints, null, 2))
console.log("→ endpoints-backend.json")
-58
View File
@@ -1,58 +0,0 @@
#!/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}`),以及带泛型的 get<T>("x")
// http.get<T>() 在 api.ts 里大量使用,泛型不吃会漏掉三分之一的调用点)
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")
-20
View File
@@ -1,20 +0,0 @@
#!/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)
// 注:@node-rs/jieba@2.0.1 没有 insertWord/addWord 方法(.d.ts 未导出),
// 改用 loadDict 加载一份自定义词条缓冲区,格式同 Python jieba 用户词典:"词 词频"
jieba.loadDict(Buffer.from("两个整数 9999\n"))
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")
-8
View File
@@ -1,8 +0,0 @@
{
"dependencies": {
"@node-rs/jieba": "^2.0.1",
"tree-sitter-c": "^0.24.1",
"tree-sitter-python": "^0.25.0",
"web-tree-sitter": "^0.26.11"
}
}
-26
View File
@@ -1,26 +0,0 @@
import { pbkdf2Sync, timingSafeEqual } from "node:crypto"
// 验证 Django pbkdf2_sha256$<iterations>$<salt>$<b64hash>
function verifyDjangoPassword(raw: string, encoded: string): boolean {
const [algo, iterStr, salt, hash] = encoded.split("$")
if (algo !== "pbkdf2_sha256") return false
const expected = Buffer.from(hash, "base64")
const actual = pbkdf2Sync(raw, salt, Number(iterStr), expected.length, "sha256")
return expected.length === actual.length && timingSafeEqual(expected, actual)
}
const encoded = "pbkdf2_sha256$1200000$JIVbwvl1TpoWNHUitEA0iJ$5pOkPVGvtZbPGHZ1DnYbhpbtywLdsnEKtzm66IBABIU="
const t0 = performance.now()
const ok = verifyDjangoPassword("student123", encoded)
const cost = performance.now() - t0
console.log("正确密码 :", ok)
console.log("错误密码 :", verifyDjangoPassword("wrongpass", encoded))
console.log("单次耗时 :", cost.toFixed(0), "ms (1200000 轮迭代)")
// 透明升级路径:验通后改存 argon2id
const upgraded = await Bun.password.hash("student123", { algorithm: "argon2id" })
const t1 = performance.now()
const ok2 = await Bun.password.verify("student123", upgraded)
console.log("argon2id :", ok2, "耗时", (performance.now() - t1).toFixed(0), "ms")
-85
View File
@@ -1,85 +0,0 @@
#!/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、去掉首尾斜杠、转小写
//
// 注意:只能去掉 /api 前缀,不能连带去掉 admin/ 段。
// 前端所有 http 调用共用同一个 axios 实例(baseURL: "/api",见 ojnext/src/utils/http.ts),
// admin 接口是前端代码里手写的字面路径,例如 http.get("admin/dashboard_info")——
// 也就是说前端 path 里的 "admin/" 是真实存在的一段,不是像 "/api" 那样的传输层前缀。
// 后端 pattern 是 "/api/admin/dashboard_info",去掉 "/api/" 之后应该保留 "admin/dashboard_info"
// 才能跟前端对上;如果连 "admin/" 也一并剥掉(brief 原稿的 `(admin\/)?` 分组),
// 后端 key 变成 "dashboard_info",前端 key 仍是 "admin/dashboard_info",永远对不上——
// 会把所有本该 KEEP 的 admin 端点错判成 REVIEW。
function key(s: string): string {
return s
.replace(/^\/?api\//, "")
.replace(/<[^>]*>/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}`)
// 反向对账:前端调用了、后端却查无此端点。
// 只查"后端端点有没有被前端调用"是单向的,提取器漏抓整个 urls 文件时这一侧毫无反应
// —— 历史上 tutorial/urls/tutorial.py 与 utils/urls.py 共 5 个端点被漏掉,靠人眼评审才发现。
// 加上这条反向差集,同类系统性盲区会由脚本自己报警。
// 残留的 orphan 不一定是 bug:前端可能确实留着调用死路径的代码,那本身就是 Task 4 要清的信息。
const beKeys = new Set(rows.map((r) => r.key))
const orphans = [...new Set(frontend.map((c) => key(c.path)))].filter((k) => !beKeys.has(k)).sort()
if (orphans.length) console.warn(`⚠ 前端调用无对应后端端点 ${orphans.length} 条:`, orphans)
// 生成日期用本地时区。toISOString() 是 UTC,本地 UTC+8 晚间生成会写成前一天,
// 而这份产物要靠生成时间判断是否需要重跑。
const today = new Date().toLocaleDateString("sv-SE")
const md = [
"# 端点清单(机器初判)",
"",
`生成时间:${today}`,
`合计 ${rows.length} 个端点 —— KEEP ${count("KEEP")}、CUT ${count("CUT")}、REVIEW ${count("REVIEW")}`,
"",
"> REVIEW 项需人工裁决,裁决后把本行的 REVIEW 改成 KEEP 或 CUT,并在末列写明理由。",
"",
"> 已知盲点 1`ojnext/src/oj/api.ts` 第 45、73 行用变量动态传路径(形如 `http.get(endpoint)`),提取脚本的正则匹配不到这类调用。因此对应的后端端点会被本表判成“前端无调用”,但实际可能仍在使用 —— 例如 `/api/contest_submissions``getSubmissions` 里 `endpoint` 变量的另一分支)。",
"",
"> 已知盲点 2`ojnext` 里有 4 处用原生 `fetch(\"/api/...\")` 而非 `http.get/post(...)` 发起请求(AI 流式响应场景:`src/oj/store/ai.ts`、`src/oj/problem/components/SubmissionResult.vue`、`src/oj/rank/list.vue`、`src/oj/class/pk.vue`),提取脚本只认 `get/post/put/delete(...)` 调用形式,完全抓不到 `fetch(...)`。本轮 REVIEW 里的 `/api/ai/analysis`、`/api/ai/hint`、`/api/ai/class_pk`、`/api/ai/class_single` 经人工核实均属此类,实际都在用。",
"",
"> 已知盲点 3`ojnext/src/utils/download.ts` 是一个独立的 axios 实例(`baseURL: \"/api/admin\"`,与 `src/utils/http.ts` 那个共用实例无关),对外只暴露 `download(url)` 一个函数,内部走 `http.get(url)`。提取脚本既不认 `download(...)` 这种调用名,也抓不到内部那个变量 `url`,所以这条通道上的调用一律是假阴性。当前两个调用点(`src/admin/problem/components/Actions.vue:46`、`src/admin/problem/detail.vue:316`)都指向 `admin/test_case`,而该端点已因别处的字面量调用被判成 KEEP,**本轮结论不受影响**。但日后新增的 `download(...)` 调用会静默变成假阴性 CUT,裁决时留意。",
"",
"> 盲点 1、2、3 都是“前端有调用=否”但实际有调用,人工裁决时不要仅凭本表这一列就判 CUT;REVIEW 里唯一不属于此类的是 `/api/judge_server_heartbeat/`——它是判题机而非前端调用的接口,不受提取脚本盲点影响,是否保留需按后端间调用来判断。",
"",
`> 反向对账(前端调用了、后端却查无此端点):${
orphans.length === 0 ? "**0 条**,前端全部调用路径都能在后端端点全集里找到对应。" : `**${orphans.length} 条**,见下。这类路径要么是提取器又漏了某个 urls 文件,要么是前端留着调用死路径的代码,两种都要查。\n>\n${orphans.map((o) => `> - \`${o}\``).join("\n")}`
}`,
"",
"| 裁决 | 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")
+90
View File
@@ -0,0 +1,90 @@
# 时区:口径、出参格式、存量成就的那次订正
日常要守的规矩在 `CLAUDE.md`「时间只有一个锚点」一节。这里是它背后的实测与那次数据订正,
**下次再动日历口径之前先读这份**,别重新推理 —— 上次推理得出过相反的结论。
## 出参为什么统一成 ISO UTC,而且不能丢微秒
`db/index.ts` 给 OID 1184`timestamptz`)挂了 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,别碰 1082date** —— `date(... at time zone ...)` 要的是 `2026-09-14`
套上 `toISOString()` 就错了。
## 为什么刻意不设 `TZ`
Dockerfile 不设 `TZ`、数据库连接也不设 `TimeZone`。它们不改变正确代码的行为,
只会在线上把漏写的地方掩盖掉(`/problems/:displayId/yearly-ac` 就这样漏过一次),
而 dev 上又是另一个答案。
时区常量按**固定偏移**算(大陆 1991 年起没有夏令时),不查 tzdata、不用 `Intl`
所以 dev / 编译产物 / 任何镜像基底 / 任何浏览器都算得一样。
## 存量成就的口径是东八区,别再退回 UTC
2026-09-14 用生产备份 `db_backup_2026_09_08_19_22_11.sql` 实测过,结论和直觉相反。
下面「那两周留下的实际后果」和脚本跑数,又用 `db_backup_2026_09_14_18_17_37.sql`
逐项复核过,一致。)
**历史指标本来就是北京时间。** 旧栈 `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 条漏发**,而且是结构性的:那 1508 条提交里真正落在北京 0–5 点和 5–7 点的**都是 0 条**
—— 真熬夜、真早起的人都在 Django 时代活跃过了,他们的成就是当时按东八区正确发的。
这个 bug 只会多给,不会少给。
### 改这类数据时最大的坑:不能只删 `user_achievement`
`unlockAchievements()` 的判定是**纯阈值比较**`metrics[metric] >= threshold`),不是
「这次有没有跨过阈值」。只删行、不修 `user_stat.metrics` 的话,学生**下一次提交就把同一个
成就原样再发一次**。必须「按东八区重算小时指标」和「对账发放」一起做。
那次用一次性脚本 `fix-achievement-hours` 做的对账:重算两个小时指标 → 撤回不达标的 →
补发达标却没发的 → 同步 `achievement.unlock_count` → 校正 `achievement_unlocked_count`
与「奖杯收藏家」连锁。2026-09-14 跑出来是「修正 148 行 · 撤回 60 条 · 补发 0 条 · 连锁 0 条」,
再跑一遍零改动。**账已经平了,脚本也已删除**(2026-09-16,在 git 历史里:
`apps/api/src/scripts/fix-achievement-hours.ts`)—— 根因(时区)修在代码里了,
同样的误发不会再产生。
真要再做一次同类订正,照它的次序来,两条别记反:**先部署口径修复,再跑对账**。
反过来的话旧代码还在按错口径累加,跑完马上又被写脏。剩下那个通用对账工具是
`oj2-api recount``bun run --filter '@oj2/api' recount`,默认只读预演),
它管的是反范式计数列和成就已解锁数。
### 下次再动日历口径,照这套方法核实
从生产备份里捞出 `submission` / `user_stat` / `user_achievement` / `achievement` 四张表
回放一遍,先用与时间无关的指标(`submission_count` / `accepted_count`)校准重放器
(实测逐人 0 差异 / 4 人差异),再比受影响的指标。别靠推理。
+1 -2
View File
@@ -2,8 +2,7 @@
* / / * / /
* *
* **** tzdata 1991 UTC+8 * **** tzdata 1991 UTC+8
* TZ tzdata `Intl` `longOffset` * TZ tzdata
* Chrome 95+ Chrome
*/ */
export const TIME_ZONE = "Asia/Shanghai" export const TIME_ZONE = "Asia/Shanghai"
export const TIME_ZONE_OFFSET_MINUTES = 8 * 60 export const TIME_ZONE_OFFSET_MINUTES = 8 * 60