Compare commits

..
1 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5 127718dc06 refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
Deploy / deploy (push) Has been cancelled
## 出参改 satisfies

出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:

- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
  的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
  z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
  submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
  problem;列表为空时才碰巧不炸,所以一直没人报。

两个都是读出侧校验自己造出来的故障,不是它拦住的故障。

## 校验责任挪回写入侧

- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
  .language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
  .sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
  tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
  problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
  不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
  problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
  difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
  会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
  在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
  报文是真边界,失败返回 null 而不是 500。

另删掉两处与契约等价的本地 stringArray(routes/helpers.ts、routes/submission.ts)。

## 文档

CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。

## 验证

- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
  数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 05:45:15 -06:00
225 changed files with 17079 additions and 40066 deletions
-7
View File
@@ -1,7 +0,0 @@
# drizzle-kit 生成的迁移快照。内容等价的重排也别做 —— 这些文件是
# db:generate 拿来比对上一版结构的输入,只该由 drizzle-kit 写。
apps/api/src/db/meta/
# unplugin 每次 dev 都会重写,格式化了也留不住
apps/web/src/auto-imports.d.ts
apps/web/src/components.d.ts
+264 -154
View File
@@ -1,28 +1,25 @@
# CLAUDE.md
OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScript,前后端同仓。
上一代在 `../OnlineJudge/`Django)和 `../ojnext/`Vue SPA
上一代在 `../OnlineJudge/`Django)和 `../ojnext/`Vue SPA**仍然完全冻结、
一行都不改**。
> **旧栈已不可逆地下线**`0002_drop_django_leftovers` 删掉了 Django 的框架表并已在生产库
> 执行完毕,漏网的一张空 `django_migrations` 由 `0014` 补删)。所以「停新栈起旧栈」已经
> 不是退路**唯一退路是从数据库备份恢复**
> **旧栈已不可逆地下线** `0002_drop_django_leftovers` 删掉了 Django 的框架表
> `django_session` 等),且已在生产库执行完毕。所以「停新栈起旧栈」「把 NPM 上游
> 改回 8080」都已失效**唯一退路是从数据库备份恢复** —— 切换手册里的「回滚保证」
> 那节只剩历史价值。
>
> **旧仓库仍然零改动**,没有例外 —— 包括修 bug、包括不影响外部接口的内部小修。
> 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、是否
> 重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户「未处理,按当前政策不动旧仓库」,
> 不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」,不再是回滚保证
> 生产库上 0002 有一张没删干净(0 行的 `django_migrations`,来源已无法复原),
> 由 `0014_drop_django_migrations` 补删,前因后果写在那个迁移文件的注释里。
>
> **旧仓库仍然零改动**,没有例外——包括修 bug、包括不影响外部接口的内部小修
> 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、
> 是否重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策
> 不动旧仓库",不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」,
> 不再是回滚保证。
细节文档`CLAUDE.md` 只留日常要记住的,展开都在这几份里):
| 文档 | 什么时候读 |
|---|---|
| `docs/deploy.md` | 部署、上线、备份恢复 |
| `docs/database.md` | 写迁移、给新库打基线、drizzle-kit 抽风 |
| `docs/timezone.md` | 动日历口径、动时间出参格式 |
| `docs/contract.md` | 动 zod 契约、想给某个字段加校验 |
| `docs/ast-rules.md` | 动 AST 代码规则、升级 tree-sitter |
| `docker/judge/README.md` | 换判题沙箱镜像、升语言版本(gcc / Python / Node …) |
| `docs/specs/` | 两份设计文档:后端重写、课堂求助与协作编辑 |
设计文档`docs/specs/2026-08-06-bun-backend-rewrite-design.md`
切换手册:`docs/specs/phase5-cutover-runbook.md` ← 上线当天照这份走
## 仓库结构
@@ -31,18 +28,18 @@ OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScrip
| `apps/api/` | 后端。Hono + Drizzle + BullMQ,编译成单二进制 |
| `apps/web/` | 前端。从 ojnext 原样搬来的 Vue 3 SPA |
| `packages/contract/` | 前后端共用的 Zod 契约 |
| `docker/` | Dockerfile + 三套 composedev / debian / school+ 部署与运维脚本 |
| `docs/` | 上面那几份专题文档 + `specs/` 里的设计文档 |
| `docker/` | Dockerfile + 三套 composedev / debian / school |
| `docs/specs/` | 设计、端点清单、各阶段评审报告与演练报告 |
## 本机环境
**Docker 可用,全套依赖都能在本机跑起来**PostgreSQL、Redis、判题沙箱),
镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用本机跑不起来后端」的旧假设。
镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用"本机跑不起来后端"
的旧假设。
```bash
bun install
bun run db:up # 起 postgres(5433) / redis(6380) / 判题沙箱(8081)
bun run db:migrate # 空库会从 0000 自举出全部结构
bun run dev # api(3000) + worker + web(5173) 一起起
```
@@ -54,23 +51,14 @@ bun run dev # api(3000) + worker + web(5173) 一起起
```bash
bun run --filter '@oj2/api' typecheck # 后端类型检查
bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路由跑一下
bun run --filter '@oj2/api' check:ast # AST 节点类型检查,升级 tree-sitter 后跑
cd apps/web && bun run type-check # 前端类型检查
cd apps/web && bun run build # 前端构建
bun run fmt # Prettier,全仓一把(只在根目录有)
```
**格式化是全仓一套 Prettier**,配置只有根目录的 `.prettierrc.toml``semi=false`
其余全默认,printWidth 80)。`bun run fmt` 覆盖 `apps/*/src``packages/*/src` 和两
构建配置;`.prettierignore` 挡掉 drizzle-kit 生成的 `src/db/meta/` 快照和 unplugin
每次 dev 都会重写的两个 `.d.ts`。后端和契约原来没进 Prettier(手写在 100 列上下),
2026-09-16 一次性全量格式化过 —— 之后**改完代码顺手跑一下 `bun run fmt`**
别再让两边的口径分叉。
⚠️ **前端类型检查只能走 `bun run type-check` 这个脚本。** 两条看起来等价的路子都会**静默
通过**`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个 tsconfig 是 `files: []` +
references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build` 根本不做类型检查。
改完 .vue / .ts 别拿构建当验证。
⚠️ **前端类型检查只能走 `bun run type-check` 这个脚本。** 两条看起来等价的路子
都会**静默通过**`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那
tsconfig 是 `files: []` + references 的壳,真正的配置在 `tsconfig.app.json`),
`vite build` 根本不做类型检查。改完 .vue / .ts 别拿构建当验证。
**不要写测试** —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。
本机 Docker 全套都能起,实跑的成本比想象中低。
@@ -79,16 +67,17 @@ references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build`
### 单二进制是有代价的
`apps/api` 编译成 `bun build --compile` 的单二进制,所以**运行时不能依赖 node_modules**。
任何 `require.resolve` / `Bun.resolveSync` / `__dirname` 去找文件的写法,本地都正常、编译后
都会炸,而且**只在离开仓库目录后才炸**(在仓库里跑时它顺着 cwd 摸到了 node_modules
假装没事)。
`apps/api` 编译成 `bun build --compile` 的单二进制,所以**运行时不能依赖
node_modules**。任何 `require.resolve` / `Bun.resolveSync` / `__dirname` 去找文件的
写法,本地都正常、编译后都会炸,而且**只在离开仓库目录后才炸**(在仓库里跑时它顺着
cwd 摸到了 node_modules假装没事)。
资源要用 `with { type: "file" }` 内嵌。`.node` 原生模块还要额外注意:这个写法只有打包器认、
`bun run` 不认,所以必须按形态分叉 —— 见 `apps/api/src/vendor/jieba.ts` 的注释,
那里把坑写全了。
资源要用 `with { type: "file" }` 内嵌。`.node` 原生模块还要额外注意:这个写法
只有打包器认、`bun run` 不认,所以必须按形态分叉 —— 见 `apps/api/src/vendor/jieba.ts`
的注释,那里把坑写全了。
**改完这类代码,dev 和编译两种形态都要跑一遍。** 我吃过亏:只验了编译产物,dev 直接起不来。
**改完这类代码,dev 和编译两种形态都要跑一遍。** 我吃过亏:只验了编译产物,
dev 直接起不来。
### 路径解析看 `runtime.ts`
@@ -97,15 +86,17 @@ references 的壳,真正的配置在 `tsconfig.app.json`),而 `vite build`
### SQL 判题会 spawn「自己」
`judge/sql/index.ts` 起的子进程是二进制自身 + `sql-child` 子命令(因为编译后磁盘上没有
child.ts 可以 spawn)。所以**入口必须有 argv 分发**,否则「起自己」变成「把整个程序再跑
一遍」→ 指数级 fork。这不是假想,开发时炸过一次开发机。`OJ2_SQL_CHILD` 那道递归闸不要删。
`judge/sql/index.ts` 起的子进程是二进制自身 + `sql-child` 子命令(因为编译后磁盘上
没有 child.ts 可以 spawn)。所以**入口必须有 argv 分发**,否则「起自己」变成
「把整个程序再跑一遍」→ 指数级 fork。这不是假想,开发时炸过一次开发机。
`OJ2_SQL_CHILD` 那道递归闸不要删。
### 加路由要防遮蔽
**Hono 按注册顺序匹配,不是静态优先**(实测确认过,别凭直觉)。`/problems/:id` 注册在
`/problems/random` 前面的话,后者永远进不去 —— 而且不报错、不警告,只是静默走进前一条的
handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉,一直到评审才发现。
**Hono 按注册顺序匹配,不是静态优先**(实测确认过,别凭直觉)。`/problems/:id`
注册在 `/problems/random` 前面的话,后者永远进不去 —— 而且不报错、不警告,
只是静默走进前一条的 handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉,
一直到评审才发现。
加完路由跑 `bun run --filter '@oj2/api' check:routes`
@@ -115,145 +106,264 @@ handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃
这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是
这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。
### 判题镜像是自己构建的
`compose.*.yml` 里的 `oj2-judge-2` **不在任何 registry 上**:上游
QingdaoU/JudgeServer 停更在 2024-04(官方镜像的 `latest``1.6.1` 是同一份,
编译器停在 gcc-13),新工具链只能自己编。`docker/judge/` 里是只改版本的 Dockerfile
分叉 + 构建脚本 + 冒烟测试,判题逻辑一行没动。
- 新机器、换镜像:先 `docker/judge/build.sh --save` → scp → `docker load`,再部署。
**服务器和机房各有各的判题沙箱,两边都要装。**
- 改工具链就把末尾序号 +1(下一版 `oj2-judge-3`)。`up -d` 不带 `--pull`,名字没变会静默用旧镜像。
- 编译/运行命令在 `apps/api/src/judge/languages.ts`,不在镜像里。gcc-14 把隐式函数
声明等提成了 error`-w` 压不住),那边的 `cLooseErrors` 三个 `-Wno-error=` 就是
为此加的 —— 删掉它们等于让一批历史题解和 C 教程示例集体 CE。
- **判题沙箱只认 C / C++ / Python。** Java / JavaScript / Golang 连同镜像里的
JDK / Node / Go 在 2026-09 一起砍了(前端本来就没给入口,12 万条提交里它们共 62 条),
契约 `judgeLanguageSchema` 里的键留着是为了渲染那 62 条历史提交。
**`Python3` / `Python2` 这两个旧值已经没有了** —— 0019 迁移把 104530 条提交、937 道题、
1235 个用户的成就指标并成了一个 `Python`,0020 顺手把那三种语言从题目的可选语言里摘掉
(不摘的话 84 道题的语言下拉还能选 Java,提交必 SYSTEM_ERROR)。查判题配置走
`judgeConfigFor()`,它带旧值别名;**回滚要连数据一起回**,只滚代码会让 Python 提交全炸。
- 换完镜像跑 `bun docker/judge/smoke.ts`:三种语言、六种状态码、gcc 宽松度一起核。
### 出参不 `parse`,用 `satisfies`
**后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。** 出参是后端自己刚
拼出来的字面量,TS 已经在编译期校验过;再 parse 一遍拿不到任何新信息,唯一可能失败的输入是
**库里的历史数据**,而失败的代价是 500 —— 这条规矩是被四次这样的线上故障换来的。
**后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。**
出参是后端自己刚拼出来的字面量,TS 已经在编译期校验过;再 `parse` 一遍拿不到任何新
信息,唯一可能失败的输入是**库里的历史数据**,而失败的代价是 500。这一层原来有 136 处,
已经全部撤掉,撤的时候当场炸出两个一直存在的线上 500:
**闸设在写入侧**:入参 `safeParse`58 处)、`db/schema.ts``.$type<>()` 列收窄、
语义校验函数(`astRulesError()` / `exerciseDataError`)。JSONB 原文
`submission.info` / `statistic_info` / `exercise.data`)一律放行,它们的形状真相在判题机
那边。query 的筛选值走 `routes/helpers.ts``asFilterValue()`,那是纯类型交接、不加校验。
- `adminProblemSchema.lastUpdateTime` 写的是 `z.string()`,但 `problem.last_update_time`
是全库唯一可空的列(961 道题里 470 道是 NULL)——**后台打开任何一道没编辑过的老题都是 500**;
- `embeddedSubmissionSchema` `submissionDetailSchema` 继承了 `problemDisplayId` 却没
omit,而路由只填了同义的 `problem`——**凡是收到过站内信的人,消息页都打不开**(列表为空
时才碰巧不炸,所以一直没人报)。
四次故障的细节、`.$type<>()` 断言该怎么核,见 `docs/contract.md`
前端为什么只在三处挂运行时闸门,见 `apps/web/CLAUDE.md`
两个都是「读出侧校验」自己造出来的故障,不是它拦住的故障。历史上还有两次同类:
`exerciseSchema` 按题型收紧后一行脏数据让整条练习列表 500;`info` 写成
`union([完整形状, z.object({})])` 后对不上的一律落进空对象那支且 parse **成功**
管理员详情页的测试点表格静默消失(全量核出 9163/124192 条中招,RE 8480/8480 全中——
沙箱在非正常退出的测试点上写 `output_md5: null`,而契约写的是 `z.string()`)。
### AST 代码规则:一张表,外加一个机器检查
**闸设在写入侧,一共三处形态:**
契约的 `AST_NODE_TARGETS_BY_LANGUAGE` 是**唯一**一张表(`label` 给界面、`node` 给判题机),
判题机侧没有第二张表,所以加 target 漏配节点类型在结构上不可能。但**配错**仍然可能,
而且完全静默 —— 节点类型对不上就是「必须使用 X」永远失败、「不能使用 X」永远通过。
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`
```bash
bun run --filter '@oj2/api' check:ast # 升级 tree-sitter-* 之后一定要跑
```
**JSONB 原文(`submission.info` / `statistic_info` / `exercise.data`)仍然一律放行**
读出侧不收窄:它们的形状真相在判题机那边。
判题机只认 C / C++ / Python`AST_SUPPORTED_LANGUAGES`),别的语言配了规则一条都不会跑,
所以后台不给它们开 tab —— **看得见却不检查**比没有更糟。C++ 的调用形态和 C 不一样
规则的语义校验为什么不挂在 zod 上,见 `docs/ast-rules.md`
query 里的筛选值要和收窄过的列比较时走 `routes/helpers.ts``asFilterValue()` ——
那是纯类型交接,**不加校验**:在那儿拦一道会把「筛出空列表」变成「筛条件被忽略
返回全部」。前端那侧(`utils/contract.ts` 为什么只挂三处)见 `apps/web/CLAUDE.md`
唯一还留着 `parse` 的地方是 `judge/events.ts``parseSubmissionEvent` ——
那是从 Redis 收回来的报文,真边界,且失败返回 `null` 而不是 500。
### AST 代码规则有两张表,必须同增同减
契约的 `AST_NODE_TARGETS_BY_LANGUAGE`(target → 中文名)决定后台下拉能选什么,
`apps/api/src/judge/ast.ts``mappings`target → tree-sitter 节点类型)决定判题机
认得什么。**加节点类型时两边都要加**,运算符表 `AST_OPERATOR_TARGETS_BY_LANGUAGE` 同理。
只加一边是**静默错判**:判题机 `mapping[target] ?? target` 拿裸名去比节点类型,
C 的语法树里永远不存在 `list_comprehension`,于是「必须使用列表推导式」永远失败、
「不能使用 f-string」永远通过,两头都不报错,只有学生受着。原来那张表是 C/Python
混在一起的 15 条,整份铺成下拉,给 C 题也能选到 Python 专有节点——就是这么来的。
判题机只认 `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 模式
没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要顺手补回来
没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要"顺手补回来"
### 前端基线是 Chrome 1052026-09-16 从 < 94 上调)
### 前端要兼容老 Chrome
机房**部分**电脑 Chrome 105,其余更新 —— 按最低那档定基线。
- **`@vitejs/plugin-legacy` 留着,别删**vite 8 的默认构建 target 是 `chrome111`,比 105 高。
这个插件同时把 `build.target` 压到 `es2020/chrome105`、给现代产物补 core-js polyfill
`toSorted` / `Set` 运算 / 迭代器辅助那批是 Chrome 110+ 才有的)。`modernTargets` 不写,
用插件自带的基线(`chrome>=105`),正好是这一档。polyfill 清单写死在 `vite.config.ts`
**升级前端依赖后重新审计**`DEBUG=vite:legacy bun run build` 会打印探测到的全集。
- **Chrome < 94 那套删掉了**`mermaid-legacy`mermaid@9)、cytoscape 的 UMD→ESM 别名、
`useMermaid.ts` 里按 UA 分叉的 v9 回调式 render —— 105 用得上 mermaid 11。
- **View Transitions 要 111105 没有**`darkTransition.ts` 的降级分支是真在用的。
### 时间只有一个锚点:`apps/api/src/time.ts`
**凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,一律走那个模块。** 不要写
`new Date(x).getHours()``setHours(0,0,0,0)``getFullYear()``new Date(y, m, d)` 这类跟
**进程时区**走的代码 —— 容器是 UTC、开发机是本机时区,两边答案不同而且不报错。
SQL 里要按日历切,用 `localTime(列)`(生成 `列 at time zone 'Asia/Shanghai'`),
别依赖数据库会话时区。
**分层:存 UTC 时刻 → 后端判定按东八区 → 出参 ISO UTC → 前端按东八区渲染。**
- **存**35 个时间列全是 `timestamptz`,写侧一律 `new Date().toISOString()`
- **判定**:日历语义走 `time.ts`SQL 用 `localTime()`
- **出参**`db/index.ts` 给 OID 1184 挂了 parser,读出来的时刻统一成 ISO 8601 UTC
**微秒必须保留**(截成毫秒会让翻页每页丢一条、班级 AC 排名少 1)。
- **渲染**:前端 `parseTime()` / `zonedParts()` 按同一个固定偏移取东八区部件
(见 `apps/web/CLAUDE.md`)。
时区常量 `TIME_ZONE` / `TIME_ZONE_OFFSET_MINUTES``packages/contract/src/time.ts`
前后端共用一份,按**固定偏移**算(大陆 1991 年起没有夏令时)。旧栈的口径本来就是东八区,
重写时丢过一次、2026-09 才收回来 —— 期间「今日提交」在北京时间 0:00–8:00 是空的,
两个小时口径的成就整体偏 8 小时,事后已用一次性脚本对账订正(账平了,脚本已删)。
**再动日历口径之前先读 `docs/timezone.md`**,那里有实测数据和核实方法;
Dockerfile 的 `TZ` 和数据库连接的 `TimeZone` 是**刻意不设**的,别「顺手补上」。
机房电脑 Chrome < 94。`mermaid-legacy` 等 fallback 依赖和 vite 的构建 target
不能动,`vite.config.ts` 里有注释说明。
## 数据库
Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它长得像 Django 建的表
(表名、bigint/int4 混用),`schema.ts` 顶部记了哪些地方是手工修的。
**schema 现在归 OJ2 独占**,结构变更走 migration 正常演进。
**外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION
- **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表
problem_tags)、题单/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存
user_stat)。
- **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` 的绝大
多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。
problem_tags)、题单/教程/成就的组成部分、一对一附属(user_profile)与可重算的
缓存user_stat)。
- **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user`
的绝大多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。
**加新子表时必须回来想一遍该走哪一档**,别默认新外键会自己连坐 —— drizzle 不写
`.onDelete()` 就是 NO ACTION,而 0010 只改了当时存在的那批。
**schema 现在归 OJ2 独占。** 旧后端已下线,「改 schema 要考虑回滚」这条约束不再存在,
结构变更走下面的 migration 正常演进即可。
### 改 schema 走 drizzle migration
`bun run db:generate`(造迁移文件)→ `bun run db:migrate`(按
`drizzle.__drizzle_migrations` 增量执行),就是 Django `makemigrations` / `migrate`
等价物。索引/结构变更走这条,不要再手写 SQL 往 `docs/` 里塞。
`bun run db:generate`(造迁移文件)→ `bun run db:migrate`(按 `drizzle.__drizzle_migrations`
增量执行),就是 Django `makemigrations` / `migrate`等价物。索引/结构变更走这条,
不要再手写 SQL 往 `docs/specs/` 里塞。
- **执行器是自己的**`db/migrate.ts`,一条迁移一个事务),不是 drizzle 那个,
`db:migrate` 和线上 `oj2-api migrate` 是同一条代码路径。
- **部署时自动执行**`docker/deploy.sh` 在构建镜像之后、起栈之前跑,失败就中止部署
- 迁移文件**不内嵌进二进制**,随镜像装在 `/usr/local/share/oj2/migrations`
(见 `runtime.ts``migrationsDir`),所以新增迁移不用改任何代码。
- **破坏性迁移默认拦截**`DROP TABLE` / `DROP COLUMN` / `ALTER COLUMN ... TYPE` /
`TRUNCATE`),退出 4,要显式放行:`OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`
- **空库能自举**,直接从 `0000` 建起,新环境不需要先灌 schema dump。
**部署时自动执行。** `docker/deploy.sh` 在「构建镜像」之后、「起栈」之前会跑
`oj2-api migrate`,失败就中止部署(旧容器原样还在跑)。CI 走的也是 deploy.sh
所以不需要给 GitHub 配数据库凭据,也不用把生产库对外开放
`CREATE INDEX CONCURRENTLY` 怎么写、给已有库打基线的 SQL、`.op()` 会吞掉索引方向这类
drizzle-kit 的坑,全在 `docs/database.md`
迁移文件**不内嵌进二进制**,随镜像装在 `/usr/local/share/oj2/migrations`
(见 `runtime.ts``migrationsDir`、Dockerfile 里那两条 COPY)。这样 drizzle 的
`migrate()` 能原样用——它靠 `meta/_journal.json` 自动发现迁移,**新增迁移不用改任何
代码**。内嵌就得为每条迁移手写一行 import,那是迟早会漏的账。
**破坏性迁移默认拦截。**`DROP TABLE` / `DROP COLUMN` / `DROP SCHEMA` /
`ALTER COLUMN ... TYPE` / `TRUNCATE` 的迁移会让部署停在迁移这步并退出 4,
需要确认备份后显式放行:
```bash
OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
```
`DROP INDEX` / `DROP CONSTRAINT` 不算——它们不掉数据,拦了只会让人习惯性带上放行开关。
**空库自举时这道闸不生效**:没有数据可丢,0002 那串 `DROP ... IF EXISTS` 全是空转,
拦下来只会逼每个新环境都带一次放行开关,把它训练成习惯动作。
**放行的三条路,别记错:**
1. 服务器上手工部署:`OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`
2. CI`.github/workflows/deploy.yml`):**必须先手工触发**并在
`workflow_dispatch` 上勾 `allow_destructive`。push 触发拿不到这个 input,值恒为空
—— 也就是说**自动部署永远不会执行破坏性迁移**,只会停在闸门上把工作流判红。
这是有意的:那种改动得有人先确认备份。
3. 先单跑迁移把结构推到位,再 push 代码:迁移一旦记进
`drizzle.__drizzle_migrations` 就不会再跑,后续自动部署里它已不是 pending,
自然不触发闸门。多环境共库时(机房 + 服务器)推荐这条。
**空库能自举了。** `oj2-api migrate` 指向一个空库时直接从 `0000` 建起:
```bash
DATABASE_URL=postgres://... oj2-api migrate
# 空库,从 0000 开始自举。
# 待执行 15 条迁移,开始。
# ✓ 0000_crazy_gateway
# ✓ 0001_add_submission_public_create_time_idx
# ✓ 0002_drop_django_leftovers
# …
# ✓ 0014_drop_django_migrations
```
`0000_crazy_gateway.sql` 原本是 `drizzle-kit pull` 的产物、整份被 `/* */` 包着、可执行
语句 0 条,所以以前新库只能先手工 `psql -f docs/specs/schema.sql`。现在它的内容由那份
生产 dump 机械转换而来(去掉 psql 专有指令、去掉 7 张 Django 遗留表及其索引外键,
其余原样保留)。**实测**:空库自举出来的结构,和「灌 schema.sql + 打基线 + 跑迁移」
这条老路子跑出来的结构,`pg_dump --schema-only` 逐字节一致(734 行,零差异)。
改 0000 对生产库没有影响 —— migrator 只比 `created_at`、**从不校验 hash**
`pg-core/dialect.js` 里就一句 `Number(lastDbMigration.created_at) < migration.folderMillis`),
而生产库那行 `baseline-0000-faked` 早把它挡在门外了。
⚠️ **0000 的注释里不要出现 statement-breakpoint 那个分隔标记的字面量。**
`readMigrationFiles` 是纯文本切分,不管它在不在注释里,照切不误 —— 注释被从中间切开,
后半截当成 SQL 发出去,报的是 `syntax error at or near "。"` 这种和真实原因毫不相干的错。
**给一个已经存在的库做基线**drizzle 没有 `--fake-initial``migrate` 见到空的
`__drizzle_migrations`、库里却已经有表,会拒绝执行并 exit 3(裸跑 `drizzle-kit migrate`
的话则是从 `0000` 撞上已存在的表、整个事务回滚,**而且 exit 1 却一个错误都不打印**)。
对已有数据的库第一次跑之前,先手插一行把 `0000` 标记成已执行:
```sql
CREATE SCHEMA IF NOT EXISTS drizzle;
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint);
INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
VALUES ('baseline-0000-faked', 1786070652521); -- = meta/_journal.json 里 0000 的 when
```
migrator 只比 `created_at`,不校验 hash,所以 hash 随便填。
**已知的三个坑**`meta/0000_snapshot.json``pull` 出来的,没法无损还原 Django 建的
schema,下面三处已经修过了,别让它们回潮):
- ~~**快照里的 Django 序列**~~:已随 `0002_drop_django_leftovers` 删表一并解决,
`tablesFilter` 也移除了。(历史原因:`tablesFilter` 只过滤表、不过滤它们的序列,
于是 `generate` 会吐出 5 条 `DROP SEQUENCE`。)
- **bigint 上限精度**`pull` 生成的 `maxValue: 9223372036854775807` 是 JS number 字面量,
round-trip 成 `...776000`,每次 generate 都会多出 10 条 `ALTER COLUMN ... SET MAXVALUE`
已改成字符串。
- **表达式索引的 opclass**`problem_tag_name_ci_unique` 在快照里带 `opclass`,但 drizzle
自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。
**还有一个写代码时要绕开的**
- **`.op()` 会吞掉索引方向**:真正的根因不是 `.desc()`,是 opclass。drizzle-kit 的
`CreatePgIndexConvertor` 里那个三元一旦走进 opclass 分支就回不到方向分支:
`${it.opclass ? ` ${it.opclass}` : it.asc ? "" : " DESC"}`。而 `drizzle-kit pull`
给**每一列**都挂了 `.op(...)`,所以本仓库里"写了 `.desc()` 却生成不出 DESC"每次都会重演。
**要方向就别写 `.op()`。** 不写没有任何代价——`int4_ops` / `timestamptz_ops` 本来就是
这些类型的默认 opclass,写了等于没写。实测(drizzle-kit 0.31.10,探针索引跑过 generate):
| schema.ts | 生成的 SQL |
|---|---|
| `.desc().nullsFirst().op("timestamptz_ops")` | `"create_time" timestamptz_ops` ← 方向丢了 |
| `.desc().nullsFirst()` | `"create_time" DESC NULLS FIRST` ✅ |
| `.desc()` | `"create_time" DESC NULLS LAST` ✅ |
所以**多列混合方向的索引可以正常 generate**,不必手写。
假 diff 的机制也要理解对:带 `.op()` 时快照记的是 `asc: false`SQL 建出来却是 ASC
**分歧在快照和真实库之间**,不在快照和 schema.ts 之间——所以再跑 generate 是干净的,
要等到下次 pull 才炸出来。这是当初难定位的原因。
### 迁移执行器是自己的,不是 drizzle 那个
`db/migrate.ts` 不调用 drizzle 的 `migrate()`,自己按 journal 逐条执行。换掉它是因为
`pg-core/dialect.js` 里那个实现有两条硬伤:
1. **所有待执行的迁移共用一个事务**,第 3 条失败会把第 1、2 条一起回滚。现在是**一条一个
事务**,语义和 Django `migrate` 一致,失败时也说得清库停在哪儿。
2. 正因为全在事务里,`CREATE INDEX CONCURRENTLY` 一律跑不了,没有开关。
记账行的写法和 drizzle 完全一致(`hash` = 整个文件的 sha256`created_at` = journal 的
`when`),而 migrator 只比 `created_at`、不校验 hash,所以两套执行器可以互换,不会看不懂
对方写的记录。
**`CREATE INDEX CONCURRENTLY` 现在能跑了。** 在迁移文件**第一行**写上标记:
```sql
-- oj2:no-transaction
CREATE INDEX CONCURRENTLY "xxx_idx" ON "submission" USING btree ("language");
```
这条迁移就走裸执行(简单查询协议,不包事务)。代价是**没有回滚**:中途失败时前面的语句
已经生效,而且 CONCURRENTLY 失败会在库里留下一个 INVALID 索引,要先
`DROP INDEX` 再重来(`select indexrelid::regclass from pg_index where not indisvalid`
能找出来)。所以**这种迁移一个文件只放一条语句**。
要不要用是另一回事:参考量级是 12.3 万行的部分索引,普通 `CREATE INDEX` 只锁 74ms
一般不用纠结,CONCURRENTLY 留给真扛不住锁写窗口的场合。
退出码:2 = 配置/文件问题,3 = 基线不对,4 = 撞上破坏性迁移,5 = 某条迁移执行失败。
## 部署
三套 compose 在 `docker/``dev`(本机)、`debian`(服务器)、`school`(机房)。
**机房那套没有 postgres,连的是服务器的库。** 两个站点共用一个数据库,但各有各的 Redis
和判题沙箱 —— 所以涉及两边的变更要一起做
**机房那套没有 postgres,连的是服务器的库。** 两个站点共用一个数据库,
但各有各的 Redis 和判题沙箱 —— 所以上线那天**两边必须一起切**
`compose.debian.yml` 靠 env 切形态:设 `DATA_DIR` / `DB_HOST` / `REDIS_HOST` 就是接现有的库
(线上就是这个),留空并加 `--profile local-data` 就是自带 postgres / redis。
`compose.debian.yml` 有两种形态,靠 env 切换:
⚠️ **`DATA_DIR` 默认值 `../data` `OJ2/data`,不是部署目录的 `data/`。** 沿用旧数据却忘了
设它,会静默起一套空数据(空库、没测试点、图片 404),而且**不报错** —— 这是整个部署里
唯一会静默走歪的地方,`deploy.sh` 为它专门设了一道自检
- **只换前后端**(上线用这个):设 `DATA_DIR` / `DB_HOST` / `REDIS_HOST`
沿用旧栈已经在跑的 postgres 和 redis,只起 api / worker / web / judge。
- **自带数据**(本机、演练):不设那几个变量,起栈时加 `--profile local-data`
- **并行试跑**(上线前先挂 `oj2.xuyue.cc` 跑几天):在「只换前后端」基础上再加
`WEB_PORT`8080 被旧 backend 占着)和 `JUDGE_STATE_DIR`(两个判题机不能共用运行目录)。
这种形态下旧栈一个容器都不用停,正式切换退化成改一行 NPM 上游。
上线两条路(push 触发 CI / 手工 `docker/deploy.sh`)、部署后的验证清单、NPM 反代那两个
不能关的开关、备份恢复的两个坑,都在 `docs/deploy.md`
⚠️ `DATA_DIR` 默认值 `../data`**`OJ2/data`**,不是部署目录的 `data/`
沿用旧数据却忘了设它,会静默起一套空数据(空库、没测试点、图片 404),
而且**不报错** —— 这是切换当天唯一会静默走歪的地方。
细节和演练结果都在 `docs/specs/phase5-cutover-runbook.md`
+1 -3
View File
@@ -5,8 +5,6 @@ export default defineConfig({
schema: "./src/db/schema.ts",
out: "./src/db",
dbCredentials: {
url:
process.env.DATABASE_URL ??
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge",
url: process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge",
},
})
+2 -3
View File
@@ -11,13 +11,12 @@
"worker": "bun src/main.ts worker",
"build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api",
"seed:dev": "bun src/scripts/seed-dev.ts",
"recount": "bun --env-file=../../.env src/main.ts recount",
"backfill:problemsets": "bun src/main.ts backfill-problemsets",
"typecheck": "tsc --noEmit",
"check:routes": "bun src/scripts/check-route-shadowing.ts",
"check:ast": "bun src/scripts/check-ast-targets.ts",
"db:pull": "drizzle-kit pull",
"db:generate": "drizzle-kit generate",
"db:migrate": "bun --env-file=../../.env src/main.ts migrate"
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
"@node-rs/jieba": "^2.0.2",
+5 -13
View File
@@ -51,27 +51,20 @@ function requireRole(
return async (c, next) => {
const session = await resolveSession(c)
if (!session.user) return denied(c, session.reason)
if (!allowed(session.user))
return failure(c, 403, "permission-denied", "权限不足")
if (!allowed(session.user)) return failure(c, 403, "permission-denied", "权限不足")
c.set("user", session.user)
await next()
}
}
/** 旧 `@admin_role_required` */
export const requireAdmin = requireRole((user) =>
ADMIN_ROLES.includes(user.adminType),
)
export const requireAdmin = requireRole((user) => ADMIN_ROLES.includes(user.adminType))
/** 旧 `@teacher_admin_required` */
export const requireTeacher = requireRole((user) =>
TEACHER_ROLES.includes(user.adminType),
)
export const requireTeacher = requireRole((user) => TEACHER_ROLES.includes(user.adminType))
/** 旧 `@super_admin_required` */
export const requireSuperAdmin = requireRole(
(user) => user.adminType === "Super Admin",
)
export const requireSuperAdmin = requireRole((user) => user.adminType === "Super Admin")
/**
* 旧 `@problem_permission_required`:先要是管理员,再要 problem_permission 不为 None。
@@ -79,6 +72,5 @@ export const requireSuperAdmin = requireRole(
* created_by 过滤 —— 旧后端也是这么分工的,别把两件事混在一起。
*/
export const requireProblemPermission = requireRole(
(user) =>
ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== "None",
(user) => ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== "None",
)
+1 -5
View File
@@ -16,11 +16,7 @@ async function verifyDjangoPbkdf2(password: string, encoded: string) {
const iterations = Number(iterationsText)
const expected = Buffer.from(digestText, "base64")
if (
!Number.isSafeInteger(iterations) ||
iterations <= 0 ||
expected.length === 0
) {
if (!Number.isSafeInteger(iterations) || iterations <= 0 || expected.length === 0) {
return false
}
+3 -14
View File
@@ -77,12 +77,7 @@ export async function createSession(
// 全压在登录这一下上
const pipeline = redis
.pipeline()
.set(
sessionKey(token),
JSON.stringify(value),
"EX",
config.sessionTtlSeconds,
)
.set(sessionKey(token), JSON.stringify(value), "EX", config.sessionTtlSeconds)
.sadd(userSessionsKey(userId), token)
.expire(userSessionsKey(userId), config.sessionTtlSeconds)
markOnline(pipeline, userId)
@@ -161,9 +156,7 @@ export type SessionResult =
| { user: AuthUser; reason?: undefined }
| { user: null; reason: "anonymous" | "disabled" }
async function getUserByToken(
token: string | undefined,
): Promise<SessionResult> {
async function getUserByToken(token: string | undefined): Promise<SessionResult> {
if (!token) return { user: null, reason: "anonymous" }
const raw = await redis.get(sessionKey(token))
@@ -292,11 +285,7 @@ async function getStoredSession(c: Context) {
}
}
export async function setContestPassword(
c: Context,
contestId: number,
password: string,
) {
export async function setContestPassword(c: Context, contestId: number, password: string) {
const session = await getStoredSession(c)
if (!session) return false
session.value.contestPasswords[String(contestId)] = password
+27 -106
View File
@@ -13,7 +13,7 @@ import {
getRoom,
hasTeacherOnline,
listRequests,
normalizeCollabLanguage,
normalizeLanguage,
openRoom,
queueAheadOf,
removeRequest,
@@ -25,12 +25,6 @@ import {
type Room,
} from "./state"
/**
* `type: "error"` 的 message **会被前端原样弹成 toast**store 的 case "error"
* → setNotice → CollabHost 的 message.info),所以这里一律写中文、写成学生看得懂的
* 话。协议层的校验错误(格式不对、题号不对)正常前端触发不到,但真触发了也得是
* 一句人话 —— 原来那几条是 "Invalid problemId" 这样的英文,直接糊在学生脸上。
*/
function isTeacher(ws: CollabSocket) {
return TEACHER_ROLES.includes(toAdminType(ws.data.adminType ?? ""))
}
@@ -92,10 +86,7 @@ export function handleCollabOpen(ws: CollabSocket) {
addTeacher(ws)
// 新上线的老师要立刻看到当前队列,不能等下一次变更
ws.send(
JSON.stringify({
type: "requests",
list: listRequests().map(serializeRequest),
}),
JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }),
)
return
}
@@ -168,8 +159,7 @@ export function handleCollabClose(ws: CollabSocket) {
closeRoom(room.studentId)
room.studentSocket.data.roomOwnerId = undefined
room.teacherSocket.data.roomOwnerId = undefined
const peer =
ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
peer.send(JSON.stringify({ type: "room_closed", reason: "peer_offline" }))
if (ws === room.teacherSocket) {
@@ -197,24 +187,17 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
problemId?: unknown
studentId?: unknown
language?: unknown
reason?: unknown
timestamp?: unknown
}
try {
message = JSON.parse(raw) as typeof message
} catch {
ws.send(
JSON.stringify({
type: "error",
message: "消息格式不对,请刷新页面重试",
}),
)
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }))
return
}
// 心跳不查库,和 /ws/submissions 的处理一致
if (message.type === "ping") {
ws.send(JSON.stringify({ type: "pong", timestamp: message.timestamp }))
ws.send(JSON.stringify({ type: "pong", timestamp: (message as any).timestamp }))
return
}
@@ -241,15 +224,10 @@ export async function handleCollabMessage(ws: CollabSocket, raw: string) {
await handleReject(ws, message.studentId)
return
case "leave":
handleLeave(ws, message.reason)
handleLeave(ws)
return
default:
ws.send(
JSON.stringify({
type: "error",
message: "不认识的操作,请刷新页面重试",
}),
)
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }))
}
}
@@ -259,9 +237,7 @@ async function handleHelpRequest(
language: unknown,
) {
if (typeof problemId !== "string" || !problemId) {
ws.send(
JSON.stringify({ type: "error", message: "题号不对,请刷新页面重试" }),
)
ws.send(JSON.stringify({ type: "error", message: "Invalid problemId" }))
return
}
if (isTeacher(ws)) {
@@ -285,9 +261,7 @@ async function handleHelpRequest(
)
.limit(1)
if (!problem) {
ws.send(
JSON.stringify({ type: "error", message: "题目不存在或不支持求助" }),
)
ws.send(JSON.stringify({ type: "error", message: "题目不存在或不支持求助" }))
return
}
@@ -307,7 +281,7 @@ async function handleHelpRequest(
className: student?.className ?? null,
problemId,
problemTitle: problem.title,
language: normalizeCollabLanguage(language),
language: normalizeLanguage(language),
createdAt: Date.now(),
status: "pending",
socket: ws,
@@ -327,7 +301,7 @@ function handleHelpLanguage(ws: CollabSocket, language: unknown) {
const request = getRequest(ws.data.userId)
// 比对 socket 归属:同账号的另一个标签页停在别的题上切语言,不该改这条求助
if (!request || request.socket !== ws) return
const next = normalizeCollabLanguage(language)
const next = normalizeLanguage(language)
if (request.language === next) return
request.language = next
@@ -356,12 +330,7 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
return
}
if (typeof studentId !== "number") {
ws.send(
JSON.stringify({
type: "error",
message: "学生标识不对,请刷新页面重试",
}),
)
ws.send(JSON.stringify({ type: "error", message: "Invalid studentId" }))
return
}
@@ -370,12 +339,7 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
const [teacher] = await db
.select({ adminType: schema.user.adminType })
.from(schema.user)
.where(
and(
eq(schema.user.id, ws.data.userId),
eq(schema.user.isDisabled, false),
),
)
.where(and(eq(schema.user.id, ws.data.userId), eq(schema.user.isDisabled, false)))
.limit(1)
if (!teacher || !TEACHER_ROLES.includes(toAdminType(teacher.adminType))) {
ws.close(1008, "Permission revoked")
@@ -389,7 +353,7 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
// 老师同时只能在一个房间
if (roomOf(ws)) {
ws.send(JSON.stringify({ type: "error", message: "请先结束当前协作" }))
ws.send(JSON.stringify({ type: "error", message: "请先退出当前协作" }))
return
}
@@ -399,10 +363,7 @@ async function handleAccept(ws: CollabSocket, studentId: unknown) {
// (正常路径走不到,是两个标签页 + 断线重连缝隙的最后一道闸)——
// 回一份最新列表让老师端自己纠正
ws.send(
JSON.stringify({
type: "requests",
list: listRequests().map(serializeRequest),
}),
JSON.stringify({ type: "requests", list: listRequests().map(serializeRequest) }),
)
return
}
@@ -443,12 +404,7 @@ async function handleReject(ws: CollabSocket, studentId: unknown) {
const [teacher] = await db
.select({ adminType: schema.user.adminType })
.from(schema.user)
.where(
and(
eq(schema.user.id, ws.data.userId),
eq(schema.user.isDisabled, false),
),
)
.where(and(eq(schema.user.id, ws.data.userId), eq(schema.user.isDisabled, false)))
.limit(1)
if (!teacher || !TEACHER_ROLES.includes(toAdminType(teacher.adminType))) {
ws.close(1008, "Permission revoked")
@@ -463,63 +419,32 @@ async function handleReject(ws: CollabSocket, studentId: unknown) {
broadcastRequests()
}
/**
* 主动退出房间。**两种语义,靠 reason 分**
*
* - 不带 reason(或 `"done"`)—— 有人点了「结束协作」,这次帮忙到此结束,
* 求助记录一并清掉;
* - `"left"` —— 人只是离开了这道题的页面(教师端「页面即协作现场」,跳走就不在
* 房间里了)。**这跟他掉线是同一件事**,所以走同一条收尾:教师离开 → 求助退回
* 排队,学生不用重新举手,老师回来再点一次就接上;学生离开 → 求助随人清掉。
*
* 分开是因为两者对学生的意义完全不同:前者是「搞定了」,后者是「老师先走一下」,
* 而原来都按前者处理 —— 老师点一下「提交信息」,学生就得重新举手。
*/
function handleLeave(ws: CollabSocket, reason: unknown) {
/** 主动退出房间。老师点关闭、学生点结束都走这里 */
function handleLeave(ws: CollabSocket) {
const room = roomOf(ws)
if (!room) return
if (reason !== "left") {
teardownRoom(room, "done")
return
}
const side = ws === room.teacherSocket ? "teacher" : "student"
teardownRoom(room, "peer_left", side, ws)
}
/**
* 拆房间。reason 决定两端看到什么:
* done —— 有人主动结束,双方都收到,请求一并清除
* peer_offline —— 有人断线或发送失败被判定为不可达,见 handleCollabClose /
* handleCollabBinary
* peer_left —— 有人离开了这道题的页面(handleLeave 的 "left"
*
* offlineSide 是消失的那一方,决定请求的去向:老师消失 → 退回排队;学生消失 →
* 随人清掉。不传时只拆房间。
*
* initiator 是主动发起的那条连接:**他收到的 reason 不一样** —— 点了「结束协作」
* 是 `done`,离开页面是 `self_left`。对他来说这是「我自己干的」,不该看到一句
* 「对方离开了」,也不该看到「老师已结束这次帮忙」。
* handleCollabBinary。offlineSide 是消失的那一方:老师消失,
* 请求退回排队;学生消失,请求随人清掉。不传时(当前只有
* handleLeave 走 "done")不做这一步,只拆房间
*/
function teardownRoom(
room: Room,
reason: "done" | "peer_offline" | "peer_left",
reason: "done" | "peer_offline",
offlineSide?: "student" | "teacher",
initiator?: CollabSocket,
) {
closeRoom(room.studentId)
room.studentSocket.data.roomOwnerId = undefined
room.teacherSocket.data.roomOwnerId = undefined
// 发起方收到的是「我自己干的」那一版:点了结束就是 done,离开页面是 self_left。
// 不能跟对面收同一条 —— 学生自己切走了却看到「老师已结束这次帮忙」是假话
const selfReason = reason === "peer_left" ? "self_left" : "done"
for (const socket of [room.studentSocket, room.teacherSocket]) {
socket.send(
JSON.stringify({
type: "room_closed",
reason: socket === initiator ? selfReason : reason,
}),
)
}
const frame = JSON.stringify({ type: "room_closed", reason })
room.studentSocket.send(frame)
room.teacherSocket.send(frame)
if (reason === "done") {
removeRequest(room.studentId)
} else if (offlineSide === "teacher") {
@@ -536,10 +461,7 @@ function teardownRoom(
* 「服务端不知道代码内容」是有意的:这个通道要做的事只有认证和分房间,
* 权限由 accept 时的库查询决定,与帧里装的是什么无关。
*/
export function handleCollabBinary(
ws: CollabSocket,
data: Buffer | Uint8Array,
) {
export function handleCollabBinary(ws: CollabSocket, data: Buffer | Uint8Array) {
// 空帧:Bun.serve 探测过,send() 对 0 字节帧也回 0(同一个返回值,
// 真实送达和真实丢弃分不清),不转发、不参与下面的失败判定,直接忽略。
// 否则任何一方发一个 0 字节二进制帧就能把整间房拆掉
@@ -547,8 +469,7 @@ export function handleCollabBinary(
const room = roomOf(ws)
if (!room) return
const peer =
ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
const peer = ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
const sent = peer.send(data)
// Bun.serve 探测过:-1 不代表失败,是背压——消息已排队,最终会送达(实测 8MB
// 帧照样完整到达);只有 0 才是真的丢了(对端事实上已经断开)。之前把 <= 0
+19 -18
View File
@@ -6,11 +6,7 @@
* 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。
*/
import { normalizeLanguage } from "@oj2/contract"
export type CollabSocket = Bun.ServerWebSocket<
import("../websocket").SubmissionSocketData
>
export type CollabSocket = Bun.ServerWebSocket<import("../websocket").SubmissionSocketData>
/**
* 协作支持的语言。和前端 utils/types.ts 里的 LANGUAGE 对齐,去掉 Flowchart ——
@@ -19,7 +15,8 @@ export type CollabSocket = Bun.ServerWebSocket<
export const COLLAB_LANGUAGES = [
"C",
"C++",
"Python",
"Python2",
"Python3",
"Java",
"JavaScript",
"Golang",
@@ -28,16 +25,10 @@ export const COLLAB_LANGUAGES = [
export type CollabLanguage = (typeof COLLAB_LANGUAGES)[number]
/**
* 认不出来的一律当 C:老客户端不带这个字段,而它以前就是写死 C 的。
*
* 先过契约的别名表 —— 上线那一刻学生页面里还揣着 `Python3`,不翻译的话会**静默**
* 落到 C,求助窗口里的代码高亮和同步编辑都按 C 走,没人会报错。
*/
export function normalizeCollabLanguage(value: unknown): CollabLanguage {
const normalized = normalizeLanguage(value) ?? value
return (COLLAB_LANGUAGES as readonly string[]).includes(normalized as string)
? (normalized as CollabLanguage)
/** 认不出来的一律当 C:老客户端不带这个字段,而它以前就是写死 C 的 */
export function normalizeLanguage(value: unknown): CollabLanguage {
return (COLLAB_LANGUAGES as readonly string[]).includes(value as string)
? (value as CollabLanguage)
: "C"
}
@@ -78,6 +69,10 @@ export function removeRequest(studentId: number) {
return requests.delete(studentId)
}
export function hasRequest(studentId: number) {
return requests.has(studentId)
}
/** 按发起时间正序。老师端按等待时长排序展示,不强制先来先到 */
export function listRequests() {
return Array.from(requests.values()).sort((a, b) => a.createdAt - b.createdAt)
@@ -89,8 +84,7 @@ export function queueAheadOf(studentId: number) {
if (!self) return 0
let ahead = 0
for (const request of requests.values()) {
if (request.status === "pending" && request.createdAt < self.createdAt)
ahead += 1
if (request.status === "pending" && request.createdAt < self.createdAt) ahead += 1
}
return ahead
}
@@ -141,3 +135,10 @@ export function roomOf(ws: CollabSocket) {
const ownerId = ws.data.roomOwnerId
return ownerId === undefined ? undefined : rooms.get(ownerId)
}
/** 仅供进程退出或测试用,正常路径不该调 */
export function resetCollabState() {
requests.clear()
teachers.clear()
rooms.clear()
}
+4 -19
View File
@@ -25,10 +25,7 @@ function loadRepoRootEnv() {
if (eq <= 0) continue
const key = trimmed.slice(0, eq).trim()
if (process.env[key] !== undefined) continue
process.env[key] = trimmed
.slice(eq + 1)
.trim()
.replace(/^["']|["']$/g, "")
process.env[key] = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, "")
}
} catch {
// 根目录没有 .env 是正常情况(例如生产用真实环境变量注入),静默跳过
@@ -67,24 +64,18 @@ export const config = {
port: Number(process.env.PORT ?? 3000),
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6380",
sessionCookie: "oj2_session",
sessionTtlSeconds: Number(
process.env.SESSION_TTL_SECONDS ?? 7 * 24 * 60 * 60,
),
sessionTtlSeconds: Number(process.env.SESSION_TTL_SECONDS ?? 7 * 24 * 60 * 60),
secureCookies: process.env.COOKIE_SECURE === "true",
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
judgeServerToken: judgeServerToken(),
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
avatarDirectory: repoPath(process.env.AVATAR_DIRECTORY ?? "data/avatar"),
// 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处
testCaseDirectory: repoPath(
process.env.TEST_CASE_DIRECTORY ?? "data/test_case",
),
testCaseDirectory: repoPath(process.env.TEST_CASE_DIRECTORY ?? "data/test_case"),
uploadDirectory: repoPath(process.env.UPLOAD_DIRECTORY ?? "data/upload"),
// 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。
// 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。
hitokotoDirectory: repoPath(
process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto",
),
hitokotoDirectory: repoPath(process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto"),
/**
* WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。
* 同源本来就放行,只有前后端分处不同域名时才需要配。
@@ -100,12 +91,6 @@ export const config = {
aiProvider: process.env.AI_PROVIDER ?? "deepseek",
aiKey: process.env.AI_KEY ?? "",
aiModel: process.env.AI_MODEL ?? "deepseek-flash",
/**
* AI 提示走两段式(先诊断、再生成),见 services/hint-diagnosis.ts。**默认关**
* 2026-09-19 起 ai_hint 在攒单段式的基线数据,攒够之前别打开,否则两批数据混在一起没法比。
* 设成 "1" 打开。
*/
aiHintDiagnose: process.env.AI_HINT_DIAGNOSE === "1",
ruffPath: process.env.RUFF_PATH ?? "ruff",
clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format",
}
@@ -1,9 +0,0 @@
-- 提交列表「题号」「用户名」两个筛选的索引,用法和实测数据见 schema.ts 里两条索引的注释。
--
-- pg_trgm 是 contrib 模块,要先装扩展,drizzle-kit generate 不会替你写这一句。
-- 官方 postgres:16-alpine 镜像自带 contrib,且 pg_trgm 是 trusted 扩展(PG 13 起),
-- 库 owner 就能装。换成不带 contrib 的 Postgres 时这里会失败、部署停在迁移这步。
-- CREATE EXTENSION 可以在事务里执行,不需要 no-transaction 标记。
CREATE EXTENSION IF NOT EXISTS pg_trgm;--> statement-breakpoint
CREATE INDEX "submission_public_problem_time_idx" ON "submission" USING btree ("problem_id","create_time","id") WHERE "submission"."contest_id" is null;--> statement-breakpoint
CREATE INDEX "submission_public_username_trgm_idx" ON "submission" USING gin ("username" gin_trgm_ops) WHERE "submission"."contest_id" is null;
@@ -1,18 +0,0 @@
-- 提交的编辑过程信号(AI 时代 OJ 设计的第 1 步:过程信号采集),字段含义见 schema.ts 的
-- submissionTrace 与契约的 submissionTraceSchema。纯建表,历史提交没有对应行,这是预期的。
CREATE TABLE "submission_trace" (
"submission_id" text PRIMARY KEY NOT NULL,
"active_ms" integer NOT NULL,
"since_open_ms" integer NOT NULL,
"typed_chars" integer NOT NULL,
"pasted_chars" integer NOT NULL,
"paste_count" integer NOT NULL,
"max_paste" integer NOT NULL,
"deleted_chars" integer NOT NULL,
"blur_count" integer NOT NULL,
"initial_len" integer NOT NULL,
"collab" boolean NOT NULL,
"since_prev_ms" bigint
);
--> statement-breakpoint
ALTER TABLE "submission_trace" ADD CONSTRAINT "submission_trace_submission_id_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;
-17
View File
@@ -1,17 +0,0 @@
-- AI 提示的留痕与学生评价(AI 时代 OJ 设计 2a:先记录、不改行为),字段含义见 schema.ts 的 aiHint。
-- 纯建表。上线之前的提示从未落库,这张表从空开始。
CREATE TABLE "ai_hint" (
"id" bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "ai_hint_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 9223372036854775807 START WITH 1 CACHE 1),
"submission_id" text NOT NULL,
"model" text NOT NULL,
"prompt_version" integer NOT NULL,
"content" text NOT NULL,
"error" text,
"duration_ms" integer NOT NULL,
"helpful" boolean,
"feedback_time" timestamp with time zone,
"create_time" timestamp with time zone NOT NULL
);
--> statement-breakpoint
ALTER TABLE "ai_hint" ADD CONSTRAINT "ai_hint_submission_id_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "ai_hint_submission_id_idx" ON "ai_hint" USING btree ("submission_id");
@@ -1,4 +0,0 @@
-- AI 提示两段式的诊断结果(AI 时代 OJ 设计 2b),字段含义见 schema.ts 的 aiHint。
-- 两列都可空、不带默认值,加列只改目录不重写表。
ALTER TABLE "ai_hint" ADD COLUMN "diagnosis" jsonb;--> statement-breakpoint
ALTER TABLE "ai_hint" ADD COLUMN "diagnosis_error" text;
@@ -1,77 +0,0 @@
-- 语言值统一成 `Python`:库里原来有 `Python3`104527 条提交)和 `Python2`3 条,
-- 全是 2022 年的),界面上两个都显示成「Python」,内部却是两个值。判题沙箱早就只剩
-- 一个 Python 了,这里把落库的值也并成一个。
--
-- 语言名不是判题状态码那种「判题机也认得的编码」—— 它只是我们自己的键(语言配置是
-- 整个对象发给判题机的),所以可以改。但它确实是**落库的值**,改完再回滚到旧版后端,
-- 旧代码查 languageConfigs["Python"] 会查不到 → 所有 Python 提交变 SYSTEM_ERROR。
-- 为此后端保留了 Python2/Python3 → Python 的别名(见 judge/languages.ts),
-- 新旧代码读哪一种数据都不会炸。
--
-- 涉及的四张表是全量扫备份确认过的(submission / problem / user_stat /
-- options_sysoptions)。options_sysoptions 里那行 `languages` 是 Django 时代的判题
-- 配置,OJ2 只读 website_* 几个键,不碰它,所以这里**故意不动**。
-- ① 提交记录。12 万条里 8 成是 Python,走一次全表 UPDATE。
UPDATE "submission" SET "language" = 'Python'
WHERE "language" IN ('Python2', 'Python3');--> statement-breakpoint
-- ② 题目的可选语言。用 WITH ORDINALITY 保住原来的顺序 —— 题目页的语言下拉和默认
-- 选中项就是按这个数组的顺序来的,打乱了学生打开题目看到的默认语言会变。
UPDATE "problem" p SET "languages" = (
SELECT COALESCE(jsonb_agg(
CASE WHEN v IN ('Python2', 'Python3') THEN 'Python' ELSE v END ORDER BY ord
), '[]'::jsonb)
FROM jsonb_array_elements_text(p."languages") WITH ORDINALITY AS t(v, ord)
)
WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements_text(p."languages") x(v)
WHERE x.v IN ('Python2', 'Python3')
);--> statement-breakpoint
-- ③ 预制代码,键是语言名(75 道题有 Python3 的模板)。
UPDATE "problem"
SET "template" = ("template" - 'Python3') || jsonb_build_object('Python', "template" -> 'Python3')
WHERE jsonb_exists("template", 'Python3');--> statement-breakpoint
-- ④ AST 代码规则,键就是语言名(15 道题)。
UPDATE "problem"
SET "ast_rules" = ("ast_rules" - 'Python3') || jsonb_build_object('Python', "ast_rules" -> 'Python3')
WHERE "ast_rules" IS NOT NULL AND jsonb_exists("ast_rules", 'Python3');--> statement-breakpoint
-- ⑤ 参考答案,形如 [{"language": "...", "code": "..."}]257 条 Python3 答案)。
UPDATE "problem" p SET "answers" = (
SELECT jsonb_agg(
CASE WHEN a ->> 'language' IN ('Python2', 'Python3')
THEN jsonb_set(a, '{language}', '"Python"')
ELSE a END ORDER BY ord
)
FROM jsonb_array_elements(p."answers") WITH ORDINALITY AS t(a, ord)
)
WHERE p."answers" IS NOT NULL AND jsonb_typeof(p."answers") = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements(p."answers") x(a)
WHERE x.a ->> 'language' IN ('Python2', 'Python3')
);--> statement-breakpoint
-- ⑥ 成就指标里的「用过哪些语言」(1235 个用户)。_languages 去重之后重算
-- languages_used —— 同时用过 Python2 和 Python3 的那 3 个用户,数字会从 n 掉到
-- n-1,这是**对的**:那本来就是同一种语言。已经发出去的成就不回收。
WITH mapped AS (
SELECT s."id", jsonb_agg(d.v ORDER BY d.ord) AS arr
FROM "user_stat" s, LATERAL (
SELECT DISTINCT ON (val) val AS v, ord FROM (
SELECT CASE WHEN e IN ('Python2', 'Python3') THEN 'Python' ELSE e END AS val, ord
FROM jsonb_array_elements_text(s."metrics" -> '_languages') WITH ORDINALITY AS t(e, ord)
) m ORDER BY val, ord
) d
WHERE jsonb_typeof(s."metrics" -> '_languages') = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements_text(s."metrics" -> '_languages') x(e)
WHERE x.e IN ('Python2', 'Python3')
)
GROUP BY s."id"
)
UPDATE "user_stat" s SET "metrics" = jsonb_set(
jsonb_set(s."metrics", '{_languages}', mapped.arr),
'{languages_used}', to_jsonb(jsonb_array_length(mapped.arr))
)
FROM mapped WHERE mapped."id" = s."id";
@@ -1,41 +0,0 @@
-- 把 Java / JavaScript / Golang 从题目的可选语言里摘掉。
--
-- 这三种语言的判题配置和判题镜像里的 JDK / Node / Go 已经一起删了(见
-- judge/languages.ts 和 docker/judge/)。但生产库里有 84 道题的 `languages` 还留着
-- 它们,而题目页的语言下拉就是按这个数组渲染的 —— 不摘掉的话,学生能在那 84 道题上
-- 选 Java 提交,判题时 languageConfigs 查不到就抛 Unsupported judge language
-- 结果是 SYSTEM_ERROR。**这道迁移是那次删语言的收尾,不能只删代码不清数据。**
--
-- 备份实测:84 道题受影响,其中**没有**任何一道只有这三种语言,所以不会有题目被清空。
-- 保险起见加了 jsonb_array_length > 0 的条件:真要出现这种题,宁可留着不动、让它
-- 在后台显形,也不要把语言清空(题目页会渲染出一个空的语言下拉)。
--
-- 历史提交里那 62 条 Java/JS/Golang 记录**不动**,语言名留在契约里就是为了渲染它们。
UPDATE "problem" p SET "languages" = (
SELECT jsonb_agg(v ORDER BY ord)
FROM jsonb_array_elements_text(p."languages") WITH ORDINALITY AS t(v, ord)
WHERE v NOT IN ('Java', 'JavaScript', 'Golang')
)
WHERE EXISTS (
SELECT 1 FROM jsonb_array_elements_text(p."languages") x(v)
WHERE x.v IN ('Java', 'JavaScript', 'Golang')
) AND (
SELECT count(*) FROM jsonb_array_elements_text(p."languages") y(v)
WHERE y.v NOT IN ('Java', 'JavaScript', 'Golang')
) > 0;--> statement-breakpoint
-- 预制代码和参考答案里对应的条目一并清掉(生产库里是空的,防后台以后写进去)。
UPDATE "problem"
SET "template" = "template" - 'Java' - 'JavaScript' - 'Golang'
WHERE jsonb_exists_any("template", ARRAY['Java', 'JavaScript', 'Golang']);--> statement-breakpoint
UPDATE "problem" p SET "answers" = (
SELECT COALESCE(jsonb_agg(a ORDER BY ord), '[]'::jsonb)
FROM jsonb_array_elements(p."answers") WITH ORDINALITY AS t(a, ord)
WHERE a ->> 'language' NOT IN ('Java', 'JavaScript', 'Golang')
)
WHERE p."answers" IS NOT NULL AND jsonb_typeof(p."answers") = 'array' AND EXISTS (
SELECT 1 FROM jsonb_array_elements(p."answers") x(a)
WHERE x.a ->> 'language' IN ('Java', 'JavaScript', 'Golang')
);
+1 -26
View File
@@ -3,34 +3,9 @@ import postgres from "postgres"
import * as schema from "./schema"
const url =
process.env.DATABASE_URL ??
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
const url = process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
// 不设会话时区:日历语义的 SQL 一律显式 `at time zone``../time` 的 localTime),
// 不靠会话默认值兜底 —— 兜底会把漏写的地方在线上掩盖掉,dev 上又是另一个答案。
const client = postgres(url)
export const db = drizzle(client, { schema })
/**
* 读出来的时刻统一成 ISO 8601 UTC,和写侧的 `new Date().toISOString()` 同形状。
*
* drizzle 的 `construct()``drizzle-orm/postgres-js/driver.js`)把 1184(timestamptz) 等
* OID 的 parser 换成了恒等函数,不处理的话读出来是 PG 文本(`2026-09-14 20:00:00+08`),
* 接口上同一个字段就有两种形状。所以**必须在 `drizzle(client)` 之后**覆盖回来。
*
* - **只换 1184。** 1082(date) 要的就是 `2026-09-14`;全库时间列都是 timestamptz。
* - **`::text` 的 OID 是 25,绕过这里**:别再为了拿字符串形状给时间列加 `::text`。
* - **保留微秒。** `Date` 只到毫秒,而 Django 时代的提交几乎全带微秒;读出的时刻常被
* 原样塞回查询条件(提交列表翻页的分界行、班级 AC 排名的 `<= min(create_time)`),
* 截掉会让分界行把自己排除。所以偏移换算交给 `Date`(先去掉小数,免得进位),
* 小数位原文拼回去、至少补足 3 位。Bun、老 Chrome 和 date-fns 都能解析 6 位小数。
*/
client.options.parsers[1184] = (value: string) => {
const fraction = /\.\d+/.exec(value)?.[0]
if (!fraction) return new Date(value).toISOString()
return `${new Date(value.replace(fraction, "")).toISOString().slice(0, 19)}${fraction.padEnd(4, "0")}Z`
}
export { schema }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-42
View File
@@ -106,48 +106,6 @@
"when": 1789034426259,
"tag": "0014_drop_django_migrations",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1789364546358,
"tag": "0015_submission_filter_indexes",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1789817209482,
"tag": "0016_add_submission_trace",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1789818766735,
"tag": "0017_add_ai_hint",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1789822227451,
"tag": "0018_ai_hint_diagnosis",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1789906464541,
"tag": "0019_unify_python_language",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1789906612433,
"tag": "0020_drop_unsupported_languages",
"breakpoints": true
}
]
}
+6 -20
View File
@@ -57,9 +57,7 @@ export async function runMigrations() {
process.exit(2)
}
if (files.length === 0) {
console.error(
`${migrationsDir} 下没找到任何迁移。镜像里的迁移目录是不是漏拷了?`,
)
console.error(`${migrationsDir} 下没找到任何迁移。镜像里的迁移目录是不是漏拷了?`)
process.exit(2)
}
@@ -138,16 +136,10 @@ export async function runMigrations() {
// 自举时不拦:空库上没有数据可丢,0002 那串 DROP ... IF EXISTS 全是空转。
// 拦下来只会逼着每个新环境都带一次 OJ2_ALLOW_DESTRUCTIVE,把这道闸训练成习惯动作 ——
// 那正是它想避免的事。
if (
blocked.length > 0 &&
!bootstrapping &&
process.env.OJ2_ALLOW_DESTRUCTIVE !== "1"
) {
if (blocked.length > 0 && !bootstrapping && process.env.OJ2_ALLOW_DESTRUCTIVE !== "1") {
console.error(
"待执行的迁移里有破坏性语句,已停下:\n" +
blocked
.map(({ tag, reasons }) => ` · ${tag}${reasons.join(" / ")}`)
.join("\n") +
blocked.map(({ tag, reasons }) => ` · ${tag}${reasons.join(" / ")}`).join("\n") +
"\n\n这类改动不可逆,不该在一次日常部署里顺手执行。" +
"\n确认已经做过备份之后,用这个显式放行:\n\n" +
" OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh\n",
@@ -188,9 +180,7 @@ export async function runMigrations() {
function destructiveReasons(sql: string) {
const bare = stripComments(sql)
return DESTRUCTIVE_PATTERNS.filter(([re]) => re.test(bare)).map(
([, label]) => label,
)
return DESTRUCTIVE_PATTERNS.filter(([re]) => re.test(bare)).map(([, label]) => label)
}
/**
@@ -202,9 +192,7 @@ function destructiveReasons(sql: string) {
*/
function readMigrationTags(): Map<number, string> {
try {
const journal = JSON.parse(
readFileSync(`${migrationsDir}/meta/_journal.json`, "utf8"),
) as {
const journal = JSON.parse(readFileSync(`${migrationsDir}/meta/_journal.json`, "utf8")) as {
entries?: Array<{ when: number; tag: string }>
}
return new Map((journal.entries ?? []).map((e) => [e.when, e.tag]))
@@ -246,9 +234,7 @@ async function applyMigration(
) {
// 只留有可执行内容的段。`readMigrationFiles` 按 `--> statement-breakpoint` 切开后
// 保留原文,所以纯注释段(比如 0002 开头那一大段说明)会自成一段。
const statements = migration.sql.filter(
(stmt) => stripComments(stmt).trim() !== "",
)
const statements = migration.sql.filter((stmt) => stripComments(stmt).trim() !== "")
if (statements.length === 0) {
// 上游已经拦过一次(那条兜底检查),走到这里说明拦漏了,宁可响一声也别静默跳过
throw new Error(`${tag} 没有任何可执行语句`)
+229 -859
View File
File diff suppressed because it is too large Load Diff
+8 -27
View File
@@ -13,10 +13,7 @@ export const configUpdateChannel = "config:updates"
export const configTopic = "events:config"
export async function publishConfigUpdate(key: string, value: unknown) {
await redis.publish(
configUpdateChannel,
JSON.stringify({ type: "config_update", key, value }),
)
await redis.publish(configUpdateChannel, JSON.stringify({ type: "config_update", key, value }))
}
/**
@@ -42,19 +39,14 @@ export async function publishSessionRevoked(
target: { token: string } | { userId: number },
reason: SessionRevokedReason,
) {
await redis.publish(
sessionRevokedChannel,
JSON.stringify({ ...target, reason }),
)
await redis.publish(sessionRevokedChannel, JSON.stringify({ ...target, reason }))
}
export function parseSessionRevoked(raw: string): SessionRevoked | null {
try {
const value = JSON.parse(raw) as SessionRevoked
if (typeof value.token !== "string" && !Number.isInteger(value.userId))
return null
if (value.reason !== "session-ended" && value.reason !== "account-disabled")
return null
if (typeof value.token !== "string" && !Number.isInteger(value.userId)) return null
if (value.reason !== "session-ended" && value.reason !== "account-disabled") return null
return value
} catch {
return null
@@ -79,10 +71,7 @@ export function userEventTopic(userId: number) {
return `events:user:${userId}`
}
export async function publishFlowchartUpdate(
userId: number,
data: FlowchartUpdate,
) {
export async function publishFlowchartUpdate(userId: number, data: FlowchartUpdate) {
await redis.publish(userEventChannel, JSON.stringify({ userId, data }))
}
@@ -91,24 +80,16 @@ export async function publishAchievementNotification(
achievements: AchievementNotification[],
) {
if (!achievements.length) return
await redis.publish(
userEventChannel,
JSON.stringify({
await redis.publish(userEventChannel, JSON.stringify({
userId,
data: { type: "achievement_unlocked", achievements },
}),
)
}))
}
export function parseUserEvent(raw: string): UserEvent | null {
try {
const value = JSON.parse(raw) as UserEvent
if (
!Number.isInteger(value.userId) ||
!value.data ||
typeof value.data !== "object"
)
return null
if (!Number.isInteger(value.userId) || !value.data || typeof value.data !== "object") return null
return value
} catch {
return null
+12 -35
View File
@@ -32,18 +32,14 @@ function parseEvaluation(value: string) {
const json = block ?? value.match(/\{[\s\S]*\}/)?.[0]
if (!json) throw new Error("AI response did not contain JSON")
const data = JSON.parse(json) as Record<string, unknown>
if (typeof data.score !== "number" || Number.isNaN(data.score))
throw new Error("AI response is missing score")
if (typeof data.score !== "number" || Number.isNaN(data.score)) throw new Error("AI response is missing score")
const score = Math.max(0, Math.min(100, data.score))
return {
score,
grade: gradeForScore(score),
feedback: typeof data.feedback === "string" ? data.feedback : "",
suggestions: typeof data.suggestions === "string" ? data.suggestions : "",
criteria:
data.criteria_details && typeof data.criteria_details === "object"
? data.criteria_details
: {},
criteria: data.criteria_details && typeof data.criteria_details === "object" ? data.criteria_details : {},
}
}
@@ -51,34 +47,19 @@ export async function evaluateFlowchart(
job: FlowchartJobData,
{ isFinalAttempt = true }: { isFinalAttempt?: boolean } = {},
) {
const [row] = await db
.select({ flowchart: schema.flowchartSubmission, problem: schema.problem })
.from(schema.flowchartSubmission)
.innerJoin(
schema.problem,
eq(schema.flowchartSubmission.problemId, schema.problem.id),
)
.where(eq(schema.flowchartSubmission.id, job.submissionId))
.limit(1)
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
.where(eq(schema.flowchartSubmission.id, job.submissionId)).limit(1)
if (!row || ![0, 1].includes(row.flowchart.status)) return
await db
.update(schema.flowchartSubmission)
.set({ status: 1 })
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await db.update(schema.flowchartSubmission).set({ status: 1 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
const started = performance.now()
try {
const reference = row.problem.mermaidCode
? `\n标准答案参考:\n${row.problem.mermaidCode}`
: "\n此题没有标准流程图。"
const result = parseEvaluation(
await completeChat(
const reference = row.problem.mermaidCode ? `\n标准答案参考:\n${row.problem.mermaidCode}` : "\n此题没有标准流程图。"
const result = parseEvaluation(await completeChat(
evaluationPrompt(row.problem),
`学生流程图:\n${row.flowchart.mermaidCode}${reference}\n设计提示:${row.problem.flowchartHint ?? "无"}`,
),
)
await db
.update(schema.flowchartSubmission)
.set({
))
await db.update(schema.flowchartSubmission).set({
status: 2,
aiScore: result.score,
aiGrade: result.grade,
@@ -89,8 +70,7 @@ export async function evaluateFlowchart(
aiModel: process.env.AI_MODEL ?? "deepseek-flash",
processingTime: (performance.now() - started) / 1000,
evaluationTime: new Date().toISOString(),
})
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await publishFlowchartUpdate(row.flowchart.userId, {
type: "flowchart_evaluation_completed",
submissionId: row.flowchart.id,
@@ -110,10 +90,7 @@ export async function evaluateFlowchart(
// 一旦提前写成 3,队列配的 attempts: 3 就成了摆设 —— 后两次尝试进来什么都不做
// 就算成功,AI 侧的偶发失败(限流、超时、网络抖动)永远等不到重试。
if (!isFinalAttempt) throw error
await db
.update(schema.flowchartSubmission)
.set({ status: 3 })
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await publishFlowchartUpdate(row.flowchart.userId, {
type: "flowchart_evaluation_failed",
submissionId: row.flowchart.id,
+6 -22
View File
@@ -64,11 +64,7 @@ const DEFAULT_AVATAR_SVG =
* 生产环境这些请求也走后端(Caddy 把 /public/* 整段反代过来),不让 Caddy 直接读盘:
* 这样开发(Vite 代理)和生产是同一条代码路径,少一处只在服务器上才出错的差异。
*/
async function serveUpload(
pathname: string,
prefix: string,
directory: string,
) {
async function serveUpload(pathname: string, prefix: string, directory: string) {
const decoded = decodeURIComponent(pathname)
const filename = basename(decoded)
if (!filename || filename !== decoded.slice(prefix.length + 1)) {
@@ -77,9 +73,7 @@ async function serveUpload(
const file = Bun.file(resolve(directory, filename))
if (await file.exists()) {
// 文件名由后端生成且内容不变,可以放心长缓存
return new Response(file, {
headers: { "cache-control": "public, max-age=86400" },
})
return new Response(file, { headers: { "cache-control": "public, max-age=86400" } })
}
return null
}
@@ -89,18 +83,11 @@ const server = Bun.serve<SubmissionSocketData>({
async fetch(request, bunServer) {
const url = new URL(request.url)
if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) {
const hit = await serveUpload(
url.pathname,
config.avatarUriPrefix,
config.avatarDirectory,
)
const hit = await serveUpload(url.pathname, config.avatarUriPrefix, config.avatarDirectory)
if (hit) return hit
if (basename(decodeURIComponent(url.pathname)) === "default.png") {
return new Response(DEFAULT_AVATAR_SVG, {
headers: {
"content-type": "image/svg+xml",
"cache-control": "public, max-age=3600",
},
headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" },
})
}
return new Response("Not found", { status: 404 })
@@ -109,11 +96,8 @@ const server = Bun.serve<SubmissionSocketData>({
// 但没有任何路由伺服它,题面图片一律 404。
if (url.pathname.startsWith(`${config.uploadUriPrefix}/`)) {
return (
(await serveUpload(
url.pathname,
config.uploadUriPrefix,
config.uploadDirectory,
)) ?? new Response("Not found", { status: 404 })
(await serveUpload(url.pathname, config.uploadUriPrefix, config.uploadDirectory)) ??
new Response("Not found", { status: 404 })
)
}
if (
+99 -29
View File
@@ -4,7 +4,6 @@ import {
astNodeLabel,
astOperatorLabel,
astRuleIsMeaningful,
astTargetNodeType,
astRuleSchema,
AST_SUPPORTED_LANGUAGES,
type AstRequirement,
@@ -32,22 +31,100 @@ export interface AstResult {
actual?: number
}
/**
* target → tree-sitter 节点类型。恒等的条目(`+`、`==` 这些运算符)不列,
* 走 `mapping[target] ?? target` 回落。
*
* **这里的键集是契约 AST_NODE_TARGETS_BY_LANGUAGE 的另一半**,两边必须同增同减:
* 那边决定后台下拉能选什么,这边决定判题机认得什么。只加一边就是静默错判。
*/
const mappings: Record<string, Record<string, string>> = {
C: {
for_loop: "for_statement",
while_loop: "while_statement",
do_while: "do_statement",
if_statement: "if_statement",
else_clause: "else_clause",
break: "break_statement",
continue: "continue_statement",
function_definition: "function_definition",
return: "return_statement",
switch_statement: "switch_statement",
case_statement: "case_statement",
assignment: "assignment_expression",
struct: "struct_specifier",
include: "preproc_include",
and: "&&",
or: "||",
not: "!",
},
"C++": {
// C 的那 14 条原样通用(tree-sitter-cpp 继承 tree-sitter-c 的语法)
for_loop: "for_statement",
while_loop: "while_statement",
do_while: "do_statement",
if_statement: "if_statement",
else_clause: "else_clause",
break: "break_statement",
continue: "continue_statement",
function_definition: "function_definition",
return: "return_statement",
switch_statement: "switch_statement",
case_statement: "case_statement",
assignment: "assignment_expression",
struct: "struct_specifier",
include: "preproc_include",
// C++ 独有
range_for_loop: "for_range_loop",
class_definition: "class_specifier",
try_except: "try_statement",
throw: "throw_statement",
namespace: "namespace_definition",
template: "template_declaration",
lambda: "lambda_expression",
using: "using_declaration",
and: "&&",
or: "||",
not: "!",
},
Python3: {
for_loop: "for_statement",
while_loop: "while_statement",
if_statement: "if_statement",
else_clause: "else_clause",
elif_clause: "elif_clause",
break: "break_statement",
continue: "continue_statement",
function_definition: "function_definition",
return: "return_statement",
try_except: "try_statement",
with_statement: "with_statement",
list_comprehension: "list_comprehension",
list_literal: "list",
dict_literal: "dictionary",
set_literal: "set",
f_string: "format_string",
import: "import_statement",
import_from: "import_from_statement",
assignment: "assignment",
class_definition: "class_definition",
},
}
let initPromise: Promise<void> | undefined
const languages = new Map<string, Language>()
async function loadLanguage(language: string) {
if (!AST_SUPPORTED_LANGUAGES.includes(language)) return null
if (!(language in mappings)) return null
// locateFile 指到内嵌的 tree-sitter.wasmemscripten 默认按脚本所在目录找,
// 单二进制里那个目录是 /$bunfs/root,它自己找不着
if (!initPromise)
initPromise = Parser.init({ locateFile: () => treeSitterWasmPath })
if (!initPromise) initPromise = Parser.init({ locateFile: () => treeSitterWasmPath })
await initPromise
const cached = languages.get(language)
if (cached) return cached
const wasmPath =
language === "C"
const wasmPath = language === "C"
? cWasmPath
: language === "C++"
? cppWasmPath
@@ -138,8 +215,7 @@ function requirementKind(engine: AstRule["engine"]): AstRequirement["kind"] {
* checkAst 直接放行 —— 学生看得见要求,判题从不检查。
*/
export function astRequirements(value: unknown): AstRequirements | null {
const grouped =
value && typeof value === "object" && !Array.isArray(value)
const grouped = value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null
if (!grouped) return null
@@ -151,12 +227,10 @@ export function astRequirements(value: unknown): AstRequirements | null {
const parsed = astRuleSchema.safeParse(rule)
if (!parsed.success) return []
if (!astRuleIsMeaningful(parsed.data)) return []
return [
{
return [{
description: describeAstRule(parsed.data, language),
kind: requirementKind(parsed.data.engine),
},
]
}]
})
if (items.length > 0) out[language] = items
}
@@ -166,7 +240,7 @@ export function astRequirements(value: unknown): AstRequirements | null {
/**
* AST 规则的语义校验。zod 只管形状(engine 在枚举里、min 是整数),管不了
* 「给 C 题选了只有 Python 才有的 list_comprehension」这类组合 —— 那种规则存得进去,
* 判题时 astTargetNodeType() 找不到就回落成裸名去比节点类型,永远失败或永远通过,
* 判题时 `mapping[target] ?? target` 拿裸名去比节点类型,永远失败或永远通过,
* 两头都不报错,只有学生受着。
*
* 放这儿而不是 astRulesSchema 的 refine 上:那个 schema 同时用于**读**后台题目详情,
@@ -185,15 +259,12 @@ export function astRulesError(astRules: AstRules | null): string | null {
const at = `代码规则 ${language}${index + 1}`
const target = rule.target ?? ""
if (rule.engine.endsWith("_node")) {
if (!(target in nodes))
return `${at}${language} 没有「${target}」这种语法`
if (!(target in nodes)) return `${at}${language} 没有「${target}」这种语法`
} else if (rule.engine === "must_use_operator") {
if (!(target in operators))
return `${at}${language} 没有「${target}」运算符`
if (!(target in operators)) return `${at}${language} 没有「${target}」运算符`
} else if (rule.engine === "must_have_nesting") {
for (const value of [rule.outer ?? "", rule.inner ?? ""]) {
if (!(value in nodes))
return `${at}${language} 没有「${value}」这种语法`
if (!(value in nodes)) return `${at}${language} 没有「${value}」这种语法`
}
} else if (!target.trim()) {
return `${at}:要检查的函数名/方法名不能为空`
@@ -212,10 +283,7 @@ export function astRulesError(astRules: AstRules | null): string | null {
* 早年配过 C++ 规则,如今 tab 里看不到那组规则,保存却被「暂不支持 C++」拦下,
* 老师在界面上无从修改。
*/
export function pickAstRules(
astRules: AstRules | null,
languages: string[],
): AstRules | null {
export function pickAstRules(astRules: AstRules | null, languages: string[]): AstRules | null {
if (!astRules) return null
const out: AstRules = {}
for (const [language, rules] of Object.entries(astRules)) {
@@ -236,7 +304,7 @@ function rangePassed(count: number, rule: AstRule) {
const CALL_NODE_TYPES: Record<string, string> = {
C: "call_expression",
"C++": "call_expression",
Python: "call",
Python3: "call",
}
function functionCalls(root: Node, target: string, language: string) {
@@ -267,7 +335,7 @@ function methodCalls(root: Node, target: string, language: string) {
)
})
}
if (language !== "Python") return []
if (language !== "Python3") return []
return collectNodes(root, "call").filter((call) => {
const fn = call.childForFieldName("function")
return (
@@ -281,9 +349,10 @@ function evaluateRule(
root: Node,
rule: AstRule,
language: string,
mapping: Record<string, string>,
): AstResult | null {
const target = rule.target ?? ""
const nodeType = astTargetNodeType(target, language)
const nodeType = mapping[target] ?? target
switch (rule.engine) {
case "must_exist_node":
@@ -340,8 +409,8 @@ function evaluateRule(
case "must_have_nesting": {
const outer = rule.outer ?? ""
const inner = rule.inner ?? ""
const outerType = astTargetNodeType(outer, language)
const innerType = astTargetNodeType(inner, language)
const outerType = mapping[outer] ?? outer
const innerType = mapping[inner] ?? inner
const passed = collectNodes(root, outerType).some((node) =>
node.children.some((child) => hasNode(child, innerType)),
)
@@ -368,9 +437,10 @@ export async function checkAst(
const tree = parser.parse(code)
if (!tree) return { passed: true, results: [] }
try {
const mapping = mappings[language] ?? {}
const results = rules
.filter(astRuleIsMeaningful)
.map((rule) => evaluateRule(tree.rootNode, rule, language))
.map((rule) => evaluateRule(tree.rootNode, rule, language, mapping))
.filter((result): result is AstResult => result !== null)
return { passed: results.every((result) => result.passed), results }
} finally {
+57 -50
View File
@@ -1,39 +1,4 @@
import { normalizeLanguage } from "@oj2/contract"
/**
* 判题沙箱认得的语言,**只有 C / C++ / Python 这三种**。
*
* Java / Golang / JavaScript 在 2026-09 连同镜像里的 JDK、Go、Node 工具链一起砍掉了:
* 前端从来没给过它们入口(后台题目的语言复选框只有 Python / C / C++ / SQL),
* 生产库 12 万条提交里它们一共 62 条,全是很早以前的。砍掉之后判题镜像小了一半多。
*
* 契约 `judgeLanguageSchema` 里那几个键**故意留着** —— 那是渲染历史提交要用的。
* 想恢复某种语言,得同时改这里和 `docker/judge/Dockerfile` 的工具链,再重建镜像。
*
* `Python` 这个键 2026-09 之前叫 `Python3`(库里还有 3 条更老的 `Python2`),
* 0019 迁移把数据并成了一个值。查配置一律走 `judgeConfigFor()`,别直接下标 ——
* 那里带着旧值的别名,迁移之前排进队列的任务、旧客户端传上来的值都还能判。
*
* SQL 题不走这里,走 `judge/sql/`;流程图题走 AI 评分。
*/
const defaultEnv = [
"LANG=en_US.UTF-8",
"LANGUAGE=en_US:en",
"LC_ALL=en_US.UTF-8",
]
/**
* gcc-14 起这三类老写法从 warning 提成了 error,而 `-w` 只关警告、压不住 error
* 隐式函数声明(忘了 `#include <stdio.h>` 就用 printf)、int 与指针互赋、
* 不兼容的指针类型。判题机镜像 2026-09 从 gcc-13 升到 14(见 docker/judge/),
* 不加这三个开关的话,**一批历史题解和 20 篇 C 教程的示例会突然全部 CE**。
*
* 只给 C 加:C++ 那边这些本来就是 error,g++ 升版不改判定。
* 哪天决定「就是要学生写规范」,是删掉这三行,不是改镜像 —— 删之前先拿
* docs/c-tutorials/verify-code.sh 全量过一遍教程。
*/
const cLooseErrors =
"-Wno-error=implicit-function-declaration -Wno-error=int-conversion -Wno-error=incompatible-pointer-types"
const defaultEnv = ["LANG=en_US.UTF-8", "LANGUAGE=en_US:en", "LC_ALL=en_US.UTF-8"]
export const languageConfigs: Record<string, Record<string, unknown>> = {
C: {
@@ -44,7 +9,8 @@ export const languageConfigs: Record<string, Record<string, unknown>> = {
max_cpu_time: 3000,
max_real_time: 10000,
max_memory: 256 * 1024 * 1024,
compile_command: `/usr/bin/gcc -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c17 ${cLooseErrors} {src_path} -lm -o {exe_path}`,
compile_command:
"/usr/bin/gcc -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c17 {src_path} -lm -o {exe_path}",
},
run: {
command: "{exe_path}",
@@ -69,7 +35,24 @@ export const languageConfigs: Record<string, Record<string, unknown>> = {
env: defaultEnv,
},
},
Python: {
Java: {
template: "",
compile: {
src_name: "Main.java",
exe_name: "Main",
max_cpu_time: 5000,
max_real_time: 10000,
max_memory: -1,
compile_command: "/usr/bin/javac {src_path} -d {exe_dir}",
},
run: {
command: "/usr/bin/java -cp {exe_dir} -XX:MaxRAM={max_memory}k Main",
seccomp_rule: null,
env: defaultEnv,
memory_limit_check_only: 1,
},
},
Python3: {
template: "",
compile: {
src_name: "solution.py",
@@ -85,16 +68,40 @@ export const languageConfigs: Record<string, Record<string, unknown>> = {
env: defaultEnv,
},
},
}
/**
* 按语言取判题配置。**判题侧一律走这个函数**,不要直接 `languageConfigs[x]`
* 它先过 `normalizeLanguage()`,所以 `Python3` / `Python2` 这类旧值也能命中。
*/
export function judgeConfigFor(language: string) {
return (
languageConfigs[language] ??
languageConfigs[normalizeLanguage(language) ?? ""] ??
null
)
Golang: {
template: "",
compile: {
src_name: "main.go",
exe_name: "main",
max_cpu_time: 3000,
max_real_time: 5000,
max_memory: 1024 * 1024 * 1024,
compile_command: "/usr/bin/go build -o {exe_path} {src_path}",
env: ["GOCACHE=/tmp", "GOPATH=/tmp", "GOMAXPROCS=1", ...defaultEnv],
},
run: {
command: "{exe_path}",
seccomp_rule: "golang",
env: ["GOMAXPROCS=1", ...defaultEnv],
memory_limit_check_only: 1,
},
},
JavaScript: {
template: "",
compile: {
src_name: "main.js",
exe_name: "main.js",
max_cpu_time: 3000,
max_real_time: 5000,
max_memory: 1024 * 1024 * 1024,
compile_command: "/usr/bin/node --check {src_path}",
env: defaultEnv,
},
run: {
command: "/usr/bin/node {exe_path}",
seccomp_rule: "node",
env: defaultEnv,
memory_limit_check_only: 1,
},
},
}
+38 -94
View File
@@ -6,16 +6,17 @@ import { and, eq, inArray } from "drizzle-orm"
import { config } from "../config"
import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
import {
updateAchievementsForProblemSet,
updateAchievementsForSubmission,
} from "../services/achievements"
import { updateAchievementsForProblemSet, updateAchievementsForSubmission } from "../services/achievements"
import { recordSolvedProblem } from "../services/problemset"
import { checkAst, type AstRule } from "./ast"
import { publishSubmissionUpdate } from "./events"
import type { JudgeJobData } from "./job"
import { judgeConfigFor } from "./languages"
import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
import { languageConfigs } from "./languages"
import {
isAccepted,
JudgeStatus,
type JudgeStatusValue,
} from "./status"
import { parseProblemTemplate } from "./template"
import { runSqlCase } from "./sql"
import { readInfo } from "../services/test-case"
@@ -77,9 +78,8 @@ async function requestJudge(
memoryLimit: number,
testCaseId: string,
) {
const languageConfig = judgeConfigFor(language)
if (!languageConfig)
throw new Error(`Unsupported judge language: ${language}`)
const languageConfig = languageConfigs[language]
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`)
const token = createHash("sha256")
.update(config.judgeServerToken)
@@ -173,7 +173,8 @@ async function persistResult(
.update(schema.problem)
.set({
submissionNumber: problem.submissionNumber + 1,
acceptedNumber: problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
acceptedNumber:
problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
statisticInfo: problemStatistics,
})
.where(eq(schema.problem.id, problemId))
@@ -232,10 +233,7 @@ async function persistResult(
submissionInfo: {},
})
.onConflictDoNothing({
target: [
schema.acmContestRank.contestId,
schema.acmContestRank.userId,
],
target: [schema.acmContestRank.contestId, schema.acmContestRank.userId],
})
const [rank] = await tx
@@ -269,8 +267,7 @@ async function persistResult(
const acTime = Math.max(
0,
Math.floor(
(Date.parse(submissionCreateTime) -
Date.parse(contest.startTime)) /
(Date.parse(submissionCreateTime) - Date.parse(contest.startTime)) /
1000,
),
)
@@ -296,11 +293,7 @@ async function persistResult(
})
}
async function markSystemError(
submissionId: string,
userId: number,
error: unknown,
) {
async function markSystemError(submissionId: string, userId: number, error: unknown) {
const message = error instanceof Error ? error.message : String(error)
const updated = await db
.update(schema.submission)
@@ -344,10 +337,7 @@ async function markSystemError(
* 都不会被它覆盖。唯一能撞上的是「重判刚把状态置回 PENDING,同一刻上一个被遗弃的
* 任务才失败」——结果是这次重判被吃掉、显示成系统错误,比静默卡死看得见。
*/
export async function failAbandonedSubmission(
submissionId: string,
error: unknown,
) {
export async function failAbandonedSubmission(submissionId: string, error: unknown) {
const [row] = await db
.select({ userId: schema.submission.userId })
.from(schema.submission)
@@ -364,10 +354,7 @@ export async function judgeSubmission(job: JudgeJobData) {
problem: schema.problem,
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(
and(
eq(schema.submission.id, job.submissionId),
@@ -377,11 +364,7 @@ export async function judgeSubmission(job: JudgeJobData) {
.limit(1)
if (!row) throw new Error(`Submission ${job.submissionId} does not exist`)
if (
![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(
row.submission.result as 6 | 7,
)
) {
if (![JudgeStatus.PENDING, JudgeStatus.JUDGING].includes(row.submission.result as 6 | 7)) {
return
}
@@ -408,8 +391,7 @@ export async function judgeSubmission(job: JudgeJobData) {
// SQL 题不经判题沙箱:沙箱是给编译型/脚本型语言用的,SQL 判的是结果集,
// 走 judge/sql 的 WASM 引擎(在独立子进程里跑,见那边的说明)。
const response =
row.submission.language === "SQL"
const response = row.submission.language === "SQL"
? await judgeSqlSubmission(row.problem, row.submission.code)
: await requestJudge(
row.submission.language,
@@ -440,19 +422,11 @@ export async function judgeSubmission(job: JudgeJobData) {
(left, right) => Number(left.test_case) - Number(right.test_case),
)
info = { err: null, data: cases }
const firstFailure = cases.find(
(item) => item.result !== JudgeStatus.ACCEPTED,
)
const firstFailure = cases.find((item) => item.result !== JudgeStatus.ACCEPTED)
result = statusValue(firstFailure?.result ?? JudgeStatus.ACCEPTED)
statisticInfo = {
time_cost: Math.max(
0,
...cases.map((item) => Number(item.cpu_time) || 0),
),
memory_cost: Math.max(
0,
...cases.map((item) => Number(item.memory) || 0),
),
time_cost: Math.max(0, ...cases.map((item) => Number(item.cpu_time) || 0)),
memory_cost: Math.max(0, ...cases.map((item) => Number(item.memory) || 0)),
score: 0,
}
// SQL 判题给出的中文提示(只读拒绝/超时/内存/无结果集)只存在测试点的
@@ -461,8 +435,7 @@ export async function judgeSubmission(job: JudgeJobData) {
const failedMessage = cases.find(
(item) => item.result !== JudgeStatus.ACCEPTED && item.error_message,
)?.error_message
if (typeof failedMessage === "string")
statisticInfo.err_info = failedMessage
if (typeof failedMessage === "string") statisticInfo.err_info = failedMessage
if (result === JudgeStatus.ACCEPTED) {
const rules = astRulesForLanguage(
@@ -510,60 +483,43 @@ export async function judgeSubmission(job: JudgeJobData) {
row.submission.createTime,
)
if (earned.length > 0) {
await publishAchievementNotification(
row.submission.userId,
earned.map((badge) => ({
await publishAchievementNotification(row.submission.userId, earned.map((badge) => ({
id: badge.id,
name: badge.name,
description: badge.description,
icon: badge.icon,
rarity: "bronze",
kind: "badge",
})),
)
})))
}
if (updated > 0) {
const unlocked = await updateAchievementsForProblemSet(
row.submission.userId,
)
await publishAchievementNotification(
row.submission.userId,
unlocked.map((achievement) => ({
const unlocked = await updateAchievementsForProblemSet(row.submission.userId)
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
})),
)
})))
}
} catch (error) {
console.error(
`Failed to record problem set progress for ${row.submission.id}`,
error,
)
console.error(`Failed to record problem set progress for ${row.submission.id}`, error)
}
}
try {
const unlocked = await updateAchievementsForSubmission(row.submission.id)
await publishAchievementNotification(
row.submission.userId,
unlocked.map((achievement) => ({
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
})),
)
})))
} catch (error) {
console.error(
`Failed to update achievements for ${row.submission.id}`,
error,
)
console.error(`Failed to update achievements for ${row.submission.id}`, error)
}
await publishSubmissionUpdate(row.submission.userId, {
@@ -572,9 +528,7 @@ export async function judgeSubmission(job: JudgeJobData) {
result,
status: "finished",
score:
typeof statisticInfo.score === "number"
? statisticInfo.score
: undefined,
typeof statisticInfo.score === "number" ? statisticInfo.score : undefined,
})
} catch (error) {
console.error(`Failed to judge submission ${row.submission.id}`, error)
@@ -582,6 +536,7 @@ export async function judgeSubmission(job: JudgeJobData) {
}
}
/**
* SQL 题判题:逐个测试点用各自的初始化脚本跑一遍,产出与沙箱同构的结果结构,
* 好让上面的状态聚合、统计、排名、WebSocket 推送逻辑完全复用。
@@ -599,23 +554,15 @@ async function judgeSqlSubmission(
const answers = Array.isArray(problem.answers) ? problem.answers : []
const refSql = answers
.map((item) => objectValue(item))
.find(
(item) =>
item.language === "SQL" &&
typeof item.code === "string" &&
item.code.trim(),
)?.code
.find((item) => item.language === "SQL" && typeof item.code === "string" && item.code.trim())?.code
if (typeof refSql !== "string") throw new Error("题目缺少 SQL 标准答案")
const info = await readInfo(problem.testCaseId)
if (!info) throw new Error("测试点信息读取失败")
if (!info.sql)
throw new Error("测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包")
if (!info.sql) throw new Error("测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包")
// 按 "1","2",… 的数字序遍历,保证测试点顺序稳定
const keys = Object.keys(info.test_cases ?? {}).sort(
(a, b) => Number(a) - Number(b),
)
const keys = Object.keys(info.test_cases ?? {}).sort((a, b) => Number(a) - Number(b))
if (keys.length === 0) throw new Error("题目没有任何测试点")
const cases: JudgeCase[] = []
@@ -624,9 +571,7 @@ async function judgeSqlSubmission(
const initSql = await readFile(
resolvePath(config.testCaseDirectory, problem.testCaseId, inputName),
"utf8",
).catch(() => {
throw new Error(`测试点脚本 ${inputName} 读取失败`)
})
).catch(() => { throw new Error(`测试点脚本 ${inputName} 读取失败`) })
const outcome = await runSqlCase({
kind: "judge",
@@ -640,8 +585,7 @@ async function judgeSqlSubmission(
})
if (!outcome.ok) {
// 初始化/标准答案执行失败属出题配置问题,整题 SYSTEM_ERROR
if (outcome.result === JudgeStatus.SYSTEM_ERROR)
throw new Error(outcome.message)
if (outcome.result === JudgeStatus.SYSTEM_ERROR) throw new Error(outcome.message)
// 子进程被杀(超时/内存)也走这里,按学生错误记成一个测试点
cases.push({
test_case: String(index + 1),
+3 -11
View File
@@ -29,12 +29,7 @@ export type SqlJob =
timeLimitMs: number
memoryLimitMb: number
}
| {
kind: "display"
initSql: string
refSql: string
mode: "query" | "modify"
}
| { kind: "display"; initSql: string; refSql: string; mode: "query" | "modify" }
/**
* 写阶段标记。必须用 writeSync:父进程正是靠这个标记决定「多久之后 SIGKILL」
@@ -95,13 +90,10 @@ export async function runSqlChild() {
// WASM 堆触顶时 emscripten 抛的是普通 Error"Aborted"/"out of memory"),
// 到这里说明连引擎自身都没撑住,按内存超限报,不当成出题人的错
const message = String((error as Error)?.message ?? error)
const memoryish =
message.includes("out of memory") || message.includes("Aborted")
const memoryish = message.includes("out of memory") || message.includes("Aborted")
finish({
ok: false,
result: memoryish
? JudgeStatus.MEMORY_LIMIT_EXCEEDED
: JudgeStatus.SYSTEM_ERROR,
result: memoryish ? JudgeStatus.MEMORY_LIMIT_EXCEEDED : JudgeStatus.SYSTEM_ERROR,
message: memoryish ? "内存超出限制" : message.slice(0, 200),
})
}
+46 -185
View File
@@ -45,17 +45,10 @@ const DISPLAY_ROW_LIMIT = 20
const ERROR_MESSAGE_MAX_LEN = 200
/** prepare 阶段的语法类错误,映射为 COMPILE_ERROR */
const SYNTAX_ERROR_MARKERS = [
"syntax error",
"unrecognized token",
"incomplete input",
]
const SYNTAX_ERROR_MARKERS = ["syntax error", "unrecognized token", "incomplete input"]
export class SqlCaseError extends Error {
constructor(
readonly result: JudgeStatusValue,
readonly detail: string,
) {
constructor(readonly result: JudgeStatusValue, readonly detail: string) {
super(detail)
}
}
@@ -89,11 +82,9 @@ type Canonical = string
*/
function canonicalValue(value: unknown): Canonical {
if (value === null || value === undefined) return "null"
if (value instanceof Uint8Array)
return `blob:${Buffer.from(value).toString("hex")}`
if (value instanceof Uint8Array) return `blob:${Buffer.from(value).toString("hex")}`
if (typeof value === "number") {
if (Number.isInteger(value) && Math.abs(value) < 2 ** 53)
return `num:${value}`
if (Number.isInteger(value) && Math.abs(value) < 2 ** 53) return `num:${value}`
// Python 的 format(v, ".6g")
return `num:${formatG6(value)}`
}
@@ -105,10 +96,7 @@ function canonicalValue(value: unknown): Canonical {
function formatG6(value: number) {
const exponent = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)))
if (exponent < -4 || exponent >= 6) {
return value
.toExponential(5)
.replace(/\.?0+e/, "e")
.replace(/e([+-])(\d)$/, "e$10$2")
return value.toExponential(5).replace(/\.?0+e/, "e").replace(/e([+-])(\d)$/, "e$10$2")
}
const text = value.toPrecision(6)
return text.includes(".") ? text.replace(/\.?0+$/, "") : text
@@ -150,11 +138,9 @@ interface PreparedStatement {
}
function iterate(db: Database, script: string): Iterable<PreparedStatement> {
return (
db as unknown as {
return (db as unknown as {
iterateStatements(sql: string): Iterable<PreparedStatement>
}
).iterateStatements(script)
}).iterateStatements(script)
}
/**
@@ -171,17 +157,9 @@ function leadingKeyword(statement: PreparedStatement) {
}
// 万一这个 build 没开 SQLITE_ENABLE_NORMALIZE,退回到原文剥注释
if (!text) {
text = statement
.getSQL()
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/--[^\n]*/g, " ")
text = statement.getSQL().replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ")
}
return (
text
.trimStart()
.split(/[\s(;]/, 1)[0]
?.toUpperCase() ?? ""
)
return text.trimStart().split(/[\s(;]/, 1)[0]?.toUpperCase() ?? ""
}
/**
@@ -209,17 +187,11 @@ class ByteBudget {
? Buffer.byteLength(value)
: 8 // 数字和 NULL 按定长算,撑不出内存
if (bytes > this.maxBytes) {
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
"单个数据值超出内存限制",
)
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制")
}
this.used += bytes
if (this.used > this.maxBytes) {
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
"查询结果超出内存限制",
)
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "查询结果超出内存限制")
}
}
}
@@ -254,17 +226,12 @@ function executeStatements(
budget?.charge(row)
rows.push(canonicalRow(row))
if (rows.length > ROW_LIMIT) {
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
`查询结果超过 ${ROW_LIMIT}`,
)
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `查询结果超过 ${ROW_LIMIT}`)
}
}
last = { columns: names.length, rows }
} else {
while (statement.step()) {
/* 无结果集语句,推进到结束 */
}
while (statement.step()) { /* 无结果集语句,推进到结束 */ }
}
} finally {
statement.free()
@@ -275,10 +242,7 @@ function executeStatements(
/** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */
function dumpTables(db: Database, budget?: ByteBudget) {
const names = queryColumn(
db,
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
)
const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
const state: Record<string, { columns: number; rows: string[] }> = {}
for (const table of names) {
const quoted = String(table).replaceAll('"', '""')
@@ -289,10 +253,7 @@ function dumpTables(db: Database, budget?: ByteBudget) {
return canonicalRow(row as unknown[])
})
if (rows.length > ROW_LIMIT) {
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
`${table} 超过 ${ROW_LIMIT}`,
)
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, `${table} 超过 ${ROW_LIMIT}`)
}
state[String(table)] = {
// 空表 exec 不返回结果,列数用 table_info 兜底
@@ -317,25 +278,14 @@ function trustedErrorText(message: string) {
}
/** 执行受信脚本(初始化/标准答案),任何失败都是出题问题 → SYSTEM_ERROR */
function executeTrusted(
db: Database,
script: string,
deadline: number,
prefix: string,
) {
function executeTrusted(db: Database, script: string, deadline: number, prefix: string) {
try {
return executeStatements(db, script, deadline)
} catch (error) {
if (error instanceof SqlCaseError) {
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`${prefix}: ${error.detail}`,
)
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `${prefix}: ${error.detail}`)
}
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`${prefix}: ${trustedErrorText(String((error as Error).message))}`,
)
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `${prefix}: ${trustedErrorText(String((error as Error).message))}`)
}
}
@@ -350,63 +300,36 @@ function runStudent(
// 查询题只读:PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单
if (mode === "query") db.run("PRAGMA query_only=1")
// 把题目的 memoryLimit 变成学生看得见的约束,替代旧实现的 setlimit(LIMIT_LENGTH)
const budget = new ByteBudget(
Math.max(Math.trunc(memoryLimitMb), 1) * 1024 * 1024,
)
const budget = new ByteBudget(Math.max(Math.trunc(memoryLimitMb), 1) * 1024 * 1024)
try {
const last = executeStatements(
db,
script,
deadline,
(statement) => {
const last = executeStatements(db, script, deadline, (statement) => {
// query_only 自己就是个 PRAGMA,不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
// 就把只读关掉了。旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒掉,这里对齐它。
// 教学场景下学生也没有用 PRAGMA 的正当需求,两种题型一律拒。
if (leadingKeyword(statement) === "PRAGMA") {
throw new SqlCaseError(
JudgeStatus.RUNTIME_ERROR,
"禁止使用 PRAGMA 语句",
)
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "禁止使用 PRAGMA 语句")
}
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
applyLimits(db, memoryLimitMb)
if (mode === "query") db.run("PRAGMA query_only=1")
},
budget,
)
}, budget)
if (mode === "query") return last
return dumpTables(db, budget)
} catch (error) {
if (error instanceof SqlCaseError) throw error
const message = String((error as Error).message)
if (message.includes("interrupted")) {
throw new SqlCaseError(
JudgeStatus.CPU_TIME_LIMIT_EXCEEDED,
"SQL 执行超时",
)
throw new SqlCaseError(JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, "SQL 执行超时")
}
if (message.includes("database or disk is full")) {
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
"数据量超出内存限制",
)
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "数据量超出内存限制")
}
// WASM 堆触顶(zeroblob/group_concat 构造出的超大单值)或 SQLite 自身的长度上限
if (
message.includes("too big") ||
message.includes("out of memory") ||
message.includes("Aborted")
) {
throw new SqlCaseError(
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
"单个数据值超出内存限制",
)
if (message.includes("too big") || message.includes("out of memory") || message.includes("Aborted")) {
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制")
}
if (message.includes("readonly database")) {
throw new SqlCaseError(
JudgeStatus.RUNTIME_ERROR,
"本题为查询题,禁止修改数据或表结构(INSERT/UPDATE/DELETE/CREATE 等)",
)
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "本题为查询题,禁止修改数据或表结构(INSERT/UPDATE/DELETE/CREATE 等)")
}
if (SYNTAX_ERROR_MARKERS.some((marker) => message.includes(marker))) {
throw new SqlCaseError(JudgeStatus.COMPILE_ERROR, truncate(message))
@@ -414,11 +337,7 @@ function runStudent(
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, truncate(message))
} finally {
if (mode === "query") {
try {
db.run("PRAGMA query_only=0")
} catch {
/* 连接可能已不可用 */
}
try { db.run("PRAGMA query_only=0") } catch { /* 连接可能已不可用 */ }
}
}
}
@@ -493,22 +412,14 @@ export async function runCase(
const refDb = newDatabase(SQL, options.memoryLimitMb)
try {
executeTrusted(refDb, initSql, trustedDeadline, "初始化脚本执行失败")
const last = executeTrusted(
refDb,
refSql,
trustedDeadline,
"标准答案执行失败",
)
const last = executeTrusted(refDb, refSql, trustedDeadline, "标准答案执行失败")
if (options.mode === "query") {
expected = last
} else {
try {
expected = dumpTables(refDb)
} catch (error) {
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`标准答案结果超出限制: ${(error as SqlCaseError).detail}`,
)
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超出限制: ${(error as SqlCaseError).detail}`)
}
}
} finally {
@@ -549,13 +460,7 @@ export async function runCase(
} catch (error) {
elapsed = Date.now() - start
const failure = error as SqlCaseError
return {
...result,
result: failure.result,
error_message: failure.detail,
cpu_time: elapsed,
real_time: elapsed,
}
return { ...result, result: failure.result, error_message: failure.detail, cpu_time: elapsed, real_time: elapsed }
}
elapsed = Date.now() - start
} finally {
@@ -591,35 +496,20 @@ interface DisplayTable {
/** 按建表顺序 dump 用户表的原始行用于展示(区别于 dumpTables 的归一化判题态) */
function dumpDisplayTables(db: Database, only?: Set<string>): DisplayTable[] {
const names = queryColumn(
db,
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
)
const names = queryColumn(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
const tables: DisplayTable[] = []
for (const raw of names) {
const name = String(raw)
if (only && !only.has(name)) continue
const quoted = name.replaceAll('"', '""')
const columns = (
db.exec(`PRAGMA table_info("${quoted}")`)[0]?.values ?? []
).map((row) => ({
const columns = (db.exec(`PRAGMA table_info("${quoted}")`)[0]?.values ?? []).map((row) => ({
name: String(row[1]),
type: String(row[2] ?? ""),
}))
const total = Number(
db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0,
)
const rows = (
db.exec(`SELECT * FROM "${quoted}" LIMIT ${DISPLAY_ROW_LIMIT}`)[0]
?.values ?? []
).map((row) => (row as unknown[]).map(displayValue))
tables.push({
name,
columns,
rows,
total_rows: total,
truncated: total > DISPLAY_ROW_LIMIT,
})
const total = Number(db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0)
const rows = (db.exec(`SELECT * FROM "${quoted}" LIMIT ${DISPLAY_ROW_LIMIT}`)[0]?.values ?? [])
.map((row) => (row as unknown[]).map(displayValue))
tables.push({ name, columns, rows, total_rows: total, truncated: total > DISPLAY_ROW_LIMIT })
}
return tables
}
@@ -656,27 +546,17 @@ export async function buildDisplay(
for (const statement of iterate(db, refSql)) {
try {
const names = statement.getColumnNames()
if (names.length === 0) {
while (statement.step()) {
/* 无结果集 */
}
continue
}
if (names.length === 0) { while (statement.step()) { /* 无结果集 */ } ; continue }
const rows: unknown[][] = []
while (statement.step()) {
rows.push(statement.get())
if (rows.length > ROW_LIMIT) {
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`标准答案结果超过 ${ROW_LIMIT}`,
)
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超过 ${ROW_LIMIT}`)
}
}
expected = {
columns: queryResultColumns(names, tables),
rows: rows
.slice(0, DISPLAY_ROW_LIMIT)
.map((row) => row.map(displayValue)),
rows: rows.slice(0, DISPLAY_ROW_LIMIT).map((row) => row.map(displayValue)),
total_rows: rows.length,
truncated: rows.length > DISPLAY_ROW_LIMIT,
}
@@ -686,16 +566,10 @@ export async function buildDisplay(
}
} catch (error) {
if (error instanceof SqlCaseError) throw error
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
`标准答案执行失败: ${trustedErrorText(String((error as Error).message))}`,
)
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案执行失败: ${trustedErrorText(String((error as Error).message))}`)
}
if (expected === null) {
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
"标准答案未产生查询结果集",
)
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集")
}
return { tables, expected }
}
@@ -704,31 +578,18 @@ export async function buildDisplay(
executeTrusted(db, refSql, deadline, "标准答案执行失败")
const after = dumpTables(db)
const changed = new Set<string>()
for (const name of new Set([
...Object.keys(before),
...Object.keys(after),
])) {
if (JSON.stringify(before[name]) !== JSON.stringify(after[name]))
changed.add(name)
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
if (JSON.stringify(before[name]) !== JSON.stringify(after[name])) changed.add(name)
}
if (changed.size === 0) {
throw new SqlCaseError(
JudgeStatus.SYSTEM_ERROR,
"标准答案未修改任何表数据,请检查题目配置",
)
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未修改任何表数据,请检查题目配置")
}
const changedTables = dumpDisplayTables(db, changed)
// 被标准答案 DROP 的表已不在库中,用初始展示数据补齐条目(前端据 dropped 提示「表已删除」)
const existing = new Set(changedTables.map((table) => table.name))
for (const table of tables) {
if (changed.has(table.name) && !existing.has(table.name)) {
changedTables.push({
...table,
rows: [],
total_rows: 0,
truncated: false,
dropped: true,
})
changedTables.push({ ...table, rows: [], total_rows: 0, truncated: false, dropped: true })
}
}
return { tables, expected: { changed_tables: changedTables } }
+10 -34
View File
@@ -85,24 +85,16 @@ const PHASE_FAILURE: Record<string, SqlJobFailure> = {
result: JudgeStatus.SYSTEM_ERROR,
message: "初始化脚本或标准答案超时/内存超限,请检查题目配置",
},
student: {
ok: false,
result: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED,
message: "SQL 执行超时",
},
student: { ok: false, result: JudgeStatus.CPU_TIME_LIMIT_EXCEEDED, message: "SQL 执行超时" },
}
async function runJob<T>(
job: SqlJob,
budget: JobBudget,
): Promise<SqlJobOutcome<T>> {
async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<T>> {
// 递归闸。子进程里绝不允许再 spawn 子进程 —— 见文件头「为什么必须有这道闸」。
if (process.env[CHILD_MARKER]) {
return {
ok: false,
result: JudgeStatus.SYSTEM_ERROR,
message:
"SQL 判题子进程试图再起子进程,已阻断(入口子命令分发可能不正确)",
message: "SQL 判题子进程试图再起子进程,已阻断(入口子命令分发可能不正确)",
}
}
@@ -123,10 +115,7 @@ async function runJob<T>(
child.stdin.write(JSON.stringify(job))
await child.stdin.end()
let timer = setTimeout(
() => child.kill("SIGKILL"),
budget.trustedMs + STARTUP_SLACK_MS,
)
let timer = setTimeout(() => child.kill("SIGKILL"), budget.trustedMs + STARTUP_SLACK_MS)
let phase = ""
// stderr 要边读边看:阶段标记一到就得马上换兜底时限,攒到进程结束再读就没意义了
const readStderr = (async () => {
@@ -143,10 +132,7 @@ async function runJob<T>(
phase = latest
if (phase === "student" && budget.studentMs !== null) {
clearTimeout(timer)
timer = setTimeout(
() => child.kill("SIGKILL"),
budget.studentMs + STUDENT_SLACK_MS,
)
timer = setTimeout(() => child.kill("SIGKILL"), budget.studentMs + STUDENT_SLACK_MS)
}
}
}
@@ -154,10 +140,7 @@ async function runJob<T>(
let stdout = ""
try {
;[stdout] = await Promise.all([
new Response(child.stdout).text(),
readStderr,
])
;[stdout] = await Promise.all([new Response(child.stdout).text(), readStderr])
await child.exited
} finally {
clearTimeout(timer)
@@ -177,15 +160,12 @@ async function runJob<T>(
try {
const parsed = JSON.parse(stdout) as
{ ok: true; case?: CaseResult; display?: unknown } | SqlJobFailure
| { ok: true; case?: CaseResult; display?: unknown }
| SqlJobFailure
if (!parsed.ok) return parsed
return { ok: true, value: (parsed.case ?? parsed.display) as T }
} catch {
return {
ok: false,
result: JudgeStatus.SYSTEM_ERROR,
message: "SQL 判题子进程返回了无法解析的结果",
}
return { ok: false, result: JudgeStatus.SYSTEM_ERROR, message: "SQL 判题子进程返回了无法解析的结果" }
}
}
@@ -196,11 +176,7 @@ export function runSqlCase(job: Extract<SqlJob, { kind: "judge" }>) {
})
}
export function buildSqlDisplay(
initSql: string,
refSql: string,
mode: "query" | "modify",
) {
export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") {
// 子进程产出的形状由 engine.ts 的 dumpDisplayTables / runDisplay 决定,就是契约里的
// SqlDisplay —— 同一个仓库里的两端,不在这儿再 parse 一遍
return runJob<SqlDisplay>(
+18 -12
View File
@@ -1,17 +1,26 @@
import { JudgeStatus, type JudgeStatusValue } from "@oj2/contract"
export const JudgeStatus = {
COMPILE_ERROR: -2,
WRONG_ANSWER: -1,
ACCEPTED: 0,
CPU_TIME_LIMIT_EXCEEDED: 1,
REAL_TIME_LIMIT_EXCEEDED: 2,
MEMORY_LIMIT_EXCEEDED: 3,
RUNTIME_ERROR: 4,
SYSTEM_ERROR: 5,
PENDING: 6,
JUDGING: 7,
PARTIALLY_ACCEPTED: 8,
AST_CHECK_FAILED: 10,
} as const
// 状态码的唯一一份在 packages/contract/src/judge-status.ts,这里只再导出,
// 省得二十几处 import 一起改
export { JudgeStatus, type JudgeStatusValue }
export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus]
export function isAccepted(result: number) {
return (
result === JudgeStatus.ACCEPTED || result === JudgeStatus.AST_CHECK_FAILED
)
return result === JudgeStatus.ACCEPTED || result === JudgeStatus.AST_CHECK_FAILED
}
/**
* 判题状态的中文名,和前端 `utils/constants.ts` 的 `JUDGE_STATUS` 措辞对应(状态码本身已收进契约,名字仍是两份)
* 判题状态的中文名,和前端 `utils/constants.ts` 的 `JUDGE_STATUS` 一致,两边必须同步
* 目前只用在喂给模型的 prompt 里 —— 原来那里拼的是裸状态码(`结果:-1`),
* 模型根本不知道 -1 是「答案错误」还是别的什么,等于白给一条信息。
*/
@@ -39,10 +48,7 @@ export function judgeStatusName(result: number) {
* 它们从分母里摘掉 —— 否则全班同时交卷的那几秒,分母涨了分子没涨,正确率凭空掉一截。
* 人数口径不受影响:交了但还在判的学生仍然算「交过」,不该被点名成「没做」。
*/
export const UNJUDGED_RESULTS: JudgeStatusValue[] = [
JudgeStatus.PENDING,
JudgeStatus.JUDGING,
]
export const UNJUDGED_RESULTS: JudgeStatusValue[] = [JudgeStatus.PENDING, JudgeStatus.JUDGING]
/**
* **不**计入「这道题失败了几次」的状态。除了通过(含 AST_CHECK_FAILED,那也是答案对了)
+14 -9
View File
@@ -12,7 +12,8 @@
* oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
* oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演
* oj2-api backfill-problemsets # 把题单进度与奖章订正到与规则一致,默认只读预演
* oj2-api recount # 把题目/用户的计数列重算回与 submission 一致,默认只读预演
*
* 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用
* Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
@@ -34,13 +35,19 @@ switch (command) {
await runMigrations()
break
}
// 数据订正,跟着二进制走而不是留成源码脚本 —— 生产镜像里没有 bun 也没有源码
// 反范式计数列被重判等操作带偏之后拿它对账,默认只读预演,--apply 才写。
// 一次性的数据订正,跟着二进制走而不是留成源码脚本 —— 生产镜像里没有 bun 也没有源码
case "backfill-problemsets": {
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": {
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") }))
}
case "sql-child": {
const { runSqlChild } = await import("./judge/sql/child")
@@ -62,8 +69,6 @@ switch (command) {
}
}
default:
console.error(
`未知子命令:${command}\n可用:serve | worker | migrate | recount | healthcheck | sql-child`,
)
console.error(`未知子命令:${command}\n可用:serve | worker | migrate | backfill-problemsets | recount | healthcheck | sql-child`)
process.exit(2)
}
+63 -359
View File
@@ -12,8 +12,6 @@ import {
type ProblemRank,
type RankProfile,
type UserRank,
type WeeklyRank,
type WeeklyRankItem,
} from "@oj2/contract"
import {
and,
@@ -28,14 +26,11 @@ import {
isNull,
lt,
lte,
max,
min,
ne,
notExists,
or,
sql,
} from "drizzle-orm"
import { alias } from "drizzle-orm/pg-core"
import { Hono } from "hono"
import { hashPassword } from "../auth/password"
@@ -47,29 +42,15 @@ import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { getBooleanOption } from "../services/options"
import { getUserProfileById } from "../services/profile"
import { localTime, weekStart } from "../time"
import {
isTeacherOrAbove,
objectValue,
queryInteger,
sampleUser,
} from "./helpers"
import { isTeacherOrAbove, objectValue, queryInteger, sampleUser } from "./helpers"
export const accountRoutes = new Hono<AppEnv>()
accountRoutes.post("/users", async (c) => {
const parsed = registerRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid registration payload")
const parsed = registerRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid registration payload")
if (!(await getBooleanOption("allow_register", true))) {
return failure(
c,
403,
"registration-disabled",
"Register function has been disabled by admin",
)
return failure(c, 403, "registration-disabled", "Register function has been disabled by admin")
}
const username = parsed.data.username.toLowerCase()
@@ -77,12 +58,7 @@ accountRoutes.post("/users", async (c) => {
const [duplicate] = await db
.select({ username: schema.user.username, email: schema.user.email })
.from(schema.user)
.where(
or(
sql`lower(${schema.user.username}) = ${username}`,
sql`lower(${schema.user.email}) = ${email}`,
),
)
.where(or(sql`lower(${schema.user.username}) = ${username}`, sql`lower(${schema.user.email}) = ${email}`))
.limit(1)
if (duplicate?.username.toLowerCase() === username) {
return failure(c, 409, "username-exists", "Username already exists")
@@ -94,9 +70,7 @@ accountRoutes.post("/users", async (c) => {
const now = new Date().toISOString()
const password = await hashPassword(parsed.data.password)
await db.transaction(async (tx) => {
const [created] = await tx
.insert(schema.user)
.values({
const [created] = await tx.insert(schema.user).values({
username,
email,
password,
@@ -107,8 +81,7 @@ accountRoutes.post("/users", async (c) => {
isDisabled: false,
problemPermission: "None",
className: null,
})
.returning({ id: schema.user.id })
}).returning({ id: schema.user.id })
if (!created) throw new Error("User insert did not return an id")
await tx.insert(schema.userProfile).values({
userId: created.id,
@@ -128,57 +101,31 @@ accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
// `if not user.is_authenticated: return self.success()` —— 匿名一律返回空,
// 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
if (!c.get("user")) return success(c, null)
const [target] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(
sql`lower(${schema.user.username}) = lower(${c.req.param("username")})`,
eq(schema.user.isDisabled, false),
),
)
.limit(1)
const [target] = await db.select({ id: schema.user.id }).from(schema.user)
.where(and(sql`lower(${schema.user.username}) = lower(${c.req.param("username")})`, eq(schema.user.isDisabled, false))).limit(1)
if (!target) return failure(c, 404, "user-not-found", "User does not exist")
const profile = await getUserProfileById(
target.id,
c.get("user")?.id === target.id,
)
if (!profile)
return failure(c, 404, "profile-not-found", "User profile does not exist")
const profile = await getUserProfileById(target.id, c.get("user")?.id === target.id)
if (!profile) return failure(c, 404, "profile-not-found", "User profile does not exist")
return success(c, profile)
})
accountRoutes.put("/me/profile", requireAuth, async (c) => {
const parsed = updateProfileRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid profile payload")
const parsed = updateProfileRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid profile payload")
const values = Object.fromEntries(
Object.entries(parsed.data).map(([key, value]) => [
key,
value === "" ? null : value,
]),
Object.entries(parsed.data).map(([key, value]) => [key, value === "" ? null : value]),
)
await db
.update(schema.userProfile)
.set(values)
.where(eq(schema.userProfile.userId, c.get("user")!.id))
await db.update(schema.userProfile).set(values).where(eq(schema.userProfile.userId, c.get("user")!.id))
const profile = await getUserProfileById(c.get("user")!.id, true)
if (!profile)
return failure(c, 404, "profile-not-found", "User profile does not exist")
if (!profile) return failure(c, 404, "profile-not-found", "User profile does not exist")
return success(c, profile)
})
accountRoutes.post("/me/avatar", requireAuth, async (c) => {
const body: Record<string, string | File> = await c.req
.parseBody()
.catch(() => ({}))
const body: Record<string, string | File> = await c.req.parseBody().catch(() => ({}))
const image = body.image
if (!(image instanceof File))
return failure(c, 400, "invalid-file", "Invalid file content")
if (image.size > 2 * 1024 * 1024)
return failure(c, 400, "file-too-large", "Picture is too large")
if (!(image instanceof File)) return failure(c, 400, "invalid-file", "Invalid file content")
if (image.size > 2 * 1024 * 1024) return failure(c, 400, "file-too-large", "Picture is too large")
const extension = extname(image.name).toLowerCase()
if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) {
return failure(c, 400, "unsupported-file", "Unsupported file format")
@@ -188,34 +135,17 @@ accountRoutes.post("/me/avatar", requireAuth, async (c) => {
await Bun.$`mkdir -p ${directory}`.quiet()
await Bun.write(resolve(directory, filename), image)
const avatar = `${config.avatarUriPrefix}/${filename}`
await db
.update(schema.userProfile)
.set({ avatar })
.where(eq(schema.userProfile.userId, c.get("user")!.id))
await db.update(schema.userProfile).set({ avatar }).where(eq(schema.userProfile.userId, c.get("user")!.id))
return success(c, { avatar })
})
accountRoutes.get("/users/:id/metrics", async (c) => {
const userId = queryInteger(c.req.param("id"), 0, { min: 1 })
// 比赛提交也算:首末提交时间、学习天数都连比赛一起统计
const [row] = await db
.select({
first: min(schema.submission.createTime),
latest: max(schema.submission.createTime),
activeDays: countDistinct(
sql`date(${localTime(schema.submission.createTime)})`,
),
})
const [row] = await db.select({ total: count(), first: min(schema.submission.createTime), latest: sql<string>`max(${schema.submission.createTime})` })
.from(schema.submission)
.where(eq(schema.submission.userId, userId))
if (!row?.first || !row.latest)
return failure(c, 404, "no-submissions", "暂无提交")
return success(c, {
now: new Date().toISOString(),
first: row.first,
latest: row.latest,
activeDays: row.activeDays,
} satisfies Metrics)
.where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId)))
if (!row?.total || !row.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交")
return success(c, { now: new Date().toISOString(), first: row.first, latest: row.latest } satisfies Metrics)
})
/**
@@ -248,10 +178,7 @@ const leaderboardOrder = [
]
accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, {
min: 1,
max: LEADERBOARD_SIZE,
})
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: LEADERBOARD_SIZE })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
// 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset,**不取决于总人数** ——
@@ -261,22 +188,14 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
// 谁在线只给老师看,学生那边整列都是 null(见 rankProfileSchema.isOnline
const [totalRow, rows, me, online] = await Promise.all([
db
.select({ value: count() })
.from(schema.userProfile)
db.select({ value: count() }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(leaderboardWhere)
.then(([row]) => row),
pageLimit === 0
? []
: db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
.where(leaderboardWhere).then(([row]) => row),
pageLimit === 0 ? [] : db
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(leaderboardWhere)
.orderBy(...leaderboardOrder)
.limit(pageLimit)
.offset(offset),
.where(leaderboardWhere).orderBy(...leaderboardOrder)
.limit(pageLimit).offset(offset),
myLeaderboardRank(c.get("user")?.id),
isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null,
])
@@ -288,16 +207,10 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
} satisfies UserRank)
})
function serializeRankRow(
{
profile,
user,
}: {
function serializeRankRow({ profile, user }: {
profile: typeof schema.userProfile.$inferSelect
user: typeof schema.user.$inferSelect
},
online: Set<number> | null = null,
) {
}, online: Set<number> | null = null) {
return {
id: profile.id,
user: sampleUser(user, profile.realName),
@@ -318,22 +231,15 @@ function serializeRankRow(
async function myLeaderboardRank(userId: number | undefined) {
if (!userId) return null
const [mine] = await db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(and(leaderboardWhere, eq(schema.user.id, userId)))
.limit(1)
.where(and(leaderboardWhere, eq(schema.user.id, userId))).limit(1)
if (!mine) return null
const { acceptedNumber, submissionNumber } = mine.profile
const [ahead] = await db
.select({ value: count() })
.from(schema.userProfile)
const [ahead] = await db.select({ value: count() }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
leaderboardWhere,
or(
.where(and(leaderboardWhere, or(
gt(schema.userProfile.acceptedNumber, acceptedNumber),
and(
eq(schema.userProfile.acceptedNumber, acceptedNumber),
@@ -344,9 +250,7 @@ async function myLeaderboardRank(userId: number | undefined) {
eq(schema.userProfile.submissionNumber, submissionNumber),
lt(schema.user.id, userId),
),
),
),
)
)))
return {
...serializeRankRow(mine),
@@ -356,8 +260,7 @@ async function myLeaderboardRank(userId: number | undefined) {
accountRoutes.get("/rankings/activity", async (c) => {
const start = c.req.query("start")
if (!start || Number.isNaN(Date.parse(start)))
return failure(c, 400, "invalid-start", "start time is required")
if (!start || Number.isNaN(Date.parse(start))) return failure(c, 400, "invalid-start", "start time is required")
/**
* 按 **user_id** 聚合,名字从 user 表取。按 `submission.username` 分组的话,
* 改过名的学生会裂成新旧两条各算各的 AC 题数 —— 排名被拆低,运气不好还会以
@@ -365,223 +268,43 @@ accountRoutes.get("/rankings/activity", async (c) => {
*
* innerJoin user 顺带把已删号学生的孤儿提交挡在外面,不用再兜底名字。
*/
const rows = await db
.select({
username: schema.user.username,
value: countDistinct(schema.submission.problemId),
})
const rows = await db.select({ username: schema.user.username, value: countDistinct(schema.submission.problemId) })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.submission.userId, schema.user.id))
.where(
and(
.where(and(
isNull(schema.submission.contestId),
gte(schema.submission.createTime, start),
inArray(schema.submission.result, [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]),
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
eq(schema.user.isDisabled, false),
ne(schema.user.adminType, "Super Admin"),
),
)
))
.groupBy(schema.submission.userId, schema.user.username)
.orderBy(desc(countDistinct(schema.submission.problemId)))
.limit(10)
return success(
c,
rows.map(
(row) =>
({
username: row.username,
count: row.value,
}) satisfies ActivityRankItem,
),
)
})
/**
* 周榜的榜面大小。**存量榜(`/rankings/users`)解决的是「谁最强」,周榜解决的是
* 「这一周谁在往前走」** —— 后者每周一清零,所以榜面短一点更像「这周的头名」,
* 长了反而又变成一张追不上的总表。榜外的人靠 `me` 单独看到自己的名次。
*/
const WEEKLY_BOARD_SIZE = 10
/** 算「解决」的两个状态:AST_CHECK_FAILED 也是答案对了,与 /rankings/activity 同口径 */
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/**
* 本周进步榜:按**本周首次 AC 的题目数**排名,每周一 0:00(东八区)清零。
*
* 和 `/rankings/users` 的区别不只是加了时间窗:那张榜排的是 `user_profile` 的存量
* AC 总数,名次几乎不动,中位学生看一眼就知道追不上,等于负反馈。这张榜的分母是
* 「这一周」,谁都可能进前十。
*
* 「首次 AC」是靠 NOT EXISTS 排掉本周之前已经通过过的 (user, problem) 对,不是简单
* 数本周 AC 的去重题数 —— 后者把老题重交一遍也算成绩,一分钟能刷满一屏。
* 相关子查询的四个条件正好是 `submission_public_metrics_idx`
* user_id, problem_id, result, create_timeWHERE contest_id IS NULL)的全部列,
* 而且外层已经把行数收在「本周的 AC」这一小撮上,不会退化成按人全表回查。
*/
accountRoutes.get("/rankings/weekly", optionalAuth, async (c) => {
const user = c.get("user")
const scope = c.req.query("scope") === "class" ? "class" : "global"
const className = scope === "class" ? (user?.className ?? null) : null
if (scope === "class" && !className)
return failure(c, 400, "class-missing", "用户没有班级信息")
const start = weekStart()
// 入榜人群与全服榜一致(leaderboardWhere):正常状态的学生与学生管理员
const audience = and(
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
className ? eq(schema.user.className, className) : undefined,
)
const thisWeek = and(
isNull(schema.submission.contestId),
gte(schema.submission.createTime, start),
audience,
)
const earlier = alias(schema.submission, "earlier")
const [solvedRows, submittedRows] = await Promise.all([
db
.select({
userId: schema.submission.userId,
username: schema.user.username,
value: countDistinct(schema.submission.problemId),
})
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(
and(
thisWeek,
inArray(schema.submission.result, ACCEPTED_RESULTS),
notExists(
db
.select({ one: sql`1` })
.from(earlier)
.where(
and(
eq(earlier.userId, schema.submission.userId),
eq(earlier.problemId, schema.submission.problemId),
isNull(earlier.contestId),
inArray(earlier.result, ACCEPTED_RESULTS),
lt(earlier.createTime, start),
),
),
),
),
)
.groupBy(schema.submission.userId, schema.user.username),
db
.select({ userId: schema.submission.userId, value: count() })
.from(schema.submission)
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(thisWeek)
.groupBy(schema.submission.userId),
])
const submissions = new Map(
submittedRows.map((row) => [row.userId, row.value]),
)
/**
* 排序键与全服榜同构:解决多的在前 → 同解决数时提交少的在前 → 再同按 id。
* 第三档同样不是凑数,周榜上「都是 1 题」的学生成片存在,没有稳定兜底键时
* postgres 每次返回的顺序可以不同,刷新一下名次就变了。
*/
const ranked = solvedRows
.sort(
(a, b) =>
b.value - a.value ||
(submissions.get(a.userId) ?? 0) - (submissions.get(b.userId) ?? 0) ||
a.userId - b.userId,
)
.map(
(row, index) =>
({
user: sampleUser({ id: row.userId, username: row.username }, null),
solvedCount: row.value,
submissionCount: submissions.get(row.userId) ?? 0,
rank: index + 1,
}) satisfies WeeklyRankItem,
)
return success(c, {
start,
scope,
className,
total: ranked.length,
results: ranked.slice(0, WEEKLY_BOARD_SIZE),
me: ranked.find((row) => row.user.id === user?.id) ?? null,
} satisfies WeeklyRank)
.orderBy(desc(countDistinct(schema.submission.problemId))).limit(10)
return success(c, rows.map((row) => ({ username: row.username, count: row.value } satisfies ActivityRankItem)))
})
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
const user = c.get("user")!
const [problem] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
const accepted = and(
eq(schema.submission.problemId, problem.id),
inArray(schema.submission.result, [0, 10]),
)
const [all] = await db
.select({ value: countDistinct(schema.submission.userId) })
.from(schema.submission)
.where(accepted)
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem)
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true))).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
const accepted = and(eq(schema.submission.problemId, problem.id), inArray(schema.submission.result, [0, 10]))
const [all] = await db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(accepted)
const className = user.className ?? ""
const classWhere = className
? and(
accepted,
inArray(
schema.submission.userId,
db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(
eq(schema.user.className, className),
eq(schema.user.isDisabled, false),
),
),
),
)
? and(accepted, inArray(schema.submission.userId, db.select({ id: schema.user.id }).from(schema.user).where(and(eq(schema.user.className, className), eq(schema.user.isDisabled, false)))))
: accepted
const [classCount] = className
? await db
.select({ value: countDistinct(schema.submission.userId) })
.from(schema.submission)
.where(classWhere)
? await db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(classWhere)
: [{ value: 0 }]
const [first] = await db
.select({ value: min(schema.submission.createTime) })
.from(schema.submission)
const [first] = await db.select({ value: min(schema.submission.createTime) }).from(schema.submission)
.where(and(classWhere, eq(schema.submission.userId, user.id)))
let rank = -1
if (first?.value) {
const [rankRow] = await db
.select({ value: count() })
.from(schema.submission)
.where(and(classWhere, lte(schema.submission.createTime, first.value)))
const [rankRow] = await db.select({ value: count() }).from(schema.submission).where(and(classWhere, lte(schema.submission.createTime, first.value)))
rank = rankRow?.value ?? -1
}
return success(c, {
className,
rank,
classAcCount: classCount?.value ?? 0,
allAcCount: all?.value ?? 0,
} satisfies ProblemRank)
return success(c, { className, rank, classAcCount: classCount?.value ?? 0, allAcCount: all?.value ?? 0 } satisfies ProblemRank)
})
/**
@@ -596,32 +319,17 @@ accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
* 题目一旦被隐藏或删除,display_ids 就比 ids 短 —— 轻则把编号张冠李戴写进库,
* 重则 `id_map[k]` KeyError。这里改成按 id 建 Map、查不到就不动。
*/
accountRoutes.post(
"/me/problem-display-ids/refresh",
requireAuth,
async (c) => {
accountRoutes.post("/me/problem-display-ids/refresh", requireAuth, async (c) => {
const user = c.get("user")!
const [profile] = await db
.select({ value: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, user.id))
.limit(1)
const [profile] = await db.select({ value: schema.userProfile.acmProblemsStatus }).from(schema.userProfile)
.where(eq(schema.userProfile.userId, user.id)).limit(1)
const status = objectValue(profile?.value)
const problems = objectValue(status.problems)
const ids = Object.keys(problems).map(Number).filter(Number.isInteger)
if (ids.length > 0) {
const rows = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
inArray(schema.problem.id, ids),
eq(schema.problem.visible, true),
),
)
const displayIds = new Map(
rows.map((row) => [String(row.id), row.displayId]),
)
const rows = await db.select({ id: schema.problem.id, displayId: schema.problem.displayId }).from(schema.problem)
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true)))
const displayIds = new Map(rows.map((row) => [String(row.id), row.displayId]))
for (const [id, value] of Object.entries(problems)) {
const item = objectValue(value)
const displayId = displayIds.get(id)
@@ -629,11 +337,7 @@ accountRoutes.post(
problems[id] = item
}
status.problems = problems
await db
.update(schema.userProfile)
.set({ acmProblemsStatus: status })
.where(eq(schema.userProfile.userId, user.id))
await db.update(schema.userProfile).set({ acmProblemsStatus: status }).where(eq(schema.userProfile.userId, user.id))
}
return success(c, null)
},
)
})
+25 -101
View File
@@ -17,29 +17,16 @@ export const achievementRoutes = new Hono<AppEnv>()
async function resolveUser(requested: string | undefined, currentId: number) {
if (!requested) {
const [current] = await db
.select({ id: schema.user.id, username: schema.user.username })
.from(schema.user)
.where(eq(schema.user.id, currentId))
.limit(1)
const [current] = await db.select({ id: schema.user.id, username: schema.user.username }).from(schema.user)
.where(eq(schema.user.id, currentId)).limit(1)
return current ?? null
}
const [target] = await db
.select({ id: schema.user.id, username: schema.user.username })
.from(schema.user)
.where(
and(
eq(schema.user.username, requested),
eq(schema.user.isDisabled, false),
),
)
.limit(1)
const [target] = await db.select({ id: schema.user.id, username: schema.user.username }).from(schema.user)
.where(and(eq(schema.user.username, requested), eq(schema.user.isDisabled, false))).limit(1)
return target ?? null
}
function pendingData(row: {
achievement: typeof schema.achievement.$inferSelect
}) {
function pendingData(row: { achievement: typeof schema.achievement.$inferSelect }) {
return {
id: row.achievement.id,
name: row.achievement.name,
@@ -53,24 +40,10 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
const [achievements, unlockedRows, statRows, activeRows] = await Promise.all([
db
.select()
.from(schema.achievement)
.where(eq(schema.achievement.visible, true))
.orderBy(asc(schema.achievement.order), asc(schema.achievement.id)),
db
.select()
.from(schema.userAchievement)
.where(eq(schema.userAchievement.userId, target.id)),
db
.select({ metrics: schema.userStat.metrics })
.from(schema.userStat)
.where(eq(schema.userStat.userId, target.id))
.limit(1),
db
.select({ value: count() })
.from(schema.user)
.where(eq(schema.user.isDisabled, false)),
db.select().from(schema.achievement).where(eq(schema.achievement.visible, true)).orderBy(asc(schema.achievement.order), asc(schema.achievement.id)),
db.select().from(schema.userAchievement).where(eq(schema.userAchievement.userId, target.id)),
db.select({ metrics: schema.userStat.metrics }).from(schema.userStat).where(eq(schema.userStat.userId, target.id)).limit(1),
db.select({ value: count() }).from(schema.user).where(eq(schema.user.isDisabled, false)),
])
const unlocked = new Map(unlockedRows.map((row) => [row.achievementId, row]))
const metrics = objectValue(statRows[0]?.metrics)
@@ -93,50 +66,22 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
unlockTime: record?.unlockTime ?? null,
backfilled: record?.backfilled ?? false,
progress: masked ? null : typeof progress === "number" ? progress : 0,
unlockRate:
active > 0
? Math.round((achievement.unlockCount / active) * 1000) / 10
: 0,
unlockRate: active > 0 ? Math.round(achievement.unlockCount / active * 1000) / 10 : 0,
} satisfies Achievement
})
return success(c, {
username: target.username,
achievements: result,
} satisfies AchievementList)
return success(c, { username: target.username, achievements: result } satisfies AchievementList)
})
achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
const [achievements, unlockedRows] = await Promise.all([
db
.select({ id: schema.achievement.id, rarity: schema.achievement.rarity })
.from(schema.achievement)
.where(eq(schema.achievement.visible, true)),
db
.select({
record: schema.userAchievement,
achievement: schema.achievement,
})
.from(schema.userAchievement)
.innerJoin(
schema.achievement,
eq(schema.userAchievement.achievementId, schema.achievement.id),
)
.where(
and(
eq(schema.userAchievement.userId, target.id),
eq(schema.achievement.visible, true),
),
)
.orderBy(desc(schema.userAchievement.unlockTime)),
db.select({ id: schema.achievement.id, rarity: schema.achievement.rarity }).from(schema.achievement).where(eq(schema.achievement.visible, true)),
db.select({ record: schema.userAchievement, achievement: schema.achievement }).from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(and(eq(schema.userAchievement.userId, target.id), eq(schema.achievement.visible, true))).orderBy(desc(schema.userAchievement.unlockTime)),
])
const labels = {
bronze: "青铜",
silver: "白银",
gold: "黄金",
platinum: "白金",
}
const labels = { bronze: "青铜", silver: "白银", gold: "黄金", platinum: "白金" }
const rarities = ["bronze", "silver", "gold", "platinum"] as const
const total = achievements.length
const unlocked = unlockedRows.length
@@ -144,54 +89,33 @@ achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
username: target.username,
total,
unlocked,
percent: total > 0 ? Math.round((unlocked / total) * 1000) / 10 : 0,
percent: total > 0 ? Math.round(unlocked / total * 1000) / 10 : 0,
rarity: rarities.map((rarity) => ({
rarity,
label: labels[rarity],
total: achievements.filter((item) => item.rarity === rarity).length,
unlocked: unlockedRows.filter(
(item) => item.achievement.rarity === rarity,
).length,
unlocked: unlockedRows.filter((item) => item.achievement.rarity === rarity).length,
})),
recent: unlockedRows.slice(0, 10).map(pendingData),
} satisfies AchievementSummary)
})
achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {
const rows = await db
.select({ record: schema.userAchievement, achievement: schema.achievement })
.from(schema.userAchievement)
.innerJoin(
schema.achievement,
eq(schema.userAchievement.achievementId, schema.achievement.id),
)
.where(
and(
eq(schema.userAchievement.userId, c.get("user")!.id),
eq(schema.userAchievement.notified, false),
eq(schema.achievement.visible, true),
),
)
const rows = await db.select({ record: schema.userAchievement, achievement: schema.achievement })
.from(schema.userAchievement).innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(and(eq(schema.userAchievement.userId, c.get("user")!.id), eq(schema.userAchievement.notified, false), eq(schema.achievement.visible, true)))
.orderBy(asc(schema.userAchievement.unlockTime))
return success(c, rows.map(pendingData))
})
achievementRoutes.post("/achievements/pending/read", requireAuth, async (c) => {
const parsed = markAchievementsReadSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid achievement ids")
const parsed = markAchievementsReadSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid achievement ids")
if (parsed.data.ids.length > 0) {
await db
.update(schema.userAchievement)
.set({ notified: true })
.where(
and(
await db.update(schema.userAchievement).set({ notified: true }).where(and(
eq(schema.userAchievement.userId, c.get("user")!.id),
inArray(schema.userAchievement.achievementId, parsed.data.ids),
),
)
))
}
return success(c, null)
})
+74 -261
View File
@@ -14,18 +14,7 @@ import {
} from "@oj2/contract"
import { randomInt } from "node:crypto"
import { z } from "zod"
import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
ne,
or,
sql,
} from "drizzle-orm"
import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-orm"
import { Hono } from "hono"
import { hashPassword } from "../../auth/password"
@@ -49,16 +38,11 @@ const CLASS_NAME_MAX_DIGITS = 4
* 那样 `ks251001` 会「匹配成功」并悄悄取前 4 位,正是要避免的猜测。
* 对齐旧 `account/views/admin.py:get_class_name`。
*/
function classNameOf(
username: string,
): { ok: true; value: string | null } | { ok: false; message: string } {
function classNameOf(username: string): { ok: true; value: string | null } | { ok: false; message: string } {
const matched = /^ks(\d+)/.exec(username)
if (!matched) return { ok: true, value: null }
const digits = matched[1]!
if (
digits.length < CLASS_NAME_MIN_DIGITS ||
digits.length > CLASS_NAME_MAX_DIGITS
) {
if (digits.length < CLASS_NAME_MIN_DIGITS || digits.length > CLASS_NAME_MAX_DIGITS) {
return {
ok: false,
message: `用户名 ${username} 的班级号 ${digits}${digits.length} 位,必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
@@ -72,22 +56,16 @@ function classNameOf(
* 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。
* 不这么做的话,把一个超管降级成普通用户后,他还留着 All 的题目权限。
*/
function normalizePermission(
adminType: AdminType,
requested: ProblemPermission,
): ProblemPermission {
function normalizePermission(adminType: AdminType, requested: ProblemPermission): ProblemPermission {
if (adminType === "Super Admin") return "All"
if (adminType === "Regular User") return "None"
return requested || "Own"
}
function serialize(
row: {
function serialize(row: {
user: typeof schema.user.$inferSelect
realName: string | null
},
isOnline: boolean,
) {
}, isOnline: boolean) {
return {
id: row.user.id,
username: row.user.username,
@@ -105,12 +83,10 @@ function serialize(
}
function selectUser(id: number) {
return db
.select({ user: schema.user, realName: schema.userProfile.realName })
return db.select({ user: schema.user, realName: schema.userProfile.realName })
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.user.id, id))
.limit(1)
.where(eq(schema.user.id, id)).limit(1)
}
/**
@@ -134,29 +110,20 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
)
const [totalRows, rows] = await Promise.all([
db
.select({ value: count() })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(where),
db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
.where(where)
db.select({ value: count() }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where),
db.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where)
.orderBy(
desc(schema.userProfile.acceptedNumber),
asc(schema.userProfile.submissionNumber),
asc(schema.user.id),
)
.limit(limit)
.offset(offset),
.limit(limit).offset(offset),
])
return success(c, {
results: rows.map(
({ profile, user }) =>
({
results: rows.map(({ profile, user }) => ({
id: profile.id,
user: sampleUser(user, profile.realName),
acceptedNumber: profile.acceptedNumber,
@@ -165,8 +132,7 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
// 这张榜不下发在线状态(null = 「调用方不该知道」,见契约里 isOnline 的注释)。
// 原来是靠 schema 的 .default(null) 填出来的,改成显式写死。
isOnline: null,
}) satisfies RankProfile,
),
} satisfies RankProfile)),
total: totalRows[0]?.value ?? 0,
} satisfies AdminUserRank)
})
@@ -181,18 +147,15 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
// 以前这里直接把 query 塞进 eq(),传个不存在的角色名只会静默返回空列表。
// 列加了 $type 之后编译器会拦下来,顺势改成校验:前端的下拉只有这四个值。
const parsedType = adminTypeSchema.safeParse(type)
if (!parsedType.success)
return failure(c, 400, "invalid-request", "角色筛选值不合法")
if (!parsedType.success) return failure(c, 400, "invalid-request", "角色筛选值不合法")
filters.push(eq(schema.user.adminType, parsedType.data))
}
if (keyword) {
filters.push(
or(
filters.push(or(
ilike(schema.user.username, `%${keyword}%`),
ilike(schema.userProfile.realName, `%${keyword}%`),
ilike(schema.user.email, `%${keyword}%`),
)!,
)
)!)
}
const where = filters.length ? and(...filters) : undefined
// 在线状态每行都要下发(列表里显示),所以不管怎么排都先取一次
@@ -203,13 +166,10 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
// 「在线优先」没有对应的库表列 —— 在线只存在于 Redis,所以把在线的 id 捞出来
// 在 SQL 里分两档;档内仍按最近登录排,这样一屏离线用户之间还是有意义的顺序。
// 没人在线时那个 case 恒等于 1,直接省掉(inArray 拿空数组也不合法)。
const order =
orderBy === "-online"
const order = orderBy === "-online"
? [
...(online.size
? [
sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`,
]
? [sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`]
: []),
sql`${schema.user.lastLogin} desc nulls last`,
]
@@ -218,25 +178,11 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
: [desc(schema.user.createTime)]
const [totalRows, rows] = await Promise.all([
db
.select({ value: count() })
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where),
db
.select({ user: schema.user, realName: schema.userProfile.realName })
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where)
.orderBy(...order, asc(schema.user.id))
.limit(limit)
.offset(offset),
db.select({ value: count() }).from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where),
db.select({ user: schema.user, realName: schema.userProfile.realName }).from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
.orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset),
])
return success(c, {
results: rows.map((row) => serialize(row, online.has(row.user.id))),
@@ -252,16 +198,9 @@ adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateUserRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = updateUserRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const data = parsed.data
const [existing] = await selectUser(id)
@@ -270,24 +209,14 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
const username = data.username.trim().toLowerCase()
const email = data.email.trim().toLowerCase()
const className = classNameOf(username)
if (!className.ok)
return failure(c, 400, "invalid-class-name", className.message)
if (!className.ok) return failure(c, 400, "invalid-class-name", className.message)
const [dupUsername] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(and(eq(schema.user.username, username), ne(schema.user.id, id)))
.limit(1)
if (dupUsername)
return failure(c, 409, "username-exists", "Username already exists")
const [dupUsername] = await db.select({ id: schema.user.id }).from(schema.user)
.where(and(eq(schema.user.username, username), ne(schema.user.id, id))).limit(1)
if (dupUsername) return failure(c, 409, "username-exists", "Username already exists")
// 比 lower(email):存量数据里有大小写混着的邮箱,按原值比会漏掉冲突
const [dupEmail] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(sql`lower(${schema.user.email}) = ${email}`, ne(schema.user.id, id)),
)
.limit(1)
const [dupEmail] = await db.select({ id: schema.user.id }).from(schema.user)
.where(and(sql`lower(${schema.user.email}) = ${email}`, ne(schema.user.id, id))).limit(1)
if (dupEmail) return failure(c, 409, "email-exists", "Email already exists")
const patch: Partial<typeof schema.user.$inferInsert> = {
@@ -296,10 +225,7 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
className: className.value,
adminType: data.adminType,
isDisabled: data.isDisabled,
problemPermission: normalizePermission(
data.adminType,
data.problemPermission,
),
problemPermission: normalizePermission(data.adminType, data.problemPermission),
}
if (data.password) {
// 与旧 User.set_password 一致:哈希与明文一起写。明文是有意保留的运营需求,
@@ -322,14 +248,10 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
* 这里保持同步是为了「已删号回退显示」和按名字搜索那两条路。
*/
if (existing.user.username !== username) {
await tx
.update(schema.submission)
.set({ username })
await tx.update(schema.submission).set({ username })
.where(eq(schema.submission.userId, id))
}
await tx
.update(schema.userProfile)
.set({ realName: data.realName })
await tx.update(schema.userProfile).set({ realName: data.realName })
.where(eq(schema.userProfile.userId, id))
})
@@ -350,26 +272,12 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
})
adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
const parsed = importUsersRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = importUsersRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const rows = parsed.data.users
type Prepared = {
username: string
password: string
raw: string
email: string
realName: string
className: string | null
}
type Prepared = { username: string; password: string; raw: string; email: string; realName: string; className: string | null }
// 先把不花钱的校验全做完,再动 argon2。班级号错、用户名重复这两种情况占了失败的绝大多数
// (老师习惯把同一份名单粘两次),先算哈希的话要白等一整个班的 argon2 才看到报错。
@@ -381,94 +289,48 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
for (const [username, password, email, realName] of rows) {
const name = username.toLowerCase()
const className = classNameOf(name)
if (!className.ok)
return failure(c, 400, "invalid-class-name", className.message)
if (!className.ok) return failure(c, 400, "invalid-class-name", className.message)
const mail = email.trim().toLowerCase()
// 邮箱在本站是唯一的(注册和 PUT /users/:id 两条路都查重),唯独导入这条以前
// 什么都不查 —— 而前端生成的占位邮箱按「班级+批内序号」拼,同一个班导第二批
// 必然重号。存进去不会报错(库里没有唯一约束),但这两个账号从此**编辑不了**:
// PUT 一保存就撞自己的查重回 409,老师只看到「Email already exists」。
if (!z.email().max(64).safeParse(mail).success) {
return failure(
c,
400,
"invalid-email",
`用户 ${name} 的邮箱 ${mail || "(空)"} 不是合法邮箱`,
)
return failure(c, 400, "invalid-email", `用户 ${name} 的邮箱 ${mail || "(空)"} 不是合法邮箱`)
}
prepared.push({
username: name,
password: "",
raw: password,
email: mail,
realName,
className: className.value,
})
prepared.push({ username: name, password: "", raw: password, email: mail, realName, className: className.value })
}
const dupInBatch = (values: string[]) => {
const seen = new Set<string>()
return [
...new Set(values.filter((value) => seen.size === seen.add(value).size)),
]
return [...new Set(values.filter((value) => seen.size === seen.add(value).size))]
}
const batchNames = dupInBatch(prepared.map((item) => item.username))
if (batchNames.length) {
return failure(
c,
409,
"username-exists",
`这批名单里用户名重复:${batchNames.join("、")}`,
)
return failure(c, 409, "username-exists", `这批名单里用户名重复:${batchNames.join("、")}`)
}
const batchMails = dupInBatch(prepared.map((item) => item.email))
if (batchMails.length) {
return failure(
c,
409,
"email-exists",
`这批名单里邮箱重复:${batchMails.join("、")}`,
)
return failure(c, 409, "email-exists", `这批名单里邮箱重复:${batchMails.join("、")}`)
}
const existing = await db
.select({ username: schema.user.username, email: schema.user.email })
const existing = await db.select({ username: schema.user.username, email: schema.user.email })
.from(schema.user)
.where(
or(
inArray(
schema.user.username,
prepared.map((item) => item.username),
),
inArray(
sql`lower(${schema.user.email})`,
prepared.map((item) => item.email),
),
),
)
.where(or(
inArray(schema.user.username, prepared.map((item) => item.username)),
inArray(sql`lower(${schema.user.email})`, prepared.map((item) => item.email)),
))
const takenNames = new Set(prepared.map((item) => item.username))
const clashNames = existing
.filter((row) => takenNames.has(row.username))
.map((row) => row.username)
const clashNames = existing.filter((row) => takenNames.has(row.username)).map((row) => row.username)
if (clashNames.length) {
return failure(
c,
409,
"username-exists",
`用户名已存在:${clashNames.join("、")}`,
)
return failure(c, 409, "username-exists", `用户名已存在:${clashNames.join("、")}`)
}
const takenMails = new Set(prepared.map((item) => item.email))
const clashMails = existing
.map((row) => row.email?.toLowerCase())
.filter((mail): mail is string => !!mail && takenMails.has(mail))
if (clashMails.length) {
return failure(
c,
409,
"email-exists",
`邮箱已被占用:${[...new Set(clashMails)].join("、")}`,
)
return failure(c, 409, "email-exists", `邮箱已被占用:${[...new Set(clashMails)].join("、")}`)
}
// argon2id 是**故意**做慢的,串行 await 的话一个班要转好几秒。但也不能 Promise.all
@@ -477,24 +339,16 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
// 固定 4 路并发,瞬时峰值 76MiB 封顶。
const HASH_CONCURRENCY = 4
let cursor = 0
await Promise.all(
Array.from(
{ length: Math.min(HASH_CONCURRENCY, prepared.length) },
async () => {
await Promise.all(Array.from({ length: Math.min(HASH_CONCURRENCY, prepared.length) }, async () => {
while (cursor < prepared.length) {
const item = prepared[cursor++]!
item.password = await hashPassword(item.raw)
}
},
),
)
}))
// 整批要么全进要么全不进 —— 导入是粘一整个班的名单,进了一半再重试会撞已存在
const created = await db.transaction(async (tx) => {
const users = await tx
.insert(schema.user)
.values(
prepared.map((item) => ({
const users = await tx.insert(schema.user).values(prepared.map((item) => ({
username: item.username,
password: item.password,
rawPassword: item.raw,
@@ -504,12 +358,9 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
problemPermission: "None" as const,
createTime: new Date().toISOString(),
isDisabled: false,
})),
)
.returning({ id: schema.user.id, username: schema.user.username })
}))).returning({ id: schema.user.id, username: schema.user.username })
const byName = new Map(users.map((row) => [row.username, row.id]))
await tx.insert(schema.userProfile).values(
prepared.map((item) => ({
await tx.insert(schema.userProfile).values(prepared.map((item) => ({
userId: byName.get(item.username)!,
realName: item.realName,
// avatar 是 notNull 且无默认值,必须显式给;路径与旧 UserProfile.avatar 的默认值一致
@@ -517,8 +368,7 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
acmProblemsStatus: {},
submissionNumber: 0,
acceptedNumber: 0,
})),
)
})))
return users.length
})
return success(c, { imported: created }, 201)
@@ -530,11 +380,7 @@ adminAccountRoutes.post("/users", requireSuperAdmin, async (c) => {
* 删除失败都当成系统故障报 500。
*/
function isForeignKeyViolation(error: unknown) {
for (
let current = error;
current;
current = (current as { cause?: unknown }).cause
) {
for (let current = error; current; current = (current as { cause?: unknown }).cause) {
if ((current as { code?: string }).code === "23503") return true
}
return false
@@ -544,19 +390,11 @@ function isForeignKeyViolation(error: unknown) {
class UserHasSubmissionsError extends Error {}
adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
const parsed = deleteUsersRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "ids is required")
const parsed = deleteUsersRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "ids is required")
const me = c.get("user")!.id
if (parsed.data.ids.includes(me)) {
return failure(
c,
400,
"cannot-delete-self",
"Current user can not be deleted",
)
return failure(c, 400, "cannot-delete-self", "Current user can not be deleted")
}
// 用户是被引用最广的一张表(提交、题目、比赛、公告……),级联删除牵连太大,
// 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
@@ -587,55 +425,30 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
.limit(1)
if (withSubmission) throw new UserHasSubmissionsError()
return tx
.delete(schema.user)
.where(inArray(schema.user.id, parsed.data.ids))
return tx.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids))
.returning({ id: schema.user.id })
})
return success(c, { deleted: deleted.length })
} catch (error) {
// 只有外键冲突(23503)和上面那条提交检查才是「这人还有历史数据」。以前这里是裸
// catch,连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见
if (
!(error instanceof UserHasSubmissionsError) &&
!isForeignKeyViolation(error)
)
throw error
return failure(
c,
409,
"user-in-use",
"该用户还有提交、题目等历史数据,无法删除;请改为禁用账号",
)
if (!(error instanceof UserHasSubmissionsError) && !isForeignKeyViolation(error)) throw error
return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号")
}
})
adminAccountRoutes.post(
"/users/:id/reset-password",
requireSuperAdmin,
async (c) => {
adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [existing] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.id, id))
.limit(1)
if (!existing)
return failure(c, 404, "user-not-found", "User does not exist")
const [existing] = await db.select({ id: schema.user.id }).from(schema.user)
.where(eq(schema.user.id, id)).limit(1)
if (!existing) return failure(c, 404, "user-not-found", "User does not exist")
// 6 位随机数字、不含 0,与旧后端一致:学生要照着念、要手输,0 和 O 分不清
const password = Array.from(
{ length: 6 },
() => "123456789"[randomInt(9)],
).join("")
await db
.update(schema.user)
.set({
const password = Array.from({ length: 6 }, () => "123456789"[randomInt(9)]).join("")
await db.update(schema.user).set({
password: await hashPassword(password),
rawPassword: password,
})
.where(eq(schema.user.id, id))
}).where(eq(schema.user.id, id))
// 旧密码登出来的会话立刻作废,理由同 PUT /users/:id
await revokeUserSessions(id, "session-ended")
return success(c, { password } satisfies ResetPasswordResponse)
},
)
})
+26 -90
View File
@@ -10,11 +10,7 @@ import { Hono } from "hono"
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import {
ACHIEVEMENT_METRICS,
findMetric,
metricName,
} from "../../services/achievement-metrics"
import { ACHIEVEMENT_METRICS, findMetric, metricName } from "../../services/achievement-metrics"
import { rescanAchievement } from "../../services/achievements"
import { queryInteger } from "../helpers"
@@ -41,102 +37,54 @@ function serialize(row: typeof schema.achievement.$inferSelect) {
/** 下拉框的可选项就是代码里注册了什么,见 services/achievement-metrics.ts 的说明 */
adminAchievementRoutes.get("/achievement-metrics", requireSuperAdmin, (c) =>
success(c, ACHIEVEMENT_METRICS satisfies AchievementMetric[]),
)
success(c, ACHIEVEMENT_METRICS satisfies AchievementMetric[]))
adminAchievementRoutes.get("/achievements", requireSuperAdmin, async (c) => {
const rows = await db
.select()
.from(schema.achievement)
const rows = await db.select().from(schema.achievement)
.orderBy(asc(schema.achievement.order), asc(schema.achievement.id))
return success(c, rows.map(serialize))
})
adminAchievementRoutes.get(
"/achievements/:id",
requireSuperAdmin,
async (c) => {
const [row] = await db
.select()
.from(schema.achievement)
.where(
eq(
schema.achievement.id,
queryInteger(c.req.param("id"), 0, { min: 1 }),
),
)
.limit(1)
adminAchievementRoutes.get("/achievements/:id", requireSuperAdmin, async (c) => {
const [row] = await db.select().from(schema.achievement)
.where(eq(schema.achievement.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1)
if (!row) return failure(c, 404, "achievement-not-found", "成就不存在")
return success(c, serialize(row))
},
)
})
adminAchievementRoutes.post("/achievements", requireSuperAdmin, async (c) => {
const parsed = createAchievementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = createAchievementRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
if (!findMetric(parsed.data.metric))
return failure(c, 400, "invalid-metric", "指标不存在")
if (!findMetric(parsed.data.metric)) return failure(c, 400, "invalid-metric", "指标不存在")
const [created] = await db
.insert(schema.achievement)
.values({
const [created] = await db.insert(schema.achievement).values({
...parsed.data,
unlockCount: 0,
createTime: new Date().toISOString(),
})
.returning()
}).returning()
// 新建的成就要补发给已达标的存量用户,否则「AC 满 10 题」这种成就
// 只有从今往后的提交才算,老用户永远拿不到
await rescanAchievement(created!.id)
const [row] = await db
.select()
.from(schema.achievement)
.where(eq(schema.achievement.id, created!.id))
.limit(1)
const [row] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, created!.id)).limit(1)
return success(c, serialize(row!), 201)
})
adminAchievementRoutes.put(
"/achievements/:id",
requireSuperAdmin,
async (c) => {
adminAchievementRoutes.put("/achievements/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAchievementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = updateAchievementRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
if (!findMetric(parsed.data.metric))
return failure(c, 400, "invalid-metric", "指标不存在")
if (!findMetric(parsed.data.metric)) return failure(c, 400, "invalid-metric", "指标不存在")
const [before] = await db
.select()
.from(schema.achievement)
.where(eq(schema.achievement.id, id))
.limit(1)
const [before] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, id)).limit(1)
if (!before) return failure(c, 404, "achievement-not-found", "成就不存在")
const [after] = await db
.update(schema.achievement)
.set(parsed.data)
.where(eq(schema.achievement.id, id))
.returning()
const [after] = await db.update(schema.achievement).set(parsed.data)
.where(eq(schema.achievement.id, id)).returning()
// 只要「谁能达成」这件事可能变了就补发,不去精细判断是否放宽。补发幂等(唯一键 + 冲突忽略),
// 多跑一次只花一次扫描;漏跑却是学生已达标却拿不到,两个方向代价不对称。
@@ -149,27 +97,15 @@ adminAchievementRoutes.put(
before.visible !== after!.visible
if (after!.visible && changed) await rescanAchievement(id)
const [row] = await db
.select()
.from(schema.achievement)
.where(eq(schema.achievement.id, id))
.limit(1)
const [row] = await db.select().from(schema.achievement).where(eq(schema.achievement.id, id)).limit(1)
return success(c, serialize(row!))
},
)
})
adminAchievementRoutes.delete(
"/achievements/:id",
requireSuperAdmin,
async (c) => {
adminAchievementRoutes.delete("/achievements/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 解锁记录随成就一起没:user_achievement.achievement_id 是 CASCADE0010
const deleted = await db
.delete(schema.achievement)
.where(eq(schema.achievement.id, id))
const deleted = await db.delete(schema.achievement).where(eq(schema.achievement.id, id))
.returning({ id: schema.achievement.id })
if (deleted.length === 0)
return failure(c, 404, "achievement-not-found", "成就不存在")
if (deleted.length === 0) return failure(c, 404, "achievement-not-found", "成就不存在")
return success(c, null)
},
)
})
+17 -58
View File
@@ -21,13 +21,7 @@ function excerpt(analysis: string | null) {
return text.length <= 120 ? text : `${text.slice(0, 120)}`
}
function listItem(row: {
id: number
username: string
createTime: string
analysis: string
isPinned: boolean
}) {
function listItem(row: { id: number; username: string; createTime: string; analysis: string; isPinned: boolean }) {
return {
id: row.id,
username: row.username,
@@ -47,9 +41,7 @@ const listColumns = {
adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
const username = c.req.query("username")?.trim()
const where = username
? ilike(schema.user.username, `%${username}%`)
: undefined
const where = username ? ilike(schema.user.username, `%${username}%`) : undefined
// 置顶列表不分页:它是「每个学生最新钉住的那份」,数量等于学生数,前端一次性拿走。
// 但**形状必须和分页那支一样**:同一个 URL 返回两种形状,调用方没法照着一个类型写。
@@ -57,9 +49,7 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
// 读的是 res.results,于是拿到 undefined`pinnedReports.length` 在渲染时抛
// 「Cannot read properties of undefined」——空库也照抛,这个页面每次打开都白屏。
if (c.req.query("pinnedOnly") === "true") {
const rows = await db
.select(listColumns)
.from(schema.aiAnalysis)
const rows = await db.select(listColumns).from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(and(eq(schema.aiAnalysis.isPinned, true), where))
.orderBy(desc(schema.aiAnalysis.createTime))
@@ -72,19 +62,11 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([
db
.select({ value: count() })
.from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(where),
db
.select(listColumns)
.from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(where)
.orderBy(desc(schema.aiAnalysis.createTime))
.limit(limit)
.offset(offset),
db.select({ value: count() }).from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where),
db.select(listColumns).from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where)
.orderBy(desc(schema.aiAnalysis.createTime)).limit(limit).offset(offset),
])
return success(c, {
results: rows.map(listItem),
@@ -93,20 +75,15 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
})
adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
const [row] = await db
.select({
const [row] = await db.select({
id: schema.aiAnalysis.id,
username: schema.user.username,
className: schema.user.className,
createTime: schema.aiAnalysis.createTime,
analysis: schema.aiAnalysis.analysis,
})
.from(schema.aiAnalysis)
}).from(schema.aiAnalysis)
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(
eq(schema.aiAnalysis.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.limit(1)
.where(eq(schema.aiAnalysis.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1)
if (!row) return failure(c, 404, "report-not-found", "AIAnalysis not found")
// data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词
return success(c, row satisfies AdminAiReport)
@@ -114,36 +91,18 @@ adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [report] = await db
.select({
id: schema.aiAnalysis.id,
userId: schema.aiAnalysis.userId,
isPinned: schema.aiAnalysis.isPinned,
})
.from(schema.aiAnalysis)
.where(eq(schema.aiAnalysis.id, id))
.limit(1)
if (!report)
return failure(c, 404, "report-not-found", "AIAnalysis not found")
const [report] = await db.select({ id: schema.aiAnalysis.id, userId: schema.aiAnalysis.userId, isPinned: schema.aiAnalysis.isPinned })
.from(schema.aiAnalysis).where(eq(schema.aiAnalysis.id, id)).limit(1)
if (!report) return failure(c, 404, "report-not-found", "AIAnalysis not found")
// 切换语义,与旧后端一致:已置顶则取消;未置顶则先把该学生其它置顶清掉,保证每人至多一份
const next = !report.isPinned
await db.transaction(async (tx) => {
if (next) {
await tx
.update(schema.aiAnalysis)
.set({ isPinned: false })
.where(
and(
eq(schema.aiAnalysis.userId, report.userId),
eq(schema.aiAnalysis.isPinned, true),
),
)
await tx.update(schema.aiAnalysis).set({ isPinned: false })
.where(and(eq(schema.aiAnalysis.userId, report.userId), eq(schema.aiAnalysis.isPinned, true)))
}
await tx
.update(schema.aiAnalysis)
.set({ isPinned: next })
.where(eq(schema.aiAnalysis.id, id))
await tx.update(schema.aiAnalysis).set({ isPinned: next }).where(eq(schema.aiAnalysis.id, id))
})
return success(c, { isPinned: next } satisfies ToggleAiReportPinResponse)
})
+23 -92
View File
@@ -34,11 +34,7 @@ function serialize(row: {
function selectOne(id: number) {
return db
.select({
announcement: schema.announcement,
user: schema.user,
realName: schema.userProfile.realName,
})
.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
.from(schema.announcement)
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -51,21 +47,10 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.announcement),
db
.select({
announcement: schema.announcement,
user: schema.user,
realName: schema.userProfile.realName,
})
db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
.from(schema.announcement)
.innerJoin(
schema.user,
eq(schema.announcement.createdById, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.orderBy(desc(schema.announcement.createTime))
.limit(limit)
.offset(offset),
@@ -78,106 +63,52 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
})
adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => {
const parsed = createAnnouncementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = createAnnouncementRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const now = new Date().toISOString()
const [created] = await db
.insert(schema.announcement)
.values({
const [created] = await db.insert(schema.announcement).values({
...parsed.data,
createTime: now,
lastUpdateTime: now,
createdById: c.get("user")!.id,
})
.returning({ id: schema.announcement.id })
}).returning({ id: schema.announcement.id })
const [row] = await selectOne(created!.id)
return success(c, serialize(row!), 201)
})
adminAnnouncementRoutes.get(
"/announcements/:id",
requireSuperAdmin,
async (c) => {
const [row] = await selectOne(
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!row)
return failure(
c,
404,
"announcement-not-found",
"Announcement does not exist",
)
adminAnnouncementRoutes.get("/announcements/:id", requireSuperAdmin, async (c) => {
const [row] = await selectOne(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
return success(c, serialize(row))
},
)
})
adminAnnouncementRoutes.put(
"/announcements/:id",
requireSuperAdmin,
async (c) => {
adminAnnouncementRoutes.put("/announcements/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAnnouncementRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = updateAnnouncementRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const updated = await db
.update(schema.announcement)
const updated = await db.update(schema.announcement)
.set({ ...parsed.data, lastUpdateTime: new Date().toISOString() })
.where(eq(schema.announcement.id, id))
.returning({ id: schema.announcement.id })
if (updated.length === 0) {
return failure(
c,
404,
"announcement-not-found",
"Announcement does not exist",
)
return failure(c, 404, "announcement-not-found", "Announcement does not exist")
}
const [row] = await selectOne(id)
return success(c, serialize(row!))
},
)
})
adminAnnouncementRoutes.delete(
"/announcements/:id",
requireSuperAdmin,
async (c) => {
adminAnnouncementRoutes.delete("/announcements/:id", requireSuperAdmin, async (c) => {
// 旧后端删不存在的公告也返回成功(filter().delete() 不报错)。这里改成 404:
// 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。
const deleted = await db
.delete(schema.announcement)
.where(
eq(
schema.announcement.id,
queryInteger(c.req.param("id"), 0, { min: 1 }),
),
)
const deleted = await db.delete(schema.announcement)
.where(eq(schema.announcement.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.returning({ id: schema.announcement.id })
if (deleted.length === 0) {
return failure(
c,
404,
"announcement-not-found",
"Announcement does not exist",
)
return failure(c, 404, "announcement-not-found", "Announcement does not exist")
}
return success(c, null)
},
)
})
+40 -134
View File
@@ -14,18 +14,13 @@ import { resolve } from "node:path"
import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm"
import { Hono } from "hono"
import {
requireAdmin,
requireSuperAdmin,
type AppEnv,
} from "../../auth/middleware"
import { requireAdmin, requireSuperAdmin, type AppEnv } from "../../auth/middleware"
import { config } from "../../config"
import { db, schema } from "../../db"
import { publishConfigUpdate } from "../../events"
import { failure, success } from "../../http"
import { getWebsiteOptions } from "../../services/options"
import { todayStart } from "../../time"
import { queryInteger } from "../helpers"
import { queryInteger, todayStart } from "../helpers"
export const adminConfRoutes = new Hono<AppEnv>()
@@ -43,9 +38,7 @@ function aliveSince() {
* 于是同一天的心跳永远小于阈值,**所有判题机都会被标成离线**。
*/
function isAlive(lastHeartbeat: string) {
return (
Date.parse(lastHeartbeat) >= Date.now() - HEARTBEAT_ALIVE_SECONDS * 1000
)
return Date.parse(lastHeartbeat) >= Date.now() - HEARTBEAT_ALIVE_SECONDS * 1000
}
// ---------------------------------------------------------------- 网站配置
@@ -77,24 +70,14 @@ adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
})
adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
const parsed = updateWebsiteConfigRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = updateWebsiteConfigRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const entries = (
Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][]
).map(([field, key]) => ({ field, key, value: parsed.data[field] }))
const entries = (Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][])
.map(([field, key]) => ({ field, key, value: parsed.data[field] }))
// 8 个键一条 upsert 写完,不再一个键一次往返
await db
.insert(schema.optionsSysoptions)
.values(entries.map(({ key, value }) => ({ key, value })))
await db.insert(schema.optionsSysoptions).values(entries.map(({ key, value }) => ({ key, value })))
.onConflictDoUpdate({
target: schema.optionsSysoptions.key,
set: { value: sql`excluded.value` },
@@ -105,75 +88,45 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
// snake_case 是这张表从 Django 继承来的存储格式,只该活在库里;线上这一跳两边
// 都是新写的,没理由让前端再写一层换名胶水。曾经推 snake、前端拿它去比驼峰字段,
// 一条也命中不了,整个「改完不必刷新」空转了很久。
for (const entry of entries)
await publishConfigUpdate(entry.field, entry.value)
for (const entry of entries) await publishConfigUpdate(entry.field, entry.value)
return success(c, null)
})
// ---------------------------------------------------------------- 判题机
adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => {
const rows = await db
.select()
.from(schema.judgeServer)
.orderBy(desc(schema.judgeServer.lastHeartbeat))
const rows = await db.select().from(schema.judgeServer).orderBy(desc(schema.judgeServer.lastHeartbeat))
return success(c, {
// 后台要显示 token 才能拿去配判题机。这个接口是超管专属的
token: config.judgeServerToken,
servers: rows.map(
(row) =>
({
servers: rows.map((row) => ({
...row,
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal",
}) satisfies JudgeServer,
),
} satisfies JudgeServer)),
} satisfies JudgeServerList)
})
adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
const parsed = updateJudgeServerRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "isDisabled is required")
const updated = await db
.update(schema.judgeServer)
const parsed = updateJudgeServerRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "isDisabled is required")
const updated = await db.update(schema.judgeServer)
.set({ isDisabled: parsed.data.isDisabled })
.where(
eq(schema.judgeServer.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
.where(eq(schema.judgeServer.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.returning({ id: schema.judgeServer.id })
if (updated.length === 0)
return failure(
c,
404,
"judge-server-not-found",
"Judge server does not exist",
)
if (updated.length === 0) return failure(c, 404, "judge-server-not-found", "Judge server does not exist")
// 旧后端在这里会 process_pending_task() 把积压的待判任务重新分发。
// 新架构不需要:任务在 BullMQ 里排着,worker 恢复就自己接着消费,不存在「没有新提交
// 就一直 waiting」那种情况 —— 那是旧的自研分发器才有的问题。
return success(c, null)
})
adminConfRoutes.delete(
"/judge-servers/:hostname",
requireSuperAdmin,
async (c) => {
const deleted = await db
.delete(schema.judgeServer)
adminConfRoutes.delete("/judge-servers/:hostname", requireSuperAdmin, async (c) => {
const deleted = await db.delete(schema.judgeServer)
.where(eq(schema.judgeServer.hostname, c.req.param("hostname")))
.returning({ id: schema.judgeServer.id })
if (deleted.length === 0)
return failure(
c,
404,
"judge-server-not-found",
"Judge server does not exist",
)
if (deleted.length === 0) return failure(c, 404, "judge-server-not-found", "Judge server does not exist")
return success(c, null)
},
)
})
// ---------------------------------------------------------------- 孤儿测试用例
@@ -186,24 +139,15 @@ async function orphanTestCaseIds() {
db.select({ id: schema.problem.testCaseId }).from(schema.problem),
])
const referenced = new Set(inDb.map((row) => row.id))
return onDisk.filter(
(name) => TEST_CASE_ID_RE.test(name) && !referenced.has(name),
)
return onDisk.filter((name) => TEST_CASE_ID_RE.test(name) && !referenced.has(name))
}
adminConfRoutes.get("/orphan-test-cases", requireSuperAdmin, async (c) => {
const ids = await orphanTestCaseIds()
const rows = await Promise.all(
ids.map(async (id) => {
const info = await stat(resolve(config.testCaseDirectory, id)).catch(
() => null,
)
return {
id,
createTime: info ? info.mtimeMs / 1000 : 0,
} satisfies OrphanTestCase
}),
)
const rows = await Promise.all(ids.map(async (id) => {
const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null)
return { id, createTime: info ? info.mtimeMs / 1000 : 0 } satisfies OrphanTestCase
}))
return success(c, rows)
})
@@ -214,18 +158,10 @@ adminConfRoutes.delete("/orphan-test-cases", requireSuperAdmin, async (c) => {
// 而测试数据没有别处备份 —— 旧后端这里是不校验的。
const targets = requested ? orphans.filter((id) => id === requested) : orphans
if (requested && targets.length === 0) {
return failure(
c,
404,
"not-an-orphan",
"该用例目录不存在或仍被题目引用,未删除",
)
return failure(c, 404, "not-an-orphan", "该用例目录不存在或仍被题目引用,未删除")
}
for (const id of targets) {
await rm(resolve(config.testCaseDirectory, id), {
recursive: true,
force: true,
})
await rm(resolve(config.testCaseDirectory, id), { recursive: true, force: true })
}
return success(c, { deleted: targets.length })
})
@@ -236,17 +172,11 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
const now = new Date().toISOString()
const [[users], [submissions], [contests], [servers]] = await Promise.all([
db.select({ value: count() }).from(schema.user),
db
.select({ value: count() })
.from(schema.submission)
db.select({ value: count() }).from(schema.submission)
.where(gte(schema.submission.createTime, todayStart())),
db
.select({ value: count() })
.from(schema.contest)
db.select({ value: count() }).from(schema.contest)
.where(not(sql`${schema.contest.endTime} < ${now}`)),
db
.select({ value: count() })
.from(schema.judgeServer)
db.select({ value: count() }).from(schema.judgeServer)
.where(gte(schema.judgeServer.lastHeartbeat, aliveSince())),
])
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发
@@ -264,16 +194,10 @@ adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => {
// 不额外按 className 过滤:那会改变旧行为,而这个功能就是随机点名,宁可宽松
const classroom = c.req.query("classroom")?.trim()
if (!classroom) return failure(c, 400, "invalid-request", "需要班级号")
const rows = await db
.select({ username: schema.user.username })
.from(schema.user)
const rows = await db.select({ username: schema.user.username }).from(schema.user)
.where(ilike(schema.user.username, `${classroom}%`))
.orderBy(sql`random()`)
.limit(10)
return success(
c,
rows.map((row) => row.username),
)
.orderBy(sql`random()`).limit(10)
return success(c, rows.map((row) => row.username))
})
// ---------------------------------------------------------------- 富文本图片上传
@@ -294,28 +218,16 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
const form = await c.req.formData().catch(() => null)
const image = form?.get("image")
if (!(image instanceof File)) {
return success(c, {
success: false,
msg: "Upload failed",
filePath: "",
} satisfies UploadImageResponse)
return success(c, { success: false, msg: "Upload failed", filePath: "" } satisfies UploadImageResponse)
}
const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
if (!IMAGE_SUFFIXES.includes(suffix)) {
return success(c, {
success: false,
msg: "Unsupported file format",
filePath: "",
} satisfies UploadImageResponse)
return success(c, { success: false, msg: "Unsupported file format", filePath: "" } satisfies UploadImageResponse)
}
// 旧后端没有大小限制,靠 nginx 兜。这里显式限一道:文件写在本地磁盘上,
// 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂
if (image.size > MAX_IMAGE_BYTES) {
return success(c, {
success: false,
msg: "图片不能超过 10MB",
filePath: "",
} satisfies UploadImageResponse)
return success(c, { success: false, msg: "图片不能超过 10MB", filePath: "" } satisfies UploadImageResponse)
}
// 文件名完全由服务端生成,不带用户提供的任何一段 —— 原名里的 ../ 或空字节都进不来
const name = `${randomFileName()}${suffix}`
@@ -324,11 +236,7 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
await Bun.write(resolve(config.uploadDirectory, name), image)
} catch (error) {
console.error("Failed to save uploaded image", error)
return success(c, {
success: false,
msg: "Upload Error",
filePath: "",
} satisfies UploadImageResponse)
return success(c, { success: false, msg: "Upload Error", filePath: "" } satisfies UploadImageResponse)
}
return success(c, {
success: true,
@@ -338,8 +246,6 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
})
function randomFileName() {
return Array.from(
{ length: 10 },
() => "abcdefghijklmnopqrstuvwxyz0123456789"[randomInt(36)],
).join("")
return Array.from({ length: 10 }, () =>
"abcdefghijklmnopqrstuvwxyz0123456789"[randomInt(36)]).join("")
}
+50 -168
View File
@@ -49,25 +49,18 @@ async function serialize(row: {
}
function selectContest(id: number) {
return db
.select({
contest: schema.contest,
user: schema.user,
realName: schema.userProfile.realName,
})
return db.select({ contest: schema.contest, user: schema.user, realName: schema.userProfile.realName })
.from(schema.contest)
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.contest.id, id))
.limit(1)
.where(eq(schema.contest.id, id)).limit(1)
}
/** 请求体里的时间校验,创建和编辑共用 */
function validatePayload(data: { startTime: string; endTime: string }) {
const start = Date.parse(data.startTime)
const end = Date.parse(data.endTime)
if (!Number.isFinite(start) || !Number.isFinite(end))
return "开始或结束时间不是合法的时间格式"
if (!Number.isFinite(start) || !Number.isFinite(end)) return "开始或结束时间不是合法的时间格式"
if (end <= start) return "Start time must occur earlier than end time"
return null
}
@@ -78,30 +71,18 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
const user = c.get("user")!
const filters = []
// 非超管只看得到自己建的比赛,与旧后端一致
if (user.adminType !== "Super Admin")
filters.push(eq(schema.contest.createdById, user.id))
if (user.adminType !== "Super Admin") filters.push(eq(schema.contest.createdById, user.id))
const keyword = c.req.query("keyword")?.trim()
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
const where = filters.length ? and(...filters) : undefined
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.contest).where(where),
db
.select({
contest: schema.contest,
user: schema.user,
realName: schema.userProfile.realName,
})
db.select({ contest: schema.contest, user: schema.user, realName: schema.userProfile.realName })
.from(schema.contest)
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where)
.orderBy(desc(schema.contest.createTime))
.limit(limit)
.offset(offset),
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where).orderBy(desc(schema.contest.createTime)).limit(limit).offset(offset),
])
return success(c, {
results: await Promise.all(rows.map(serialize)),
@@ -110,9 +91,7 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
})
adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => {
const [row] = await selectContest(
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
const [row] = await selectContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!row || !ownedBy(c.get("user")!, row.contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
@@ -120,24 +99,15 @@ adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => {
})
adminContestRoutes.post("/contests", requireTeacher, async (c) => {
const parsed = createContestRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = createContestRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const error = validatePayload(parsed.data)
if (error) return failure(c, 400, "invalid-contest", error)
const now = new Date().toISOString()
const [created] = await db
.insert(schema.contest)
.values({
const [created] = await db.insert(schema.contest).values({
title: parsed.data.title,
description: parsed.data.description,
tag: parsed.data.tag,
@@ -149,24 +119,16 @@ adminContestRoutes.post("/contests", requireTeacher, async (c) => {
createdById: c.get("user")!.id,
createTime: now,
lastUpdateTime: now,
})
.returning({ id: schema.contest.id })
}).returning({ id: schema.contest.id })
const [row] = await selectContest(created!.id)
return success(c, await serialize(row!), 201)
})
adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateContestRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = updateContestRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const [existing] = await selectContest(id)
if (!existing || !ownedBy(c.get("user")!, existing.contest)) {
@@ -175,9 +137,7 @@ adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
const error = validatePayload(parsed.data)
if (error) return failure(c, 400, "invalid-contest", error)
await db
.update(schema.contest)
.set({
await db.update(schema.contest).set({
title: parsed.data.title,
description: parsed.data.description,
tag: parsed.data.tag,
@@ -186,8 +146,7 @@ adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
password: parsed.data.password || null,
visible: parsed.data.visible,
lastUpdateTime: new Date().toISOString(),
})
.where(eq(schema.contest.id, id))
}).where(eq(schema.contest.id, id))
const [row] = await selectContest(id)
return success(c, await serialize(row!))
})
@@ -210,12 +169,9 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
//
// 已知的副作用,别当成 bug 去"修":副本和原题共用同一个测试点目录(testCaseId 原样复制),
// 今天无害(删题特意不删目录),但以后要是加"删题顺手清测试点",得先把这里改成复制目录。
if (!original)
return failure(c, 404, "contest-not-found", "Contest does not exist")
if (!original) return failure(c, 404, "contest-not-found", "Contest does not exist")
const duration =
Date.parse(original.contest.endTime) -
Date.parse(original.contest.startTime)
const duration = Date.parse(original.contest.endTime) - Date.parse(original.contest.startTime)
// 新比赛从 10 分钟后开始,时长与原比赛相同 —— 给出题人留出改时间的余地,
// 又不至于建出一个已经结束的比赛
const start = new Date(Date.now() + 10 * 60 * 1000)
@@ -224,9 +180,7 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
const me = c.get("user")!.id
const cloned = await db.transaction(async (tx) => {
const [contest] = await tx
.insert(schema.contest)
.values({
const [contest] = await tx.insert(schema.contest).values({
title: original.contest.title,
description: original.contest.description,
tag: original.contest.tag,
@@ -242,21 +196,15 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
createdById: me,
createTime: now,
lastUpdateTime: now,
})
.returning({ id: schema.contest.id })
}).returning({ id: schema.contest.id })
const problems = await tx
.select()
.from(schema.problem)
const problems = await tx.select().from(schema.problem)
.where(eq(schema.problem.contestId, id))
if (problems.length === 0) return contest!.id
// 题面、标签各一条语句,不再按题循环。新旧题的对应关系靠 _id 认:
// 克隆出来的题原样保留 _id,而它们全在同一场新比赛里,彼此不会重名。
const copies = await tx
.insert(schema.problem)
.values(
problems.map(({ id: _oldId, ...rest }) => ({
const copies = await tx.insert(schema.problem).values(problems.map(({ id: _oldId, ...rest }) => ({
...rest,
contestId: contest!.id,
// 计数器归零:克隆的是题面,不是历史战绩
@@ -266,37 +214,17 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
createdById: me,
createTime: now,
lastUpdateTime: now,
})),
)
.returning({ id: schema.problem.id, displayId: schema.problem.displayId })
const newIdByDisplayId = new Map(
copies.map((copy) => [copy.displayId, copy.id]),
)
}))).returning({ id: schema.problem.id, displayId: schema.problem.displayId })
const newIdByDisplayId = new Map(copies.map((copy) => [copy.displayId, copy.id]))
// 标签是多对多中间表,Django 的 problem.tags.set(tags) 对应这里手工复制关系行
const tags = await tx
.select({
problemId: schema.problemTags.problemId,
tagId: schema.problemTags.problemtagId,
})
.from(schema.problemTags)
.where(
inArray(
schema.problemTags.problemId,
problems.map((problem) => problem.id),
),
)
const tags = await tx.select({ problemId: schema.problemTags.problemId, tagId: schema.problemTags.problemtagId })
.from(schema.problemTags).where(inArray(schema.problemTags.problemId, problems.map((problem) => problem.id)))
if (tags.length) {
const displayIdByOldId = new Map(
problems.map((problem) => [problem.id, problem.displayId]),
)
const displayIdByOldId = new Map(problems.map((problem) => [problem.id, problem.displayId]))
const links = tags.flatMap((tag) => {
const newId = newIdByDisplayId.get(
displayIdByOldId.get(tag.problemId) ?? "",
)
return newId === undefined
? []
: [{ problemId: newId, problemtagId: tag.tagId }]
const newId = newIdByDisplayId.get(displayIdByOldId.get(tag.problemId) ?? "")
return newId === undefined ? [] : [{ problemId: newId, problemtagId: tag.tagId }]
})
if (links.length) await tx.insert(schema.problemTags).values(links)
}
@@ -309,49 +237,31 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
// ---------------------------------------------------------------- ACM 赛后核查
adminContestRoutes.get(
"/contests/:id/acm-helper",
requireTeacher,
async (c) => {
adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的
// PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, id))
.limit(1)
const [contest] = await db.select().from(schema.contest)
.where(eq(schema.contest.id, id)).limit(1)
if (!contest || !ownedBy(c.get("user")!, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
const [problems, ranks] = await Promise.all([
db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(eq(schema.problem.contestId, id)),
db
.select({
db.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem).where(eq(schema.problem.contestId, id)),
db.select({
id: schema.acmContestRank.id,
username: schema.user.username,
realName: schema.userProfile.realName,
submissionInfo: schema.acmContestRank.submissionInfo,
acceptedNumber: schema.acmContestRank.acceptedNumber,
})
.from(schema.acmContestRank)
.innerJoin(
schema.user,
eq(schema.acmContestRank.userId, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
}).from(schema.acmContestRank)
.innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.acmContestRank.contestId, id)),
])
const displayIds = new Map(
problems.map((problem) => [String(problem.id), problem.displayId]),
)
const displayIds = new Map(problems.map((problem) => [String(problem.id), problem.displayId]))
const results = []
for (const rank of ranks) {
@@ -374,51 +284,26 @@ adminContestRoutes.get(
}
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
results.sort((left, right) => right._acTime - left._acTime)
return success(
c,
results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[],
)
},
)
return success(c, results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[])
})
adminContestRoutes.put(
"/contests/:id/acm-helper",
requireTeacher,
async (c) => {
adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => {
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateAcmHelperRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = updateAcmHelperRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, contestId))
.limit(1)
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
if (!contest || !ownedBy(c.get("user")!, contest)) {
return failure(c, 404, "contest-not-found", "Contest does not exist")
}
// rank 必须属于这场比赛。旧后端只按 rank_id 取,不校验归属 ——
// 那样带上任意 rank_id 就能改别的比赛的核查标记
const [rank] = await db
.select()
.from(schema.acmContestRank)
.where(
and(
const [rank] = await db.select().from(schema.acmContestRank).where(and(
eq(schema.acmContestRank.id, parsed.data.rankId),
eq(schema.acmContestRank.contestId, contestId),
),
)
.limit(1)
if (!rank)
return failure(c, 404, "rank-not-found", "Rank id does not exist")
)).limit(1)
if (!rank) return failure(c, 404, "rank-not-found", "Rank id does not exist")
const info = rank.submissionInfo
const entry = info[parsed.data.problemId]
@@ -427,10 +312,7 @@ adminContestRoutes.put(
}
entry.checked = parsed.data.checked
info[parsed.data.problemId] = entry
await db
.update(schema.acmContestRank)
.set({ submissionInfo: info })
await db.update(schema.acmContestRank).set({ submissionInfo: info })
.where(eq(schema.acmContestRank.id, rank.id))
return success(c, null)
},
)
})
+58 -191
View File
@@ -71,12 +71,8 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
// 该语言下已公开的教程,既是分母,也是「哪些课算数」的白名单 ——
// 未公开的课学生本来就打不开,混进来会让读完的人显示成没读完
const tutorials = await db
.select({ id: schema.tutorial.id })
.from(schema.tutorial)
.where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
const tutorials = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
const tutorialIds = tutorials.map((row) => row.id)
// 学生表打底 left join 进度:没读过的人也要出现在结果里,这是这张表的重点
@@ -91,164 +87,93 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
// 做了 8 道练习,join 出来是 24 行,count 全是错的 —— 两个一对多挂在同一张表上
// 就是这个下场,用 filter 也救不回来
const [rows, exerciseRows] = await Promise.all([
db
.select({
db.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
className: schema.user.className,
// 「已读」按 TUTORIAL_READ_SECONDS 卡,不是「有这条记录」:点开一眼就退的不算。
// 累计时长不卡,那些秒数照样算 —— 「已读 0 课、累计 25 分钟」是要看见的一种情况
readCount:
sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(
Number,
),
totalSeconds:
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(
Number,
),
lastViewedAt: sql<
string | null
>`max(${schema.tutorialProgress.lastViewedAt})`,
})
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
readCount: sql<number>`count(${schema.tutorialProgress.tutorialId}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}), 0)`.mapWith(Number),
lastViewedAt: sql<string | null>`max(${schema.tutorialProgress.lastViewedAt})`,
}).from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.leftJoin(schema.tutorialProgress, progressJoin)
.where(studentCondition(className.value))
.groupBy(
schema.user.id,
schema.user.username,
schema.userProfile.realName,
schema.user.className,
)
// 前端默认按「已读」升序排,同分的一大批(尤其一堆 0)就落回这里的次序。
// 不给 orderBy 的话那是聚合吐出来的任意顺序,刷一次换一个样 —— 按班级、
// 学号排稳住它。className 为空的(推不出班级的)ASC 默认排在最后
.orderBy(asc(schema.user.className), asc(schema.user.username)),
db
.select({
.groupBy(schema.user.id, schema.user.username, schema.userProfile.realName, schema.user.className),
db.select({
userId: schema.exerciseAttempt.userId,
tried: count(),
solved:
sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
Number,
),
attempts:
sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}), 0)`.mapWith(
Number,
),
})
.from(schema.exerciseAttempt)
.innerJoin(
schema.exercise,
eq(schema.exercise.id, schema.exerciseAttempt.exerciseId),
)
.innerJoin(
schema.tutorial,
eq(schema.tutorial.id, schema.exercise.tutorialId),
)
.where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
attempts: sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}), 0)`.mapWith(Number),
}).from(schema.exerciseAttempt)
.innerJoin(schema.exercise, eq(schema.exercise.id, schema.exerciseAttempt.exerciseId))
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(schema.exerciseAttempt.userId),
])
const attempts = new Map(exerciseRows.map((row) => [row.userId, row]))
const [exerciseCountRow] = tutorialIds.length
? await db
.select({ value: count() })
.from(schema.exercise)
? await db.select({ value: count() }).from(schema.exercise)
.where(inArray(schema.exercise.tutorialId, tutorialIds))
: [{ value: 0 }]
return success(c, {
tutorialCount: tutorialIds.length,
exerciseCount: exerciseCountRow?.value ?? 0,
results: rows.map(
(row) =>
({
results: rows.map((row) => ({
...row,
exerciseTried: attempts.get(row.userId)?.tried ?? 0,
exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
}) satisfies LearnStudentProgress,
),
} satisfies LearnStudentProgress)),
} satisfies LearnStudentProgressList)
})
adminLearnRoutes.get(
"/learn-analytics/tutorials",
requireTeacher,
async (c) => {
adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const [studentCountRow] = await db
.select({ value: count() })
.from(schema.user)
const [studentCountRow] = await db.select({ value: count() }).from(schema.user)
.where(studentCondition(className.value))
const studentCount = studentCountRow?.value ?? 0
// 进度行 join 回 user 是为了让班级筛选生效,同时把老师自己试读的记录挡在外面
const rows = await db
.select({
const rows = await db.select({
tutorialId: schema.tutorial.id,
title: schema.tutorial.title,
order: schema.tutorial.order,
// 数的是 user.id 而不是 progress.user_idjoin 不上的(老师自己试读的、
// 已禁用的、不在所选班级的)在这一列是 NULL,count(distinct) 正好不算它,
// 而 progress.user_id 那边永远非空,会把过滤当没发生
readers:
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(
Number,
),
totalSeconds:
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
Number,
),
readers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS})`.mapWith(Number),
totalSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
// 人均时长的分母是 readers(读满 3 分钟的人),分子就得是同一批人的时长,
// 否则拿全部时长去除达标人数,人均会被翻了一眼就走的人凭空抬高
readSeconds:
sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(
Number,
),
})
.from(schema.tutorial)
.leftJoin(
schema.tutorialProgress,
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
)
.leftJoin(
schema.user,
and(
readSeconds: sql<number>`coalesce(sum(${schema.tutorialProgress.totalSeconds}) filter (where ${schema.tutorialProgress.totalSeconds} >= ${TUTORIAL_READ_SECONDS}), 0)`.mapWith(Number),
}).from(schema.tutorial)
.leftJoin(schema.tutorialProgress, eq(schema.tutorialProgress.tutorialId, schema.tutorial.id))
.leftJoin(schema.user, and(
eq(schema.user.id, schema.tutorialProgress.userId),
studentCondition(className.value),
),
)
))
// 学生条件写在 join 的 on 上而不是 where 上:写 where 会把没人读过的课整行滤掉,
// 而「一节课一个人都没读」恰恰是老师最需要看见的一行
.where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order))
return success(c, {
studentCount,
results: rows.map(
({ readSeconds, ...row }) =>
({
results: rows.map(({ readSeconds, ...row }) => ({
...row,
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
}) satisfies LearnTutorialProgress,
),
} satisfies LearnTutorialProgress)),
} satisfies LearnTutorialProgressList)
},
)
})
/**
* 按练习:哪道练一练卡住了全班。
@@ -256,21 +181,15 @@ adminLearnRoutes.get(
* 一道题一行,含做过/做对的人数、做对的人平均试了几次、一次就做对的人数。
* 没人做过的题也在列表里(一行零)—— 「这道题全班没一个人碰」同样是要看见的。
*/
adminLearnRoutes.get(
"/learn-analytics/exercises",
requireTeacher,
async (c) => {
adminLearnRoutes.get("/learn-analytics/exercises", requireTeacher, async (c) => {
const type = tutorialTypeOf(c.req.query("type"))
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const [studentCountRow] = await db
.select({ value: count() })
.from(schema.user)
const [studentCountRow] = await db.select({ value: count() }).from(schema.user)
.where(studentCondition(className.value))
const rows = await db
.select({
const rows = await db.select({
exerciseId: schema.exercise.id,
tutorialId: schema.tutorial.id,
tutorialTitle: schema.tutorial.title,
@@ -279,79 +198,40 @@ adminLearnRoutes.get(
order: schema.exercise.order,
// 题干在 jsonb 里,各题型的字段名都叫 question;取不到就给空串,别让整行挂掉
question: sql<string>`coalesce(${schema.exercise.data}->>'question', '')`,
triedUsers: sql<number>`count(distinct ${schema.user.id})`.mapWith(
Number,
),
solvedUsers:
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
Number,
),
firstTryUsers:
sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.attemptsToSolve} = 1)`.mapWith(
Number,
),
attempts:
sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
Number,
),
triedUsers: sql<number>`count(distinct ${schema.user.id})`.mapWith(Number),
solvedUsers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
firstTryUsers: sql<number>`count(distinct ${schema.user.id}) filter (where ${schema.exerciseAttempt.attemptsToSolve} = 1)`.mapWith(Number),
attempts: sql<number>`coalesce(sum(${schema.exerciseAttempt.attempts}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
// 只算做对的人:没做对的人「试了几次」还没停,混进平均值只会把它拉花
avgAttemptsToSolve:
sql<number>`coalesce(avg(${schema.exerciseAttempt.attemptsToSolve}) filter (where ${schema.user.id} is not null), 0)`.mapWith(
Number,
),
})
.from(schema.exercise)
.innerJoin(
schema.tutorial,
eq(schema.tutorial.id, schema.exercise.tutorialId),
)
.leftJoin(
schema.exerciseAttempt,
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
)
avgAttemptsToSolve: sql<number>`coalesce(avg(${schema.exerciseAttempt.attemptsToSolve}) filter (where ${schema.user.id} is not null), 0)`.mapWith(Number),
}).from(schema.exercise)
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.leftJoin(schema.exerciseAttempt, eq(schema.exerciseAttempt.exerciseId, schema.exercise.id))
// 学生条件挂在 join 的 on 上,不是 where 上:写 where 会把没人做过的题整行滤掉
.leftJoin(
schema.user,
and(
.leftJoin(schema.user, and(
eq(schema.user.id, schema.exerciseAttempt.userId),
studentCondition(className.value),
),
)
.where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
.groupBy(
schema.exercise.id,
schema.tutorial.id,
schema.tutorial.title,
schema.tutorial.order,
)
))
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)))
.groupBy(schema.exercise.id, schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
return success(c, {
studentCount: studentCountRow?.value ?? 0,
results: rows.map(
(row) =>
({
results: rows.map((row) => ({
...row,
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
}) satisfies LearnExerciseProgress,
),
} satisfies LearnExerciseProgress)),
} satisfies LearnExerciseProgressList)
},
)
})
/** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */
adminLearnRoutes.get(
"/learn-analytics/exercises/:id/attempts",
requireTeacher,
async (c) => {
adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const className = classFilter(c.req.query("className"))
if (!className.ok) return failure(c, 400, "invalid-class", "班级只能是数字")
const rows = await db
.select({
const rows = await db.select({
userId: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
@@ -362,25 +242,12 @@ adminLearnRoutes.get(
attemptsToSolve: schema.exerciseAttempt.attemptsToSolve,
lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer,
lastAttemptAt: schema.exerciseAttempt.lastAttemptAt,
})
.from(schema.exerciseAttempt)
}).from(schema.exerciseAttempt)
.innerJoin(schema.user, eq(schema.user.id, schema.exerciseAttempt.userId))
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(
and(
eq(schema.exerciseAttempt.exerciseId, id),
studentCondition(className.value),
),
)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and(eq(schema.exerciseAttempt.exerciseId, id), studentCondition(className.value)))
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
.orderBy(
asc(schema.exerciseAttempt.solved),
desc(schema.exerciseAttempt.wrongAttempts),
)
.orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts))
return success(c, rows satisfies LearnExerciseAttempt[])
},
)
})
File diff suppressed because it is too large Load Diff
+121 -401
View File
@@ -12,18 +12,7 @@ import {
type AdminProblemSetProblem,
type AdminProblemSetProgress,
} from "@oj2/contract"
import {
and,
asc,
count,
desc,
eq,
ilike,
inArray,
isNull,
or,
sql,
} from "drizzle-orm"
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
@@ -46,16 +35,9 @@ function ownedBy(user: AuthUser, row: { createdById: number }) {
* 取出题单并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 ——
* 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。
*/
async function loadOwned(
c: { req: { param(name: string): string } },
user: AuthUser,
) {
async function loadOwned(c: { req: { param(name: string): string } }, user: AuthUser) {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [row] = await db
.select()
.from(schema.problemset)
.where(eq(schema.problemset.id, id))
.limit(1)
const [row] = await db.select().from(schema.problemset).where(eq(schema.problemset.id, id)).limit(1)
return row && ownedBy(user, row) ? row : null
}
@@ -68,45 +50,18 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
if (rows.length === 0) return []
const ids = rows.map((row) => row.id)
const [problems, participants, creators] = await Promise.all([
db
.select({
problemsetId: schema.problemsetProblem.problemsetId,
value: count(),
})
.from(schema.problemsetProblem)
.where(inArray(schema.problemsetProblem.problemsetId, ids))
db.select({ problemsetId: schema.problemsetProblem.problemsetId, value: count() })
.from(schema.problemsetProblem).where(inArray(schema.problemsetProblem.problemsetId, ids))
.groupBy(schema.problemsetProblem.problemsetId),
db
.select({
problemsetId: schema.problemsetProgress.problemsetId,
value: count(),
})
.from(schema.problemsetProgress)
.where(inArray(schema.problemsetProgress.problemsetId, ids))
db.select({ problemsetId: schema.problemsetProgress.problemsetId, value: count() })
.from(schema.problemsetProgress).where(inArray(schema.problemsetProgress.problemsetId, ids))
.groupBy(schema.problemsetProgress.problemsetId),
db
.select({
id: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
})
.from(schema.user)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(
inArray(schema.user.id, [
...new Set(rows.map((row) => row.createdById)),
]),
),
db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName })
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(inArray(schema.user.id, [...new Set(rows.map((row) => row.createdById))])),
])
const problemsBySet = new Map(
problems.map((item) => [item.problemsetId, item.value]),
)
const participantsBySet = new Map(
participants.map((item) => [item.problemsetId, item.value]),
)
const problemsBySet = new Map(problems.map((item) => [item.problemsetId, item.value]))
const participantsBySet = new Map(participants.map((item) => [item.problemsetId, item.value]))
const creatorById = new Map(creators.map((item) => [item.id, item]))
return rows.map((row) => {
const creator = creatorById.get(row.createdById)
@@ -118,10 +73,7 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
status: row.status,
endTime: row.endTime,
visible: row.visible,
createdBy: sampleUser(
creator ?? { id: row.createdById, username: "" },
creator?.realName,
),
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime,
problemsCount: problemsBySet.get(row.id) ?? 0,
@@ -140,33 +92,24 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
// 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供
// 「切换可见性」的接口 —— 一旦把题单设成不可见,它就从后台列表里消失,
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。
if (user.adminType !== "Super Admin")
filters.push(eq(schema.problemset.createdById, user.id))
if (user.adminType !== "Super Admin") filters.push(eq(schema.problemset.createdById, user.id))
const keyword = c.req.query("keyword")?.trim()
const difficulty = c.req.query("difficulty")?.trim()
const status = c.req.query("status")?.trim()
if (keyword) {
filters.push(
or(
filters.push(or(
ilike(schema.problemset.title, `%${keyword}%`),
ilike(schema.problemset.description, `%${keyword}%`),
)!,
)
)!)
}
if (difficulty)
filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
const where = filters.length ? and(...filters) : undefined
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problemset).where(where),
db
.select()
.from(schema.problemset)
.where(where)
.orderBy(desc(schema.problemset.createTime))
.limit(limit)
.offset(offset),
db.select().from(schema.problemset).where(where)
.orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset),
])
return success(c, {
results: await serializeMany(rows),
@@ -175,30 +118,18 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
})
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
const parsed = createProblemSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = createProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const now = new Date().toISOString()
const [created] = await db
.insert(schema.problemset)
.values({
const [created] = await db.insert(schema.problemset).values({
...parsed.data,
endTime: parsed.data.endTime
? new Date(parsed.data.endTime).toISOString()
: null,
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
createdById: c.get("user")!.id,
createTime: now,
lastUpdateTime: now,
})
.returning()
}).returning()
return success(c, await serialize(created!), 201)
})
@@ -211,69 +142,38 @@ adminProblemSetRoutes.get("/problem-sets/:id", requireTeacher, async (c) => {
adminProblemSetRoutes.put("/problem-sets/:id", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = updateProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const [updated] = await db
.update(schema.problemset)
.set({
const [updated] = await db.update(schema.problemset).set({
...parsed.data,
endTime: parsed.data.endTime
? new Date(parsed.data.endTime).toISOString()
: null,
endTime: parsed.data.endTime ? new Date(parsed.data.endTime).toISOString() : null,
lastUpdateTime: new Date().toISOString(),
})
.where(eq(schema.problemset.id, row.id))
.returning()
}).where(eq(schema.problemset.id, row.id)).returning()
return success(c, await serialize(updated!))
})
adminProblemSetRoutes.put(
"/problem-sets/:id/visibility",
requireTeacher,
async (c) => {
adminProblemSetRoutes.put("/problem-sets/:id/visibility", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
// 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关
const [updated] = await db
.update(schema.problemset)
const [updated] = await db.update(schema.problemset)
.set({ visible: !row.visible, lastUpdateTime: new Date().toISOString() })
.where(eq(schema.problemset.id, row.id))
.returning()
.where(eq(schema.problemset.id, row.id)).returning()
return success(c, await serialize(updated!))
},
)
})
adminProblemSetRoutes.put(
"/problem-sets/:id/status",
requireTeacher,
async (c) => {
adminProblemSetRoutes.put("/problem-sets/:id/status", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetStatusRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "status 不合法")
const [updated] = await db
.update(schema.problemset)
.set({
status: parsed.data.status,
lastUpdateTime: new Date().toISOString(),
})
.where(eq(schema.problemset.id, row.id))
.returning()
const parsed = updateProblemSetStatusRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "status 不合法")
const [updated] = await db.update(schema.problemset)
.set({ status: parsed.data.status, lastUpdateTime: new Date().toISOString() })
.where(eq(schema.problemset.id, row.id)).returning()
return success(c, await serialize(updated!))
},
)
})
adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
@@ -286,29 +186,15 @@ adminProblemSetRoutes.delete("/problem-sets/:id", requireTeacher, async (c) => {
// ---------------------------------------------------------------- 题单里的题目
adminProblemSetRoutes.get(
"/problem-sets/:id/problems",
requireTeacher,
async (c) => {
adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const rows = await db
.select({ item: schema.problemsetProblem, problem: schema.problem })
const rows = await db.select({ item: schema.problemsetProblem, problem: schema.problem })
.from(schema.problemsetProblem)
.innerJoin(
schema.problem,
eq(schema.problemsetProblem.problemId, schema.problem.id),
)
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
.where(eq(schema.problemsetProblem.problemsetId, row.id))
.orderBy(
asc(schema.problemsetProblem.order),
asc(schema.problemsetProblem.id),
)
return success(
c,
rows.map(
({ item, problem }) =>
({
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
return success(c, rows.map(({ item, problem }) => ({
id: item.id,
problemsetId: item.problemsetId,
problemId: item.problemId,
@@ -319,142 +205,76 @@ adminProblemSetRoutes.get(
isRequired: item.isRequired,
score: item.score,
hint: item.hint,
}) satisfies AdminProblemSetProblem,
),
)
},
)
} satisfies AdminProblemSetProblem)))
})
adminProblemSetRoutes.post(
"/problem-sets/:id/problems",
requireTeacher,
async (c) => {
adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = addProblemToSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = addProblemToSetRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const [problem] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem).where(and(
sql`lower(${schema.problem.displayId}) = lower(${parsed.data.problemId})`,
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
),
)
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "题目不存在或不可见")
)).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在或不可见")
const [duplicate] = await db
.select({ id: schema.problemsetProblem.id })
.from(schema.problemsetProblem)
.where(
and(
const [duplicate] = await db.select({ id: schema.problemsetProblem.id }).from(schema.problemsetProblem)
.where(and(
eq(schema.problemsetProblem.problemsetId, row.id),
eq(schema.problemsetProblem.problemId, problem.id),
),
)
.limit(1)
if (duplicate)
return failure(c, 409, "problem-already-in-set", "题目已在该题单中")
)).limit(1)
if (duplicate) return failure(c, 409, "problem-already-in-set", "题目已在该题单中")
const [created] = await db
.insert(schema.problemsetProblem)
.values({
const [created] = await db.insert(schema.problemsetProblem).values({
problemsetId: row.id,
problemId: problem.id,
order: parsed.data.order,
isRequired: parsed.data.isRequired,
score: parsed.data.score,
hint: parsed.data.hint,
})
.returning({ id: schema.problemsetProblem.id })
}).returning({ id: schema.problemsetProblem.id })
// 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变,
// 否则学生看到的进度分母还是老的。旧栈是靠 ProblemSetProblem 的 post_save 信号做的,
// 不在 views 里,别因为翻不到显式调用就以为它没做(见 services/problemset.ts)。
await resyncProgress(row.id)
return success(c, { id: created!.id }, 201)
},
)
})
adminProblemSetRoutes.put(
"/problem-sets/:id/problems/:itemId",
requireTeacher,
async (c) => {
adminProblemSetRoutes.put("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemInSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = updateProblemInSetRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "参数错误")
const updated = await db
.update(schema.problemsetProblem)
.set(parsed.data)
.where(
and(
eq(
schema.problemsetProblem.id,
queryInteger(c.req.param("itemId"), 0, { min: 1 }),
),
const updated = await db.update(schema.problemsetProblem).set(parsed.data).where(and(
eq(schema.problemsetProblem.id, queryInteger(c.req.param("itemId"), 0, { min: 1 })),
eq(schema.problemsetProblem.problemsetId, row.id),
),
)
.returning({ id: schema.problemsetProblem.id })
if (updated.length === 0)
return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
)).returning({ id: schema.problemsetProblem.id })
if (updated.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
if (parsed.data.score !== undefined) await resyncProgress(row.id)
return success(c, null)
},
)
})
adminProblemSetRoutes.delete(
"/problem-sets/:id/problems/:itemId",
requireTeacher,
async (c) => {
adminProblemSetRoutes.delete("/problem-sets/:id/problems/:itemId", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const deleted = await db
.delete(schema.problemsetProblem)
.where(
and(
eq(
schema.problemsetProblem.id,
queryInteger(c.req.param("itemId"), 0, { min: 1 }),
),
const deleted = await db.delete(schema.problemsetProblem).where(and(
eq(schema.problemsetProblem.id, queryInteger(c.req.param("itemId"), 0, { min: 1 })),
eq(schema.problemsetProblem.problemsetId, row.id),
),
)
.returning({
id: schema.problemsetProblem.id,
problemId: schema.problemsetProblem.problemId,
})
if (deleted.length === 0)
return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
)).returning({ id: schema.problemsetProblem.id, problemId: schema.problemsetProblem.problemId })
if (deleted.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
// 这道题在本题单里的提交记录也要清掉,对齐旧栈 problemset/signals.py 的 post_delete。
// 不清的话 problemset_submission 会一直攒指向已移出题单的孤儿行。
await db
.delete(schema.problemsetSubmission)
.where(
and(
await db.delete(schema.problemsetSubmission).where(and(
eq(schema.problemsetSubmission.problemsetId, row.id),
eq(schema.problemsetSubmission.problemId, deleted[0]!.problemId),
),
)
))
await resyncProgress(row.id)
return success(c, null)
},
)
})
// ---------------------------------------------------------------- 奖章
@@ -465,20 +285,11 @@ async function badgeWithCount(badge: BadgeRow) {
/** 批量版:一条 group by 数完整批奖章的获得人数 */
async function badgesWithCount(badges: BadgeRow[]) {
if (badges.length === 0) return []
const earned = await db
.select({ badgeId: schema.userBadge.badgeId, value: count() })
.from(schema.userBadge)
.where(
inArray(
schema.userBadge.badgeId,
badges.map((badge) => badge.id),
),
)
const earned = await db.select({ badgeId: schema.userBadge.badgeId, value: count() })
.from(schema.userBadge).where(inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id)))
.groupBy(schema.userBadge.badgeId)
const countByBadge = new Map(earned.map((item) => [item.badgeId, item.value]))
return badges.map(
(badge) =>
({
return badges.map((badge) => ({
id: badge.id,
problemsetId: badge.problemsetId,
name: badge.name,
@@ -487,95 +298,50 @@ async function badgesWithCount(badges: BadgeRow[]) {
conditionType: badge.conditionType,
conditionValue: badge.conditionValue,
earnedCount: countByBadge.get(badge.id) ?? 0,
}) satisfies AdminProblemSetBadge,
)
} satisfies AdminProblemSetBadge))
}
adminProblemSetRoutes.get(
"/problem-sets/:id/badges",
requireTeacher,
async (c) => {
adminProblemSetRoutes.get("/problem-sets/:id/badges", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badges = await db
.select()
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, row.id))
.orderBy(asc(schema.problemsetBadge.id))
const badges = await db.select().from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, row.id)).orderBy(asc(schema.problemsetBadge.id))
return success(c, await badgesWithCount(badges))
},
)
})
adminProblemSetRoutes.post(
"/problem-sets/:id/badges",
requireTeacher,
async (c) => {
adminProblemSetRoutes.post("/problem-sets/:id/badges", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = createProblemSetBadgeRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = createProblemSetBadgeRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const [created] = await db
.insert(schema.problemsetBadge)
.values({
const [created] = await db.insert(schema.problemsetBadge).values({
...parsed.data,
problemsetId: row.id,
})
.returning()
}).returning()
// 新建奖章要立刻补发给已达标的人 —— 旧后端靠 post_save 信号,这里显式调
await recalculateBadge(created!)
return success(c, await badgeWithCount(created!), 201)
},
)
})
adminProblemSetRoutes.put(
"/problem-sets/:id/badges/:badgeId",
requireTeacher,
async (c) => {
adminProblemSetRoutes.put("/problem-sets/:id/badges/:badgeId", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const parsed = updateProblemSetBadgeRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = updateProblemSetBadgeRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const [updated] = await db
.update(schema.problemsetBadge)
.set(parsed.data)
.where(
and(
eq(
schema.problemsetBadge.id,
queryInteger(c.req.param("badgeId"), 0, { min: 1 }),
),
const [updated] = await db.update(schema.problemsetBadge).set(parsed.data).where(and(
eq(schema.problemsetBadge.id, queryInteger(c.req.param("badgeId"), 0, { min: 1 })),
eq(schema.problemsetBadge.problemsetId, row.id),
),
)
.returning()
)).returning()
if (!updated) return failure(c, 404, "badge-not-found", "奖章不存在")
await recalculateBadge(updated)
return success(c, await badgeWithCount(updated))
},
)
})
adminProblemSetRoutes.delete(
"/problem-sets/:id/badges/:badgeId",
requireTeacher,
async (c) => {
adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badgeId = queryInteger(c.req.param("badgeId"), 0, { min: 1 })
@@ -583,54 +349,32 @@ adminProblemSetRoutes.delete(
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单,
// 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录,
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。
const [badge] = await db
.select({ id: schema.problemsetBadge.id })
.from(schema.problemsetBadge)
.where(
and(
const [badge] = await db.select({ id: schema.problemsetBadge.id }).from(schema.problemsetBadge)
.where(and(
eq(schema.problemsetBadge.id, badgeId),
eq(schema.problemsetBadge.problemsetId, row.id),
),
)
.limit(1)
)).limit(1)
if (!badge) return failure(c, 404, "badge-not-found", "奖章不存在")
// 获奖记录随奖章一起没:user_badge.badge_id 是 CASCADE0010
await db
.delete(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.id, badge.id))
await db.delete(schema.problemsetBadge).where(eq(schema.problemsetBadge.id, badge.id))
return success(c, null)
},
)
})
// ---------------------------------------------------------------- 学生进度
adminProblemSetRoutes.get(
"/problem-sets/:id/progress",
requireTeacher,
async (c) => {
adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const rows = await db
.select({
const rows = await db.select({
progress: schema.problemsetProgress,
username: schema.user.username,
realName: schema.userProfile.realName,
})
.from(schema.problemsetProgress)
.innerJoin(
schema.user,
eq(schema.problemsetProgress.userId, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
}).from(schema.problemsetProgress)
.innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.problemsetProgress.problemsetId, row.id))
.orderBy(desc(schema.problemsetProgress.joinTime))
return success(
c,
rows.map(
({ progress, username, realName }) =>
return success(c, rows.map(({ progress, username, realName }) =>
({
id: progress.id,
userId: progress.userId,
@@ -644,56 +388,32 @@ adminProblemSetRoutes.get(
completedProblemsCount: progress.completedProblemsCount,
totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore,
}) satisfies AdminProblemSetProgress,
),
)
},
)
} satisfies AdminProblemSetProgress)))
})
adminProblemSetRoutes.delete(
"/problem-sets/:id/progress/:userId",
requireTeacher,
async (c) => {
adminProblemSetRoutes.delete("/problem-sets/:id/progress/:userId", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const userId = queryInteger(c.req.param("userId"), 0, { min: 1 })
const deleted = await db.transaction(async (tx) => {
// 把人踢出题单,他基于这份题单拿到的奖章也该收回,否则奖章会悬空
const badges = await tx
.select({ id: schema.problemsetBadge.id })
.from(schema.problemsetBadge)
const badges = await tx.select({ id: schema.problemsetBadge.id }).from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, row.id))
if (badges.length) {
await tx.delete(schema.userBadge).where(
and(
await tx.delete(schema.userBadge).where(and(
eq(schema.userBadge.userId, userId),
inArray(
schema.userBadge.badgeId,
badges.map((badge) => badge.id),
),
),
)
inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id)),
))
}
await tx
.delete(schema.problemsetSubmission)
.where(
and(
await tx.delete(schema.problemsetSubmission).where(and(
eq(schema.problemsetSubmission.problemsetId, row.id),
eq(schema.problemsetSubmission.userId, userId),
),
)
return tx
.delete(schema.problemsetProgress)
.where(
and(
))
return tx.delete(schema.problemsetProgress).where(and(
eq(schema.problemsetProgress.problemsetId, row.id),
eq(schema.problemsetProgress.userId, userId),
),
)
.returning({ id: schema.problemsetProgress.id })
)).returning({ id: schema.problemsetProgress.id })
})
if (deleted.length === 0)
return failure(c, 404, "progress-not-found", "用户未加入该题单")
if (deleted.length === 0) return failure(c, 404, "progress-not-found", "用户未加入该题单")
return success(c, null)
},
)
})
+77 -261
View File
@@ -9,45 +9,22 @@ import {
type RenameTagResponse,
type StuckProblem,
} from "@oj2/contract"
import {
and,
asc,
countDistinct,
count,
desc,
eq,
gte,
ilike,
inArray,
isNull,
lte,
ne,
sql,
} from "drizzle-orm"
import { and, asc, countDistinct, count, desc, eq, gte, ilike, inArray, isNull, lte, ne, sql } from "drizzle-orm"
import { Hono } from "hono"
import {
requireProblemPermission,
requireTeacher,
type AppEnv,
} from "../../auth/middleware"
import { requireProblemPermission, requireTeacher, type AppEnv } from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { JudgeStatus } from "../../judge/status"
import { completeChat } from "../../services/ai"
import { localTime, localYear } from "../../time"
import { queryInteger, rounded } from "../helpers"
import { findTagsByName, normalizeTagNames } from "./problem"
export const adminTagRoutes = new Hono<AppEnv>()
const ACCEPTED = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
const FAILED = [
JudgeStatus.WRONG_ANSWER,
JudgeStatus.COMPILE_ERROR,
JudgeStatus.RUNTIME_ERROR,
]
const FAILED = [JudgeStatus.WRONG_ANSWER, JudgeStatus.COMPILE_ERROR, JudgeStatus.RUNTIME_ERROR]
/** 能管所有题目:超管,或 problemPermission 为 All */
function canManageAllProblems(user: AuthUser) {
@@ -58,91 +35,51 @@ function canManageAllProblems(user: AuthUser) {
adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
const keyword = c.req.query("keyword")?.trim()
const rows = await db
.select({
const rows = await db.select({
id: schema.problemTag.id,
name: schema.problemTag.name,
problemCount: countDistinct(schema.problemTags.problemId),
})
.from(schema.problemTag)
.leftJoin(
schema.problemTags,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
}).from(schema.problemTag)
.leftJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
.groupBy(schema.problemTag.id, schema.problemTag.name)
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0
.orderBy(
desc(countDistinct(schema.problemTags.problemId)),
asc(schema.problemTag.name),
)
.orderBy(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name))
return success(c, rows satisfies AdminTag[])
})
adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = renameTagRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "标签名不能为空")
const parsed = renameTagRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "标签名不能为空")
const name = parsed.data.name
const [tag] = await db
.select()
.from(schema.problemTag)
.where(eq(schema.problemTag.id, id))
.limit(1)
const [tag] = await db.select().from(schema.problemTag).where(eq(schema.problemTag.id, id)).limit(1)
if (!tag) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
const [target] = await db
.select()
.from(schema.problemTag)
.where(
and(
sql`lower(${schema.problemTag.name}) = lower(${name})`,
ne(schema.problemTag.id, id),
),
)
.limit(1)
const [target] = await db.select().from(schema.problemTag)
.where(and(sql`lower(${schema.problemTag.name}) = lower(${name})`, ne(schema.problemTag.id, id))).limit(1)
if (!target) {
await db
.update(schema.problemTag)
.set({ name })
.where(eq(schema.problemTag.id, id))
return success(c, {
merged: false,
id,
name,
affectedCount: 0,
} satisfies RenameTagResponse)
await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id))
return success(c, { merged: false, id, name, affectedCount: 0 } satisfies RenameTagResponse)
}
// 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除
const affected = await db.transaction(async (tx) => {
const links = await tx
.select({ problemId: schema.problemTags.problemId })
.from(schema.problemTags)
.where(eq(schema.problemTags.problemtagId, id))
const already = new Set(
(
await tx
.select({ problemId: schema.problemTags.problemId })
.from(schema.problemTags)
.where(eq(schema.problemTags.problemtagId, target.id))
).map((row) => row.problemId),
)
const links = await tx.select({ problemId: schema.problemTags.problemId })
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
const already = new Set((await tx.select({ problemId: schema.problemTags.problemId })
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, target.id)))
.map((row) => row.problemId))
// 只给还没挂目标标签的题目补关系,否则会撞 (problem_id, problemtag_id) 唯一约束
const missing = links.filter((link) => !already.has(link.problemId))
if (missing.length) {
await tx.insert(schema.problemTags).values(
missing.map((link) => ({
await tx.insert(schema.problemTags).values(missing.map((link) => ({
problemId: link.problemId,
problemtagId: target.id,
})),
)
})))
}
// 旧标签上剩下的关系行随标签一起没:problem_tags.problemtag_id 是 CASCADE0010)。
// 上面那批 insert 已经把题目挂到 target 上了,这里删掉的只是旧的那一份关系。
@@ -150,57 +87,29 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
return links.length
})
return success(c, {
merged: true,
id: target.id,
name: target.name,
affectedCount: affected,
merged: true, id: target.id, name: target.name, affectedCount: affected,
} satisfies RenameTagResponse)
})
adminTagRoutes.delete(
"/problem-tags/:id",
requireProblemPermission,
async (c) => {
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE0010
const deleted = await db
.delete(schema.problemTag)
.where(eq(schema.problemTag.id, id))
const deleted = await db.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
.returning({ id: schema.problemTag.id })
if (deleted.length === 0)
return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
if (deleted.length === 0) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
return success(c, null)
},
)
})
adminTagRoutes.post(
"/problems/batch-tag",
requireProblemPermission,
async (c) => {
const parsed = batchProblemTagRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
adminTagRoutes.post("/problems/batch-tag", requireProblemPermission, async (c) => {
const parsed = batchProblemTagRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "参数错误",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
}
const user = c.get("user")!
const filters = [
inArray(schema.problem.id, parsed.data.problemIds),
isNull(schema.problem.contestId),
]
if (!canManageAllProblems(user))
filters.push(eq(schema.problem.createdById, user.id))
const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(and(...filters))
if (problems.length === 0)
return failure(c, 404, "no-problems", "没有可操作的题目")
const filters = [inArray(schema.problem.id, parsed.data.problemIds), isNull(schema.problem.contestId)]
if (!canManageAllProblems(user)) filters.push(eq(schema.problem.createdById, user.id))
const problems = await db.select({ id: schema.problem.id }).from(schema.problem).where(and(...filters))
if (problems.length === 0) return failure(c, 404, "no-problems", "没有可操作的题目")
// 去重且大小写不敏感,与旧 resolve_tags / find_tags 一致
const wanted = normalizeTagNames(parsed.data.tagNames)
@@ -209,57 +118,35 @@ adminTagRoutes.post(
const existing = await findTagsByName(tx as unknown as typeof db, wanted)
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
if (parsed.data.action === "add") {
const missing = wanted.filter(
(name) => !existing.has(name.toLowerCase()),
)
const missing = wanted.filter((name) => !existing.has(name.toLowerCase()))
if (missing.length) {
const created = await tx
.insert(schema.problemTag)
.values(missing.map((name) => ({ name })))
.returning({
id: schema.problemTag.id,
name: schema.problemTag.name,
})
for (const row of created)
existing.set(row.name.toLowerCase(), row.id)
const created = await tx.insert(schema.problemTag).values(missing.map((name) => ({ name })))
.returning({ id: schema.problemTag.id, name: schema.problemTag.name })
for (const row of created) existing.set(row.name.toLowerCase(), row.id)
}
}
return wanted
.map((name) => existing.get(name.toLowerCase()))
.filter((id) => id !== undefined)
return wanted.map((name) => existing.get(name.toLowerCase())).filter((id) => id !== undefined)
})
if (tagIds.length === 0) return failure(c, 404, "no-tags", "没有匹配的标签")
const problemIds = problems.map((problem) => problem.id)
await db.transaction(async (tx) => {
if (parsed.data.action === "remove") {
await tx
.delete(schema.problemTags)
.where(
and(
await tx.delete(schema.problemTags).where(and(
inArray(schema.problemTags.problemId, problemIds),
inArray(schema.problemTags.problemtagId, tagIds),
),
)
))
return
}
const existing = await tx
.select()
.from(schema.problemTags)
.where(
and(
const existing = await tx.select().from(schema.problemTags).where(and(
inArray(schema.problemTags.problemId, problemIds),
inArray(schema.problemTags.problemtagId, tagIds),
),
)
const have = new Set(
existing.map((row) => `${row.problemId}:${row.problemtagId}`),
)
))
const have = new Set(existing.map((row) => `${row.problemId}:${row.problemtagId}`))
const rows = []
for (const problemId of problemIds) {
for (const tagId of tagIds) {
if (!have.has(`${problemId}:${tagId}`))
rows.push({ problemId, problemtagId: tagId })
if (!have.has(`${problemId}:${tagId}`)) rows.push({ problemId, problemtagId: tagId })
}
}
if (rows.length) await tx.insert(schema.problemTags).values(rows)
@@ -269,25 +156,14 @@ adminTagRoutes.post(
problemCount: problems.length,
tagCount: tagIds.length,
} satisfies BatchProblemTagResponse)
},
)
})
// ---------------------------------------------------------------- 题目可见性
adminTagRoutes.put(
"/problems/:id/visibility",
requireProblemPermission,
async (c) => {
adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problem] = await db
.select({
id: schema.problem.id,
visible: schema.problem.visible,
createdById: schema.problem.createdById,
})
.from(schema.problem)
.where(eq(schema.problem.id, id))
.limit(1)
const [problem] = await db.select({ id: schema.problem.id, visible: schema.problem.visible, createdById: schema.problem.createdById })
.from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
// 旧后端这里的 `self.error(...)` 少写了 return,题目不存在时会继续往下跑并抛
// AttributeError500)。这里正常返回 404。
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
@@ -295,13 +171,9 @@ adminTagRoutes.put(
if (!canManageAllProblems(user) && problem.createdById !== user.id) {
return failure(c, 404, "problem-not-found", "题目不存在")
}
await db
.update(schema.problem)
.set({ visible: !problem.visible })
.where(eq(schema.problem.id, id))
await db.update(schema.problem).set({ visible: !problem.visible }).where(eq(schema.problem.id, id))
return success(c, { visible: !problem.visible })
},
)
})
// ---------------------------------------------------------------- 卡点题目 / AC 趋势
@@ -311,26 +183,15 @@ adminTagRoutes.put(
// requireTeacher,而且完全没有报错。换个前缀,结构上就不可能再被遮蔽。
adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})`
const rows = await db
.select({
const rows = await db.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
total: count(),
accepted:
sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(
Number,
),
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number),
failed: sql<number>`count(*) ${failedFilter}`.mapWith(Number),
failedUsers:
sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(
Number,
),
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
failedUsers: sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(Number),
}).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
/**
* ac-trend where
* 1 61
@@ -345,93 +206,55 @@ adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
*/
.where(isNull(schema.submission.contestId))
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
.having(
sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`,
)
.orderBy(
desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`),
)
.having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`)
.orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`))
.limit(40)
return success(
c,
rows.map(
(row) =>
({
return success(c, rows.map((row) => ({
problemId: row.displayId,
problemTitle: row.title,
total: row.total,
failed: row.failed,
failedUsers: row.failedUsers,
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
}) satisfies StuckProblem,
),
)
} satisfies StuckProblem)))
})
adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
const currentYear = localYear()
const currentYear = new Date().getFullYear()
// 参数按旧后端的口径夹逼:越界一律回落到默认值,不报错
let sinceYear = queryInteger(c.req.query("sinceYear"), 2023)
if (sinceYear < 2022 || sinceYear > currentYear) sinceYear = 2023
let untilYear = queryInteger(c.req.query("untilYear"), currentYear)
if (untilYear < sinceYear || untilYear > currentYear)
untilYear = currentYear - 1
if (untilYear < sinceYear || untilYear > currentYear) untilYear = currentYear - 1
let minPerYear = queryInteger(c.req.query("minPerYear"), 100)
if (![50, 100, 200].includes(minPerYear)) minPerYear = 100
// 年份按东八区切,和上面 `currentYear` 的夹逼同口径
const year =
sql<number>`extract(year from ${localTime(schema.submission.createTime)})`.mapWith(
Number,
)
const rows = await db
.select({
const year = sql<number>`extract(year from ${schema.submission.createTime})`.mapWith(Number)
const rows = await db.select({
problemId: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
year,
total: count(),
accepted:
sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(
Number,
),
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.where(
and(
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number),
}).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(and(
isNull(schema.submission.contestId),
gte(year, sinceYear),
lte(year, untilYear),
),
)
.groupBy(
schema.problem.id,
schema.problem.displayId,
schema.problem.title,
year,
)
gte(sql`extract(year from ${schema.submission.createTime})`, sinceYear),
lte(sql`extract(year from ${schema.submission.createTime})`, untilYear),
))
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title, year)
.orderBy(asc(schema.problem.id), asc(year))
const required = new Set<number>()
for (let y = sinceYear; y <= untilYear; y += 1) required.add(y)
const grouped = new Map<
number,
{ displayId: string; title: string; yearly: typeof rows }
>()
const grouped = new Map<number, { displayId: string; title: string; yearly: typeof rows }>()
for (const row of rows) {
const bucket = grouped.get(row.problemId)
if (bucket) bucket.yearly.push(row)
else
grouped.set(row.problemId, {
displayId: row.displayId,
title: row.title,
yearly: [row],
})
else grouped.set(row.problemId, { displayId: row.displayId, title: row.title, yearly: [row] })
}
const result = []
@@ -458,15 +281,9 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
// ---------------------------------------------------------------- Python → 流程图
adminTagRoutes.post(
"/problems/flowchart",
requireProblemPermission,
async (c) => {
const parsed = generateFlowchartRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "python 代码不能为空")
adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) => {
const parsed = generateFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "python 代码不能为空")
try {
const flowchart = await completeChat(
`你是一个可以将Python代码转换为mermaid的助手。
@@ -480,5 +297,4 @@ adminTagRoutes.post(
console.error("Flowchart generation failed", error)
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
}
},
)
})
+42 -130
View File
@@ -40,11 +40,7 @@ function serializeTutorial(row: {
function selectTutorial(id: number) {
return db
.select({
tutorial: schema.tutorial,
user: schema.user,
realName: schema.userProfile.realName,
})
.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName })
.from(schema.tutorial)
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -54,11 +50,7 @@ function selectTutorial(id: number) {
adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
const rows = await db
.select({
tutorial: schema.tutorial,
user: schema.user,
realName: schema.userProfile.realName,
})
.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName })
.from(schema.tutorial)
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
@@ -72,98 +64,62 @@ adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
})
adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => {
const parsed = createTutorialRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = createTutorialRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const now = new Date().toISOString()
const [created] = await db
.insert(schema.tutorial)
.values({
const [created] = await db.insert(schema.tutorial).values({
...parsed.data,
createdAt: now,
updatedAt: now,
createdById: c.get("user")!.id,
})
.returning({ id: schema.tutorial.id })
}).returning({ id: schema.tutorial.id })
const [row] = await selectTutorial(created!.id)
return success(c, serializeTutorial(row!), 201)
})
adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => {
const [row] = await selectTutorial(
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!row)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, serializeTutorial(row))
})
adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = updateTutorialRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = updateTutorialRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const updated = await db
.update(schema.tutorial)
const updated = await db.update(schema.tutorial)
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
.where(eq(schema.tutorial.id, id))
.returning({ id: schema.tutorial.id })
if (updated.length === 0)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
.where(eq(schema.tutorial.id, id)).returning({ id: schema.tutorial.id })
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(id)
return success(c, serializeTutorial(row!))
})
adminTutorialRoutes.put(
"/tutorials/:id/visibility",
requireSuperAdmin,
async (c) => {
adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = setTutorialVisibilityRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "isPublic is required")
const parsed = setTutorialVisibilityRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "isPublic is required")
// 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉
const updated = await db
.update(schema.tutorial)
const updated = await db.update(schema.tutorial)
.set({ isPublic: parsed.data.isPublic })
.where(eq(schema.tutorial.id, id))
.returning({ id: schema.tutorial.id })
if (updated.length === 0)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
.where(eq(schema.tutorial.id, id)).returning({ id: schema.tutorial.id })
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [row] = await selectTutorial(id)
return success(c, serializeTutorial(row!))
},
)
})
adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
// 练习与学习留痕都随教程一起没:exercise.tutorial_id 与 tutorial_progress.tutorial_id
// 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**,
// 别默认新表会自己连坐 —— 0010 只改了当时存在的那批外键。
const deleted = await db
.delete(schema.tutorial)
.where(eq(schema.tutorial.id, id))
const deleted = await db.delete(schema.tutorial).where(eq(schema.tutorial.id, id))
.returning({ id: schema.tutorial.id })
if (deleted.length === 0)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
if (deleted.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, null)
})
@@ -180,96 +136,52 @@ function serializeExercise(row: typeof schema.exercise.$inferSelect) {
// 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数,
// 但它本来就是一对多的从属关系,嵌套路径更贴事实,也省掉「忘了传 tutorial_id」这类错误
adminTutorialRoutes.get(
"/tutorials/:id/exercises",
requireSuperAdmin,
async (c) => {
const rows = await db
.select()
.from(schema.exercise)
.where(
eq(
schema.exercise.tutorialId,
queryInteger(c.req.param("id"), 0, { min: 1 }),
),
)
adminTutorialRoutes.get("/tutorials/:id/exercises", requireSuperAdmin, async (c) => {
const rows = await db.select().from(schema.exercise)
.where(eq(schema.exercise.tutorialId, queryInteger(c.req.param("id"), 0, { min: 1 })))
.orderBy(asc(schema.exercise.order), asc(schema.exercise.id))
return success(c, rows.map(serializeExercise))
},
)
})
adminTutorialRoutes.post("/exercises", requireSuperAdmin, async (c) => {
const parsed = createExerciseRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = createExerciseRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const [tutorial] = await db
.select({ id: schema.tutorial.id })
.from(schema.tutorial)
.where(eq(schema.tutorial.id, parsed.data.tutorialId))
.limit(1)
if (!tutorial)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
.where(eq(schema.tutorial.id, parsed.data.tutorialId)).limit(1)
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
const [created] = await db
.insert(schema.exercise)
.values({
const [created] = await db.insert(schema.exercise).values({
tutorialId: parsed.data.tutorialId,
type: parsed.data.type,
data: parsed.data.data,
order: parsed.data.order,
createdAt: new Date().toISOString(),
})
.returning()
}).returning()
return success(c, serializeExercise(created!), 201)
})
adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => {
const parsed = updateExerciseRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
const parsed = updateExerciseRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) {
return failure(
c,
400,
"invalid-request",
parsed.error.issues[0]?.message ?? "Invalid payload",
)
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
}
const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
const [updated] = await db
.update(schema.exercise)
.set({
type: parsed.data.type,
data: parsed.data.data,
order: parsed.data.order,
})
.where(
eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
const [updated] = await db.update(schema.exercise)
.set({ type: parsed.data.type, data: parsed.data.data, order: parsed.data.order })
.where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.returning()
if (!updated)
return failure(c, 404, "exercise-not-found", "Exercise does not exist")
if (!updated) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
return success(c, serializeExercise(updated))
})
adminTutorialRoutes.delete("/exercises/:id", requireSuperAdmin, async (c) => {
const deleted = await db
.delete(schema.exercise)
.where(
eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
)
const deleted = await db.delete(schema.exercise)
.where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
.returning({ id: schema.exercise.id })
if (deleted.length === 0)
return failure(c, 404, "exercise-not-found", "Exercise does not exist")
if (deleted.length === 0) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
return success(c, null)
})
+206 -786
View File
File diff suppressed because it is too large Load Diff
+39 -124
View File
@@ -38,36 +38,25 @@ async function loadClassUsers(classNames?: string[], gradePrefix?: string) {
]
if (classNames) filters.push(inArray(schema.user.className, classNames))
if (gradePrefix) filters.push(like(schema.user.className, `${gradePrefix}%`))
const rows = await db
.select({
const rows = await db.select({
userId: schema.user.id,
username: schema.user.username,
className: schema.user.className,
acceptedNumber: schema.userProfile.acceptedNumber,
submissionNumber: schema.userProfile.submissionNumber,
})
.from(schema.user)
.innerJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(and(...filters))
}).from(schema.user).innerJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(and(...filters))
return rows.filter((row): row is ClassUser => row.className !== null)
}
function mean(values: number[]) {
return values.length
? values.reduce((sum, value) => sum + value, 0) / values.length
: 0
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0
}
function median(values: number[]) {
if (!values.length) return 0
const sorted = [...values].sort((a, b) => a - b)
const middle = Math.floor(sorted.length / 2)
return sorted.length % 2
? sorted[middle]!
: (sorted[middle - 1]! + sorted[middle]!) / 2
return sorted.length % 2 ? sorted[middle]! : (sorted[middle - 1]! + sorted[middle]!) / 2
}
function quantile(values: number[], p: number) {
@@ -84,59 +73,35 @@ function quantile(values: number[], p: number) {
function sampleStdDev(values: number[]) {
if (values.length <= 1) return 0
const average = mean(values)
return Math.sqrt(
values.reduce((sum, value) => sum + (value - average) ** 2, 0) /
(values.length - 1),
)
return Math.sqrt(values.reduce((sum, value) => sum + (value - average) ** 2, 0) / (values.length - 1))
}
classroomRoutes.get("/rankings/classes", async (c) => {
const grade = c.req.query("grade")?.trim()
if (!grade || !/^\d+$/.test(grade))
return failure(c, 400, "invalid-grade", "grade is required")
if (!grade || !/^\d+$/.test(grade)) return failure(c, 400, "invalid-grade", "grade is required")
const users = await loadClassUsers(undefined, grade)
const groups = new Map<string, ClassUser[]>()
for (const user of users)
groups.set(user.className, [...(groups.get(user.className) ?? []), user])
const result = [...groups]
.map(([className, members]) => {
const totalAc = members.reduce(
(sum, member) => sum + member.acceptedNumber,
0,
)
const totalSubmission = members.reduce(
(sum, member) => sum + member.submissionNumber,
0,
)
for (const user of users) groups.set(user.className, [...(groups.get(user.className) ?? []), user])
const result = [...groups].map(([className, members]) => {
const totalAc = members.reduce((sum, member) => sum + member.acceptedNumber, 0)
const totalSubmission = members.reduce((sum, member) => sum + member.submissionNumber, 0)
return {
className,
userCount: members.length,
totalAc,
totalSubmission,
avgAc: rounded(totalAc / members.length),
acRate:
totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0,
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0,
}
})
.sort(
(a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission,
)
return success(
c,
result.map(
(item, index) => ({ ...item, rank: index + 1 }) satisfies ClassRankItem,
),
)
}).sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission)
return success(c, result.map((item, index) => ({ ...item, rank: index + 1 } satisfies ClassRankItem)))
})
classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
const user = c.get("user")!
if (!user.className)
return failure(c, 400, "class-missing", "用户没有班级信息")
if (!user.className) return failure(c, 400, "class-missing", "用户没有班级信息")
const members = (await loadClassUsers([user.className])).sort(
(a, b) =>
b.acceptedNumber - a.acceptedNumber ||
a.submissionNumber - b.submissionNumber,
(a, b) => b.acceptedNumber - a.acceptedNumber || a.submissionNumber - b.submissionNumber,
)
const ranks = members.map((member, index) => ({
userId: member.userId,
@@ -156,64 +121,35 @@ classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10)
selected = ranks.slice(start, start + 10)
}
return success(c, {
className: user.className,
myRank,
total: ranks.length,
ranks: selected,
} satisfies ClassUserRank)
return success(c, { className: user.className, myRank, total: ranks.length, ranks: selected } satisfies ClassUserRank)
})
classroomRoutes.post("/classes/comparison", async (c) => {
const parsed = classComparisonRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "At least one class is required")
const parsed = classComparisonRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "At least one class is required")
const users = await loadClassUsers(parsed.data.classNames)
const allAc = users.map((user) => user.acceptedNumber)
const globalQ1 = quantile(allAc, 0.25)
const globalQ3 = quantile(allAc, 0.75)
const byClass = new Map<string, ClassUser[]>()
for (const user of users)
byClass.set(user.className, [...(byClass.get(user.className) ?? []), user])
for (const user of users) byClass.set(user.className, [...(byClass.get(user.className) ?? []), user])
let recentByUser = new Map<number, Set<number>>()
let recentSubmissionCount = new Map<string, number>()
const hasTimeRange = Boolean(parsed.data.startTime && parsed.data.endTime)
if (hasTimeRange) {
const rows = await db
.select({
userId: schema.submission.userId,
problemId: schema.submission.problemId,
result: schema.submission.result,
})
.from(schema.submission)
.where(
and(
inArray(
schema.submission.userId,
users.map((user) => user.userId),
),
const rows = await db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, result: schema.submission.result })
.from(schema.submission).where(and(
inArray(schema.submission.userId, users.map((user) => user.userId)),
gte(schema.submission.createTime, parsed.data.startTime!),
lte(schema.submission.createTime, parsed.data.endTime!),
),
)
const userClass = new Map(
users.map((user) => [user.userId, user.className]),
)
))
const userClass = new Map(users.map((user) => [user.userId, user.className]))
for (const row of rows) {
const className = userClass.get(row.userId)
if (!className) continue
recentSubmissionCount.set(
className,
(recentSubmissionCount.get(className) ?? 0) + 1,
)
if (
[JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(
row.result as 0 | 10,
)
) {
recentSubmissionCount.set(className, (recentSubmissionCount.get(className) ?? 0) + 1)
if ([JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(row.result as 0 | 10)) {
const set = recentByUser.get(row.userId) ?? new Set<number>()
set.add(row.problemId)
recentByUser.set(row.userId, set)
@@ -222,17 +158,12 @@ classroomRoutes.post("/classes/comparison", async (c) => {
}
const comparisons = [...byClass].map(([className, members]) => {
const ac = members
.map((member) => member.acceptedNumber)
.sort((a, b) => b - a)
const submissions = members
.map((member) => member.submissionNumber)
.sort((a, b) => b - a)
const ac = members.map((member) => member.acceptedNumber).sort((a, b) => b - a)
const submissions = members.map((member) => member.submissionNumber).sort((a, b) => b - a)
const userCount = members.length
const topCount = Math.max(1, Math.ceil(userCount * 0.1))
const bottomCount = topCount
const middle =
topCount + bottomCount < userCount ? ac.slice(topCount, -bottomCount) : ac
const middle = topCount + bottomCount < userCount ? ac.slice(topCount, -bottomCount) : ac
const totalAc = ac.reduce((sum, value) => sum + value, 0)
const totalSubmission = submissions.reduce((sum, value) => sum + value, 0)
const base: ClassComparison = {
@@ -249,30 +180,19 @@ classroomRoutes.post("/classes/comparison", async (c) => {
top10Avg: rounded(mean(ac.slice(0, topCount))),
middle80Avg: rounded(mean(middle)),
bottom10Avg: rounded(mean(ac.slice(-bottomCount))),
excellentRate: rounded(
(ac.filter((value) => value >= globalQ3).length / userCount) * 100,
),
passRate: rounded(
(ac.filter((value) => value >= globalQ1).length / userCount) * 100,
),
activeRate: rounded(
(submissions.filter((value) => value > 0).length / userCount) * 100,
),
acRate:
totalSubmission > 0 ? rounded((totalAc / totalSubmission) * 100) : 0,
excellentRate: rounded(ac.filter((value) => value >= globalQ3).length / userCount * 100),
passRate: rounded(ac.filter((value) => value >= globalQ1).length / userCount * 100),
activeRate: rounded(submissions.filter((value) => value > 0).length / userCount * 100),
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0,
compositeScore: 0,
}
if (hasTimeRange) {
const recent = members
.map((member) => recentByUser.get(member.userId)?.size ?? 0)
.sort((a, b) => b - a)
const recent = members.map((member) => recentByUser.get(member.userId)?.size ?? 0).sort((a, b) => b - a)
base.recentTotalAc = recent.reduce((sum, value) => sum + value, 0)
base.recentTotalSubmission = recentSubmissionCount.get(className) ?? 0
base.recentAvgAc = rounded(mean(recent))
base.recentMedianAc = rounded(median(recent))
base.recentTop10Avg = rounded(
mean(recent.slice(0, Math.max(1, Math.ceil(recent.length * 0.1)))),
)
base.recentTop10Avg = rounded(mean(recent.slice(0, Math.max(1, Math.ceil(recent.length * 0.1)))))
base.recentActiveCount = recent.filter((value) => value > 0).length
}
return base
@@ -281,19 +201,14 @@ classroomRoutes.post("/classes/comparison", async (c) => {
const maxMiddle = Math.max(1, ...comparisons.map((item) => item.middle80Avg))
for (const item of comparisons) {
item.compositeScore = rounded(
0.4 * ((item.medianAc / maxMedian) * 100) +
0.15 * ((item.middle80Avg / maxMiddle) * 100) +
0.4 * (item.medianAc / maxMedian * 100) +
0.15 * (item.middle80Avg / maxMiddle * 100) +
0.2 * item.activeRate +
0.15 * item.passRate +
0.1 * item.excellentRate,
1,
)
}
comparisons.sort(
(a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc,
)
return success(c, {
comparisons,
hasTimeRange,
} satisfies ClassComparisonResponse)
comparisons.sort((a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc)
return success(c, { comparisons, hasTimeRange } satisfies ClassComparisonResponse)
})
+83 -302
View File
@@ -33,37 +33,15 @@ contentRoutes.get("/announcements", async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([
db
.select({ value: count() })
.from(schema.announcement)
.where(eq(schema.announcement.visible, true)),
db
.select({
announcement: schema.announcement,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.announcement)
.innerJoin(
schema.user,
eq(schema.announcement.createdById, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
db.select({ value: count() }).from(schema.announcement).where(eq(schema.announcement.visible, true)),
db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.announcement.visible, true))
.orderBy(
desc(schema.announcement.top),
desc(schema.announcement.createTime),
)
.limit(limit)
.offset(offset),
.orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset),
])
return success(c, {
results: rows.map(
({ announcement, user, realName }) =>
({
results: rows.map(({ announcement, user, realName }) => ({
id: announcement.id,
title: announcement.title,
tag: announcement.tag,
@@ -71,37 +49,18 @@ contentRoutes.get("/announcements", async (c) => {
createdBy: sampleUser(user, realName),
createTime: announcement.createTime,
lastUpdateTime: announcement.lastUpdateTime,
}) satisfies AnnouncementListItem,
),
} satisfies AnnouncementListItem)),
total: totalRows[0]?.value ?? 0,
} satisfies AnnouncementList)
})
contentRoutes.get("/announcements/:id", async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [row] = await db
.select({
announcement: schema.announcement,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.announcement)
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
const [row] = await db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
eq(schema.announcement.id, id),
eq(schema.announcement.visible, true),
),
)
.limit(1)
if (!row)
return failure(
c,
404,
"announcement-not-found",
"Announcement does not exist",
)
.where(and(eq(schema.announcement.id, id), eq(schema.announcement.visible, true))).limit(1)
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
return success(c, {
id: row.announcement.id,
title: row.announcement.title,
@@ -119,41 +78,16 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const [totalRows, rows] = await Promise.all([
db
.select({ value: count() })
.from(schema.message)
.where(eq(schema.message.recipientId, user.id)),
db
.select({
message: schema.message,
sender: schema.user,
realName: schema.userProfile.realName,
submission: schema.submission,
displayId: schema.problem.displayId,
})
.from(schema.message)
.innerJoin(schema.user, eq(schema.message.senderId, schema.user.id))
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.innerJoin(
schema.submission,
eq(schema.message.submissionId, schema.submission.id),
)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.where(eq(schema.message.recipientId, user.id))
.orderBy(desc(schema.message.createTime))
.limit(limit)
.offset(offset),
db.select({ value: count() }).from(schema.message).where(eq(schema.message.recipientId, user.id)),
db.select({ message: schema.message, sender: schema.user, realName: schema.userProfile.realName, submission: schema.submission, displayId: schema.problem.displayId })
.from(schema.message).innerJoin(schema.user, eq(schema.message.senderId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.innerJoin(schema.submission, eq(schema.message.submissionId, schema.submission.id))
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset),
])
return success(c, {
results: rows.map(
({ message, sender, realName, submission, displayId }) =>
({
results: rows.map(({ message, sender, realName, submission, displayId }) => ({
id: message.id,
sender: sampleUser(sender, realName),
createTime: message.createTime,
@@ -173,8 +107,7 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
problem: displayId,
showLink: true,
} satisfies EmbeddedSubmission,
}) satisfies Message,
),
} satisfies Message)),
total: totalRows[0]?.value ?? 0,
} satisfies MessageList)
})
@@ -187,39 +120,15 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
*/
contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
const user = c.get("user")!
const parsed = createMessageRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid message payload")
if (parsed.data.recipientId === user.id)
return failure(
c,
400,
"invalid-recipient",
"Can not send a message to yourself",
)
const parsed = createMessageRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid message payload")
if (parsed.data.recipientId === user.id) return failure(c, 400, "invalid-recipient", "Can not send a message to yourself")
const [[recipient], [submission]] = await Promise.all([
db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(
eq(schema.user.id, parsed.data.recipientId),
eq(schema.user.isDisabled, false),
),
)
.limit(1),
db
.select({ id: schema.submission.id })
.from(schema.submission)
.where(eq(schema.submission.id, parsed.data.submissionId))
.limit(1),
db.select({ id: schema.user.id }).from(schema.user).where(and(eq(schema.user.id, parsed.data.recipientId), eq(schema.user.isDisabled, false))).limit(1),
db.select({ id: schema.submission.id }).from(schema.submission).where(eq(schema.submission.id, parsed.data.submissionId)).limit(1),
])
if (!recipient)
return failure(c, 404, "user-not-found", "User does not exist")
if (!submission)
return failure(c, 404, "submission-not-found", "Submission does not exist")
if (!recipient) return failure(c, 404, "user-not-found", "User does not exist")
if (!submission) return failure(c, 404, "submission-not-found", "Submission does not exist")
await db.insert(schema.message).values({
message: parsed.data.message,
createTime: new Date().toISOString(),
@@ -231,22 +140,11 @@ contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
})
async function reactionState(problemId: number, userId: number) {
const [mine] = await db
.select({ type: schema.reaction.type })
.from(schema.reaction)
.where(
and(
eq(schema.reaction.problemId, problemId),
eq(schema.reaction.userId, userId),
),
)
.limit(1)
const [mine] = await db.select({ type: schema.reaction.type }).from(schema.reaction)
.where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1)
if (!mine) return { mine: null, counts: null } satisfies ReactionState
const rows = await db
.select({ type: schema.reaction.type, value: count() })
.from(schema.reaction)
.where(eq(schema.reaction.problemId, problemId))
.groupBy(schema.reaction.type)
const rows = await db.select({ type: schema.reaction.type, value: count() }).from(schema.reaction)
.where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type)
// fromEntries 推不出这个键集,但 options 就是 ReactionKey 的全集,断言是成立的。
// row.type 不必再 safeParsereaction.type 列上挂着 $type<ReactionKey>()
const counts = Object.fromEntries(
@@ -263,85 +161,41 @@ contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
contentRoutes.post("/problems/:id/reaction", requireAuth, async (c) => {
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = setReactionRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid reaction")
const parsed = setReactionRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid reaction")
const user = c.get("user")!
const [[problem], [solved]] = await Promise.all([
db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(eq(schema.problem.id, problemId), eq(schema.problem.visible, true)),
)
.limit(1),
db
.select({ id: schema.submission.id })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.problemId, problemId),
inArray(schema.submission.result, [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]),
),
)
.limit(1),
db.select({ id: schema.problem.id }).from(schema.problem).where(and(eq(schema.problem.id, problemId), eq(schema.problem.visible, true))).limit(1),
db.select({ id: schema.submission.id }).from(schema.submission).where(and(
eq(schema.submission.userId, user.id), eq(schema.submission.problemId, problemId),
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
)).limit(1),
])
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!solved)
return failure(
c,
403,
"accepted-submission-required",
"An accepted submission is required",
)
await db
.insert(schema.reaction)
.values({
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!solved) return failure(c, 403, "accepted-submission-required", "An accepted submission is required")
await db.insert(schema.reaction).values({
problemId,
userId: user.id,
type: parsed.data.type,
createTime: new Date().toISOString(),
})
.onConflictDoNothing({
target: [schema.reaction.problemId, schema.reaction.userId],
})
}).onConflictDoNothing({ target: [schema.reaction.problemId, schema.reaction.userId] })
return success(c, await reactionState(problemId, user.id))
})
contentRoutes.get("/tutorials", async (c) => {
const type = c.req.query("type") === "c" ? "c" : "python"
const rows = await db
.select({ id: schema.tutorial.id, title: schema.tutorial.title })
.from(schema.tutorial)
.where(
and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type)),
)
.orderBy(asc(schema.tutorial.order))
const rows = await db.select({ id: schema.tutorial.id, title: schema.tutorial.title }).from(schema.tutorial)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order))
return success(c, rows satisfies TutorialSummary[])
})
contentRoutes.get("/tutorials/:id", async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [row] = await db
.select({
tutorial: schema.tutorial,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.tutorial)
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
const [row] = await db.select({ tutorial: schema.tutorial, user: schema.user, realName: schema.userProfile.realName })
.from(schema.tutorial).innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
.limit(1)
if (!row)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, {
id: row.tutorial.id,
title: row.tutorial.title,
@@ -368,63 +222,40 @@ contentRoutes.get("/tutorials/:id", async (c) => {
contentRoutes.get("/learn/progress", requireAuth, async (c) => {
const user = c.get("user")!
const type = c.req.query("type") === "c" ? "c" : "python"
const visible = and(
eq(schema.tutorial.type, type),
eq(schema.tutorial.isPublic, true),
)
const visible = and(eq(schema.tutorial.type, type), eq(schema.tutorial.isPublic, true))
// 从 tutorial 打底 left join 进度,而不是反过来:没读过的课也要有一行零,
// 否则目录里「练习 0/5」和「这课没有练习」在前端分不出来
const [rows, exerciseRows] = await Promise.all([
db
.select({
db.select({
tutorialId: schema.tutorial.id,
viewCount: schema.tutorialProgress.viewCount,
totalSeconds: schema.tutorialProgress.totalSeconds,
firstViewedAt: schema.tutorialProgress.firstViewedAt,
lastViewedAt: schema.tutorialProgress.lastViewedAt,
})
.from(schema.tutorial)
.leftJoin(
schema.tutorialProgress,
and(
}).from(schema.tutorial)
.leftJoin(schema.tutorialProgress, and(
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
eq(schema.tutorialProgress.userId, user.id),
),
)
))
.where(visible)
.orderBy(asc(schema.tutorial.order)),
db
.select({
db.select({
tutorialId: schema.exercise.tutorialId,
total: count(),
solved:
sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(
Number,
),
})
.from(schema.exercise)
.innerJoin(
schema.tutorial,
eq(schema.tutorial.id, schema.exercise.tutorialId),
)
.leftJoin(
schema.exerciseAttempt,
and(
solved: sql<number>`count(*) filter (where ${schema.exerciseAttempt.solved})`.mapWith(Number),
}).from(schema.exercise)
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.leftJoin(schema.exerciseAttempt, and(
eq(schema.exerciseAttempt.exerciseId, schema.exercise.id),
eq(schema.exerciseAttempt.userId, user.id),
),
)
))
.where(visible)
.groupBy(schema.exercise.tutorialId),
])
const exercises = new Map(exerciseRows.map((row) => [row.tutorialId, row]))
return success(
c,
rows.map(
(row) =>
({
return success(c, rows.map((row) => ({
tutorialId: row.tutorialId,
viewCount: row.viewCount ?? 0,
totalSeconds: row.totalSeconds ?? 0,
@@ -432,9 +263,7 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
lastViewedAt: row.lastViewedAt,
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
}) satisfies TutorialProgress,
),
)
} satisfies TutorialProgress)))
})
/**
@@ -447,36 +276,23 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
const user = c.get("user")!
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = tutorialProgressPingSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid progress payload")
const [tutorial] = await db
.select({ id: schema.tutorial.id })
.from(schema.tutorial)
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
.limit(1)
if (!tutorial)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const parsed = tutorialProgressPingSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid progress payload")
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const now = new Date().toISOString()
const { seconds, opened } = parsed.data
await db
.insert(schema.tutorialProgress)
.values({
await db.insert(schema.tutorialProgress).values({
userId: user.id,
tutorialId: id,
viewCount: opened ? 1 : 0,
totalSeconds: seconds,
firstViewedAt: now,
lastViewedAt: now,
})
.onConflictDoUpdate({
target: [
schema.tutorialProgress.userId,
schema.tutorialProgress.tutorialId,
],
}).onConflictDoUpdate({
target: [schema.tutorialProgress.userId, schema.tutorialProgress.tutorialId],
set: {
// 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页
// 同时上报时,读改写会互相覆盖,时长凭空少掉一半
@@ -501,30 +317,18 @@ contentRoutes.post("/tutorials/:id/progress", requireAuth, async (c) => {
contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
const user = c.get("user")!
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const parsed = exerciseAttemptRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid attempt payload")
const parsed = exerciseAttemptRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid attempt payload")
// 练习跟着教程走:教程没公开,它底下的练习也不该能上报
const [exercise] = await db
.select({ id: schema.exercise.id })
.from(schema.exercise)
.innerJoin(
schema.tutorial,
eq(schema.tutorial.id, schema.exercise.tutorialId),
)
.where(and(eq(schema.exercise.id, id), eq(schema.tutorial.isPublic, true)))
.limit(1)
if (!exercise)
return failure(c, 404, "exercise-not-found", "Exercise does not exist")
const [exercise] = await db.select({ id: schema.exercise.id }).from(schema.exercise)
.innerJoin(schema.tutorial, eq(schema.tutorial.id, schema.exercise.tutorialId))
.where(and(eq(schema.exercise.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
if (!exercise) return failure(c, 404, "exercise-not-found", "Exercise does not exist")
const now = new Date().toISOString()
const { correct } = parsed.data
const answer = correct ? null : (parsed.data.answer ?? null)
await db
.insert(schema.exerciseAttempt)
.values({
await db.insert(schema.exerciseAttempt).values({
userId: user.id,
exerciseId: id,
attempts: 1,
@@ -535,12 +339,8 @@ contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
firstAttemptAt: now,
lastAttemptAt: now,
solvedAt: correct ? now : null,
})
.onConflictDoUpdate({
target: [
schema.exerciseAttempt.userId,
schema.exerciseAttempt.exerciseId,
],
}).onConflictDoUpdate({
target: [schema.exerciseAttempt.userId, schema.exerciseAttempt.exerciseId],
set: {
// 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。
//
@@ -568,28 +368,9 @@ contentRoutes.post("/exercises/:id/attempts", requireAuth, async (c) => {
contentRoutes.get("/tutorials/:id/exercises", async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [tutorial] = await db
.select({ id: schema.tutorial.id })
.from(schema.tutorial)
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
.limit(1)
if (!tutorial)
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const rows = await db
.select()
.from(schema.exercise)
.where(eq(schema.exercise.tutorialId, id))
.orderBy(asc(schema.exercise.order))
return success(
c,
rows.map(
(row) =>
({
id: row.id,
type: row.type,
data: objectValue(row.data),
order: row.order,
}) satisfies Exercise,
),
)
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const rows = await db.select().from(schema.exercise).where(eq(schema.exercise.tutorialId, id)).orderBy(asc(schema.exercise.order))
return success(c, rows.map((row) => ({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order } satisfies Exercise)))
})
+52 -213
View File
@@ -9,18 +9,7 @@ import {
type ProblemDetail,
type ProblemListItem,
} from "@oj2/contract"
import {
and,
asc,
count,
desc,
eq,
gte,
ilike,
inArray,
lte,
sql,
} from "drizzle-orm"
import { and, asc, count, desc, eq, gte, ilike, inArray, lte, sql } from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireAuth } from "../auth/middleware"
@@ -38,12 +27,7 @@ import {
requireContestAccess,
type ContestEnv,
} from "../services/contest"
import {
objectValue,
publicTemplates,
queryInteger,
sampleUser,
} from "./helpers"
import { objectValue, publicTemplates, queryInteger, sampleUser } from "./helpers"
export const contestRoutes = new Hono<ContestEnv>()
@@ -51,14 +35,8 @@ export const contestRoutes = new Hono<ContestEnv>()
async function creators(ids: number[]) {
const map = new Map<number, ReturnType<typeof sampleUser>>()
if (ids.length === 0) return map
const rows = await db
.select({
id: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
const rows = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName })
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(inArray(schema.user.id, ids))
for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
return map
@@ -97,33 +75,18 @@ contestRoutes.get("/contests", async (c) => {
if (tag) filters.push(eq(schema.contest.tag, tag))
if (status === "1") filters.push(gte(schema.contest.startTime, now))
else if (status === "-1") filters.push(lte(schema.contest.endTime, now))
else if (status === "0")
filters.push(
and(
lte(schema.contest.startTime, now),
gte(schema.contest.endTime, now),
)!,
)
else if (status === "0") filters.push(and(lte(schema.contest.startTime, now), gte(schema.contest.endTime, now))!)
const where = and(...filters)
const [totalRow, rows] = await Promise.all([
db.select({ value: count() }).from(schema.contest).where(where),
db
.select()
.from(schema.contest)
.where(where)
.orderBy(desc(schema.contest.startTime))
.limit(limit)
.offset(offset),
db.select().from(schema.contest).where(where).orderBy(desc(schema.contest.startTime)).limit(limit).offset(offset),
])
const byId = await creators([...new Set(rows.map((row) => row.createdById))])
return success(c, {
results: rows.map((row) =>
serializeContest(
results: rows.map((row) => serializeContest(
row,
byId.get(row.createdById) ??
sampleUser({ id: row.createdById, username: "" }, null),
),
),
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
)),
total: totalRow[0]?.value ?? 0,
} satisfies ContestList)
})
@@ -131,55 +94,31 @@ contestRoutes.get("/contests", async (c) => {
// optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」——
// 隐藏的比赛只有他看得到详情,匿名访问照旧当作不存在
contestRoutes.get("/contests/:id", optionalAuth, async (c) => {
const contest = await findAccessibleContest(
c.get("user"),
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!contest)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const byId = await creators([contest.createdById])
return success(
c,
serializeContest(
return success(c, serializeContest(
contest,
byId.get(contest.createdById) ??
sampleUser({ id: contest.createdById, username: "" }, null),
byId.get(contest.createdById) ?? sampleUser({ id: contest.createdById, username: "" }, null),
true,
),
)
))
})
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
const contest = await findAccessibleContest(
c.get("user"),
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!contest || !contest.password)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const parsed = contestPasswordRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Password is required")
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
const parsed = contestPasswordRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Password is required")
if (!checkContestPassword(parsed.data.password, contest.password)) {
return failure(
c,
403,
"wrong-password",
"Wrong password or password expired",
)
return failure(c, 403, "wrong-password", "Wrong password or password expired")
}
await setContestPassword(c, contest.id, parsed.data.password)
return success(c, true)
})
contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
const contest = await findAccessibleContest(
c.get("user"),
queryInteger(c.req.param("id"), 0, { min: 1 }),
)
if (!contest || !contest.password)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "details")
return success(c, { access: access.ok } satisfies ContestAccess)
})
@@ -197,11 +136,8 @@ contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
*/
async function contestProblemStatuses(userId: number | undefined) {
if (!userId) return {}
const [profile] = await db
.select({ status: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, userId))
.limit(1)
const [profile] = await db.select({ status: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile).where(eq(schema.userProfile.userId, userId)).limit(1)
return objectValue(objectValue(profile?.status).contest_problems)
}
@@ -212,56 +148,24 @@ function myStatusOf(statuses: Record<string, unknown>, problemId: number) {
async function contestProblemTags(problemIds: number[]) {
if (problemIds.length === 0) return new Map<number, string[]>()
const rows = await db
.select({
problemId: schema.problemTags.problemId,
name: schema.problemTag.name,
})
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
.from(schema.problemTags).innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(inArray(schema.problemTags.problemId, problemIds))
const map = new Map<number, string[]>()
for (const row of rows)
map.set(row.problemId, [...(map.get(row.problemId) ?? []), row.name])
for (const row of rows) map.set(row.problemId, [...(map.get(row.problemId) ?? []), row.name])
return map
}
contestRoutes.get(
"/contests/:id/problems",
optionalAuth,
requireContestAccess("problems"),
async (c) => {
contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("problems"), async (c) => {
const contest = c.get("contest")!
const rows = await db
.select({
problem: schema.problem,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(
and(
eq(schema.problem.contestId, contest.id),
eq(schema.problem.visible, true),
),
)
.orderBy(asc(schema.problem.displayId))
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true))).orderBy(asc(schema.problem.displayId))
const tags = await contestProblemTags(rows.map((row) => row.problem.id))
const allowed = contestDetailsAllowed(c.get("user"), contest)
const statuses = await contestProblemStatuses(c.get("user")?.id)
return success(
c,
rows.map(
({ problem, user, realName }) =>
({
return success(c, rows.map(({ problem, user, realName }) => ({
id: problem.id,
_id: problem.displayId,
title: problem.title,
@@ -275,40 +179,16 @@ contestRoutes.get(
showFlowchart: problem.showFlowchart,
hasAstRules: problem.astRules !== null,
myStatus: myStatusOf(statuses, problem.id),
}) satisfies ProblemListItem,
),
)
},
)
} satisfies ProblemListItem)))
})
contestRoutes.get(
"/contests/:id/problems/:displayId",
optionalAuth,
requireContestAccess("problems"),
async (c) => {
contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => {
const contest = c.get("contest")!
const [row] = await db
.select({
problem: schema.problem,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(
and(
eq(schema.problem.contestId, contest.id),
eq(schema.problem.visible, true),
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
),
)
.limit(1)
if (!row)
return failure(c, 404, "problem-not-found", "Problem does not exist")
const [row] = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true), sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`)).limit(1)
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
const tags = await contestProblemTags([row.problem.id])
const allowed = contestDetailsAllowed(c.get("user"), contest)
const statuses = await contestProblemStatuses(c.get("user")?.id)
@@ -342,72 +222,33 @@ contestRoutes.get(
allowFlowchart: row.problem.allowFlowchart,
showFlowchart: row.problem.showFlowchart,
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
flowchartData: row.problem.allowFlowchart
? null
: objectValue(row.problem.flowchartData),
flowchartData: row.problem.allowFlowchart ? null : objectValue(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig,
sqlDisplay: row.problem.sqlDisplay,
// 代码要求:只给渲染好的文案,规则原文不下发给学生
astRequirements: astRequirements(row.problem.astRules),
} satisfies ProblemDetail)
},
)
})
contestRoutes.get(
"/contests/:id/rank",
optionalAuth,
requireContestAccess("ranks"),
async (c) => {
contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => {
const contest = c.get("contest")!
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const where = and(
eq(schema.acmContestRank.contestId, contest.id),
inArray(schema.user.adminType, [...STUDENT_ROLES]),
eq(schema.user.isDisabled, false),
)
const where = and(eq(schema.acmContestRank.contestId, contest.id), inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false))
const [totalRows, rows] = await Promise.all([
db
.select({ value: count() })
.from(schema.acmContestRank)
.innerJoin(
schema.user,
eq(schema.acmContestRank.userId, schema.user.id),
)
.where(where),
db
.select({
rank: schema.acmContestRank,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.acmContestRank)
.innerJoin(
schema.user,
eq(schema.acmContestRank.userId, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where)
db.select({ value: count() }).from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id)).where(where),
db.select({ rank: schema.acmContestRank, user: schema.user, realName: schema.userProfile.realName })
.from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
// 末尾的 id 是给排序兜全序用的:同 AC 数同罚时前两列分不出先后,而这条列表是
// limit/offset 翻页的,行序不稳定就意味着同一个人在第 2 页出现两次、另一个人
// 从此消失。id 本身不参与名次,只保证同分的人每次都按同一个顺序排
.orderBy(
desc(schema.acmContestRank.acceptedNumber),
asc(schema.acmContestRank.totalTime),
asc(schema.acmContestRank.id),
)
.limit(limit)
.offset(offset),
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime), asc(schema.acmContestRank.id)).limit(limit).offset(offset),
])
const admin = isContestAdmin(c.get("user"), contest)
return success(c, {
results: rows.map(
({ rank, user, realName }) =>
({
results: rows.map(({ rank, user, realName }) => ({
id: rank.id,
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
@@ -417,9 +258,7 @@ contestRoutes.get(
totalTime: rank.totalTime,
submissionInfo: rank.submissionInfo,
contestId: rank.contestId,
}) satisfies ContestRankItem,
),
} satisfies ContestRankItem)),
total: totalRows[0]?.value ?? 0,
} satisfies ContestRank)
},
)
})
+126 -478
View File
@@ -10,17 +10,7 @@ import {
type FlowchartStatistics,
type FlowchartSubmission,
} from "@oj2/contract"
import {
and,
asc,
count,
desc,
eq,
inArray,
isNull,
sql,
type SQL,
} from "drizzle-orm"
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
@@ -31,14 +21,13 @@ import { flowchartQueue } from "../queue"
import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling"
import { buildWordFrequencies } from "../services/word-frequency"
import { todayStart } from "../time"
import {
isAdminRole,
matchedUsers,
objectValue,
queryInteger,
rounded,
stripClassPrefix,
todayStart,
} from "./helpers"
export const flowchartRoutes = new Hono<AppEnv>()
@@ -48,16 +37,8 @@ function flowchartThrottleKey(userId: number) {
return `flowchart:${userId}`
}
function canView(
user: import("../auth/session").AuthUser,
row: { userId: number },
problem: { createdById: number },
) {
return (
row.userId === user.id ||
isAdminRole(user) ||
problem.createdById === user.id
)
function canView(user: import("../auth/session").AuthUser, row: { userId: number }, problem: { createdById: number }) {
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id
}
function flowchartData(
@@ -85,48 +66,20 @@ function flowchartData(
}
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
const parsed = createFlowchartRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (
!parsed.success ||
JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024
) {
return failure(
c,
400,
"invalid-request",
parsed.error?.issues[0]?.message ?? "Flowchart data is too large",
)
const parsed = createFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success || JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024) {
return failure(c, 400, "invalid-request", parsed.error?.issues[0]?.message ?? "Flowchart data is too large")
}
const [problem] = await db
.select({ id: schema.problem.id, allow: schema.problem.allowFlowchart })
.from(schema.problem)
.where(eq(schema.problem.id, parsed.data.problemId))
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!problem.allow)
return failure(
c,
400,
"flowchart-not-allowed",
"This problem does not allow flowchart submission",
)
const [problem] = await db.select({ id: schema.problem.id, allow: schema.problem.allowFlowchart }).from(schema.problem)
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!problem.allow) return failure(c, 400, "flowchart-not-allowed", "This problem does not allow flowchart submission")
// 限流:每次提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源。
// 身份前缀单独开一个桶,**不能**直接用 user id —— 那是代码提交在用的桶,
// 共用的话学生在机房连着交几次代码,流程图这边就会莫名其妙交不上去。
const throttle = await consumeToken(
"user",
flowchartThrottleKey(c.get("user")!.id),
)
const throttle = await consumeToken("user", flowchartThrottleKey(c.get("user")!.id))
if (!throttle.allowed) {
return failure(
c,
429,
"too-many-submissions",
`Please wait ${Math.floor(throttle.wait)} seconds`,
)
return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`)
}
const id = randomBytes(16).toString("hex")
await db.insert(schema.flowchartSubmission).values({
@@ -150,156 +103,41 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
try {
await flowchartQueue.add("evaluate", { submissionId: id }, { jobId: id })
} catch (error) {
await db
.update(schema.flowchartSubmission)
.set({ status: 3 })
.where(eq(schema.flowchartSubmission.id, id))
return failure(
c,
502,
"queue-unavailable",
"Evaluation queue is unavailable",
)
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, id))
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
}
return success(
c,
{ submissionId: id, status: "pending" } satisfies CreateFlowchartResponse,
201,
)
return success(c, { submissionId: id, status: "pending" } satisfies CreateFlowchartResponse, 201)
})
/**
* / `flowchart_submission` join
* `problem._id` / `user.username` submission.ts
* problemFilter / usernameFilter
*
* - count ** join ** index-only
* scan 3KB flowchart_data + 1.2KB mermaid_code
* submission
* - flowchart_user_time_idx / flowchart_problem_time_idx
* join
*
* / **** filter
*
*/
async function flowchartProblemFilter(displayId: string) {
const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
// 流程图题都是公开题(快照里那 12 道 contest_id 全为空),
// 比赛题的 _id 撞号是常态,不该被筛进来
isNull(schema.problem.contestId),
),
)
return problems.length
? inArray(
schema.flowchartSubmission.problemId,
problems.map((row) => row.id),
)
: sql`false`
}
async function flowchartUserFilter(username: string) {
const ids = (await matchedUsers(username)).map((row) => row.id)
return ids.length
? inArray(schema.flowchartSubmission.userId, ids)
: sql`false`
}
/**
* `select({ flowchart: 整行, problem: 整行 })`
* mermaid_codeflowchart_dataai_feedbackai_suggestionsai_criteria_details
* ****description / /
* 4.9KBp90 6.9KB 2.2KB 10
* ~70KBlimit=250 1.7MB
*
* submission.ts submissionListColumns
* code / info
*/
const flowchartListColumns = {
flowchart: {
id: schema.flowchartSubmission.id,
// showLink 判定要,序列化本身用不到
userId: schema.flowchartSubmission.userId,
status: schema.flowchartSubmission.status,
createTime: schema.flowchartSubmission.createTime,
aiScore: schema.flowchartSubmission.aiScore,
aiGrade: schema.flowchartSubmission.aiGrade,
aiProvider: schema.flowchartSubmission.aiProvider,
aiModel: schema.flowchartSubmission.aiModel,
processingTime: schema.flowchartSubmission.processingTime,
evaluationTime: schema.flowchartSubmission.evaluationTime,
},
username: schema.user.username,
problem: {
displayId: schema.problem.displayId,
title: schema.problem.title,
// 同上,canView 要
createdById: schema.problem.createdById,
},
}
flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
const user = c.get("user")!
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = []
const displayId = c.req.query("problemId")?.trim()
const username = c.req.query("username")?.trim()
const grade = c.req.query("grade")
// 与代码提交列表同一套口径(submission.ts 的 GET /submissions):关掉
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
if (
!(await getBooleanOption("submission_list_show_all", true)) &&
!isAdminRole(user)
) {
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
return success(c, { results: [], total: 0 } satisfies FlowchartList)
}
// 「只看自己」盖过用户名;普通学生不填用户名时也只看自己
const onlyMyself =
c.req.query("myself") === "1" ||
(!username && user.adminType === "Regular User")
const filters: Array<SQL | undefined> = []
filters.push(
...(await Promise.all([
displayId ? flowchartProblemFilter(displayId) : undefined,
!onlyMyself && username ? flowchartUserFilter(username) : undefined,
])),
)
if (onlyMyself) filters.push(eq(schema.flowchartSubmission.userId, user.id))
if (c.req.query("today") === "1")
filters.push(
sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`,
)
if (["S", "A", "B", "C"].includes(grade ?? ""))
filters.push(eq(schema.flowchartSubmission.aiGrade, grade!))
const where = and(...filters)
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) filters.push(eq(schema.flowchartSubmission.userId, user.id))
else if (username) filters.push(ilike(schema.user.username, `%${username}%`))
if (c.req.query("today") === "1") filters.push(sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`)
if (["S", "A", "B", "C"].includes(grade ?? "")) filters.push(eq(schema.flowchartSubmission.aiGrade, grade!))
const where = filters.length ? and(...filters) : undefined
const [totalRows, rows] = await Promise.all([
// 筛条件已经全落在 flowchart_submission 自己的列上,count 不挂任何 join
db.select({ value: count() }).from(schema.flowchartSubmission).where(where),
db
.select(flowchartListColumns)
.from(schema.flowchartSubmission)
.innerJoin(
schema.user,
eq(schema.flowchartSubmission.userId, schema.user.id),
)
.innerJoin(
schema.problem,
eq(schema.flowchartSubmission.problemId, schema.problem.id),
)
.where(where)
.orderBy(desc(schema.flowchartSubmission.createTime))
.limit(limit)
.offset(offset),
db.select({ value: count() }).from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id)).innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)).where(where),
db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username, problem: schema.problem })
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)).where(where)
.orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset),
])
return success(c, {
results: rows.map(
({ flowchart, username, problem }) =>
({
results: rows.map(({ flowchart, username, problem }) => ({
id: flowchart.id,
username,
problem: problem.displayId,
@@ -313,8 +151,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
processingTime: flowchart.processingTime,
evaluationTime: flowchart.evaluationTime,
showLink: canView(user, flowchart, problem),
}) satisfies FlowchartListItem,
),
} satisfies FlowchartListItem)),
total: totalRows[0]?.value ?? 0,
} satisfies FlowchartList)
})
@@ -322,22 +159,15 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
const FLOWCHART_COMPLETED = 2
/**
*
*
*
* ****
* SQL ****
*
* ****
* 线
* feedback / suggestions / comment jieba
* start cut
*
* 线 feedback / suggestions / comment
* jieba start FlowchartStatisticsPanel.vue
* `duration === "all"` cut
*
*
* ****SQL `order by create_time desc limit N` N
* JS pushText N 5.97 comment +
* feedback + suggestions 1 3000
* 500
* JS
*
*
*/
const WORDCLOUD_TEXT_LIMIT = 3000
@@ -346,142 +176,58 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
if (!end) return failure(c, 400, "invalid-request", "end is required")
const start = c.req.query("start")?.trim()
const filters: Array<SQL | undefined> = [
const filters = [
eq(schema.flowchartSubmission.status, FLOWCHART_COMPLETED),
sql`${schema.flowchartSubmission.createTime} <= ${end}`,
]
if (start)
filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`)
if (start) filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`)
const displayId = c.req.query("problemId")?.trim()
if (displayId) {
const [problem] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
.where(and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
))
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
filters.push(eq(schema.flowchartSubmission.problemId, problem.id))
}
const username = c.req.query("username")?.trim()
// 用户名先解析成账号,再拿 user_id 去筛 —— 理由同代码提交的统计接口
// submission.ts 的 GET /submissions/statistics),顺带让下面这几条一个 join 都不用挂
const matched = username ? await matchedUsers(username) : []
if (username) {
const ids = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件,否则「查无此班」变成「全站统计」
filters.push(
ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`,
)
}
const where = and(...filters)
// 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。
// 未禁用的普通用户才进分母,教师和管理员不算
if (username) filters.push(ilike(schema.user.username, `%${username}%`))
// 只有指定了用户名才谈得上「班级人数」,不指定时分母无意义
const roster = username
? matched.filter(
(row) => !row.isDisabled && row.adminType === "Regular User",
)
? await db
.select({ username: schema.user.username, className: schema.user.className })
.from(schema.user)
.where(and(
ilike(schema.user.username, `%${username}%`),
eq(schema.user.isDisabled, false),
eq(schema.user.adminType, "Regular User"),
))
: []
/**
* limit
*
* **** limit `select(username, score, grade, criteria, feedback,
* suggestions) order by create_time desc`,把整个时间窗的行拉进内存再用 JS 算 ——
* 3000 JS AI 366B
* criteria 255 + suggestions 64 + feedback 47 2134 5
* 18MB
*/
const [[totals], gradeRows, criteriaRows, textRows, submittedRows] =
await Promise.all([
db
.select({
total: count(),
/**
* sum / count `avg()`****
* Django Avg() NULL
* avg() NULL
*/
scoreSum:
sql<number>`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith(
Number,
),
scoreCount:
sql<number>`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(
Number,
),
// 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样,
// 按 user_id 就不必 join user
completedCount:
sql<number>`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(
Number,
),
})
.from(schema.flowchartSubmission)
.where(where),
db
.select({ grade: schema.flowchartSubmission.aiGrade, n: count() })
.from(schema.flowchartSubmission)
.where(where)
.groupBy(schema.flowchartSubmission.aiGrade),
/**
* ****`ai_criteria_details` `{ 项名: { score, max, comment } }`
* jsonb_each JS
* `typeof detail.score !== "number"` continue
*
* ** `jsonb_typeof(...) = 'object'` jsonb_each **
* jsonb_each 500
* `'5'::jsonb` `'[1,2]'::jsonb`
*
* where 53350
* 180ms 360ms Partial HashAggregate
* GroupAggregate + 21 26MB
*
* **** criteriaMax SQL
* create_time 21 4 × 5
* 254ms 842ms
*/
db.execute<{ key: string; avg: number }>(sql`
select e.key as key, avg((e.value->>'score')::double precision) as avg
from ${schema.flowchartSubmission}
cross join lateral jsonb_each(
case when jsonb_typeof(${schema.flowchartSubmission.aiCriteriaDetails}) = 'object'
then ${schema.flowchartSubmission.aiCriteriaDetails}
else '{}'::jsonb end
) e
where ${where} and jsonb_typeof(e.value->'score') = 'number'
group by e.key
`),
// 词云的原料。只有这条要读大列,所以只有它按时间倒序取最近的 N 条
db
const rows = await db
.select({
username: schema.user.username,
score: schema.flowchartSubmission.aiScore,
grade: schema.flowchartSubmission.aiGrade,
criteria: schema.flowchartSubmission.aiCriteriaDetails,
feedback: schema.flowchartSubmission.aiFeedback,
suggestions: schema.flowchartSubmission.aiSuggestions,
})
.from(schema.flowchartSubmission)
.where(where)
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
.where(and(...filters))
// 按时间倒序,好让词云取到的那部分是最近的
.orderBy(desc(schema.flowchartSubmission.createTime))
.limit(WORDCLOUD_TEXT_LIMIT),
// 「谁没做」只在有花名册时算得出来,行数也就一个班
roster.length
? db
.selectDistinct({ userId: schema.flowchartSubmission.userId })
.from(schema.flowchartSubmission)
.where(where)
: [],
])
if (!totals || totals.total === 0) {
return success(c, {
const empty = {
totalCount: 0,
avgScore: 0,
gradeDistribution: {},
@@ -495,68 +241,65 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
username: row.username,
realName: stripClassPrefix(row.username, row.className),
})),
} satisfies FlowchartStatistics)
}
if (rows.length === 0) return success(c, empty satisfies FlowchartStatistics)
const gradeDistribution: Record<string, number> = {}
for (const row of gradeRows) {
// 旧后端用 values_list("ai_grade") 分组,null 也会成为一个桶;这里保持同样的口径。
// null 和空串会分成两组,合并到同一个桶里
const grade = row.grade ?? ""
gradeDistribution[grade] = (gradeDistribution[grade] ?? 0) + row.n
}
/**
* textRows
*
*
* create_time **** max
* 退 100 `if (bucket) ... else set(max)`
* WORDCLOUD_TEXT_LIMIT
* 30 40****
* SQL 退 100
*/
const criteriaMax = new Map<string, number>()
const criteriaTotals = new Map<string, { sum: number; count: number; max: number }>()
const texts: string[] = []
const pushText = (value: string) => {
if (texts.length < WORDCLOUD_TEXT_LIMIT) texts.push(value)
}
for (const row of textRows) {
const submitted = new Set<string>()
let scoreSum = 0
let scoreCount = 0
for (const row of rows) {
submitted.add(row.username)
// 旧后端用 values_list("ai_grade") 分组,null 也会成为一个桶;这里保持同样的口径
const grade = row.grade ?? ""
gradeDistribution[grade] = (gradeDistribution[grade] ?? 0) + 1
if (row.score !== null) {
scoreSum += row.score
scoreCount += 1
}
for (const [key, value] of Object.entries(objectValue(row.criteria))) {
const detail = objectValue(value)
// 和上面那条聚合同一道闸:分数不是数字的项当没配过,满分和评语也都不收
if (typeof detail.score !== "number") continue
if (!criteriaMax.has(key)) {
criteriaMax.set(key, typeof detail.max === "number" ? detail.max : 100)
const bucket = criteriaTotals.get(key)
if (bucket) {
bucket.sum += detail.score
bucket.count += 1
} else {
// max 取第一次见到的那条,与旧后端 `if key not in criteria_max` 一致
criteriaTotals.set(key, {
sum: detail.score,
count: 1,
max: typeof detail.max === "number" ? detail.max : 100,
})
}
if (typeof detail.comment === "string" && detail.comment)
pushText(detail.comment)
if (typeof detail.comment === "string" && detail.comment) pushText(detail.comment)
}
if (row.feedback) pushText(row.feedback)
if (row.suggestions) pushText(row.suggestions)
}
const criteriaAverages: Record<string, { avg: number; max: number }> = {}
for (const row of criteriaRows) {
criteriaAverages[row.key] = {
avg: rounded(row.avg, 1),
max: criteriaMax.get(row.key) ?? 100,
}
for (const [key, bucket] of criteriaTotals) {
criteriaAverages[key] = { avg: rounded(bucket.sum / bucket.count, 1), max: bucket.max }
}
const submitted = new Set(submittedRows.map((row) => row.userId))
return success(c, {
totalCount: totals.total,
avgScore: totals.scoreCount
? rounded(totals.scoreSum / totals.scoreCount, 1)
: 0,
totalCount: rows.length,
// 分母是有分数的条数,不是总条数 —— 对齐 Django 的 Avg(),它跳过 NULL
avgScore: scoreCount ? rounded(scoreSum / scoreCount, 1) : 0,
gradeDistribution,
criteriaAverages,
personCount: roster.length,
completedCount: totals.completedCount,
completedCount: submitted.size,
wordFrequencies: await buildWordFrequencies(texts),
dataUnaccepted: roster
.filter((row) => !submitted.has(row.id))
.filter((row) => !submitted.has(row.username))
.map((row) => ({
username: row.username,
realName: stripClassPrefix(row.username, row.className),
@@ -565,74 +308,33 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
})
flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
const [row] = await db
.select({
flowchart: schema.flowchartSubmission,
username: schema.user.username,
problem: schema.problem,
})
.from(schema.flowchartSubmission)
.innerJoin(
schema.user,
eq(schema.flowchartSubmission.userId, schema.user.id),
)
.innerJoin(
schema.problem,
eq(schema.flowchartSubmission.problemId, schema.problem.id),
)
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
.limit(1)
if (!row || !canView(c.get("user")!, row.flowchart, row.problem))
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
const [row] = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username, problem: schema.problem })
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1)
if (!row || !canView(c.get("user")!, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist")
return success(c, flowchartData(row.flowchart, row.username))
})
flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
const user = c.get("user")!
const [row] = await db
.select({ flowchart: schema.flowchartSubmission, problem: schema.problem })
.from(schema.flowchartSubmission)
.innerJoin(
schema.problem,
eq(schema.flowchartSubmission.problemId, schema.problem.id),
)
.where(eq(schema.flowchartSubmission.id, c.req.param("id")))
.limit(1)
if (!row || !canView(user, row.flowchart, row.problem))
return failure(c, 404, "flowchart-not-found", "Submission does not exist")
if (![2, 3].includes(row.flowchart.status))
return failure(
c,
409,
"retry-not-allowed",
"Submission is not in a state that allows retry",
)
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1)
if (!row || !canView(user, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist")
if (![2, 3].includes(row.flowchart.status)) return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry")
// canView 允许本人重试自己的提交,不限流的话学生可以反复点着刷 AI 调用。
// 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法
if (!isAdminRole(user)) {
const throttle = await consumeToken("user", flowchartThrottleKey(user.id))
if (!throttle.allowed) {
return failure(
c,
429,
"too-many-submissions",
`Please wait ${Math.floor(throttle.wait)} seconds`,
)
return failure(c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`)
}
}
await db
.update(schema.flowchartSubmission)
.set({
status: 0,
aiScore: null,
aiGrade: null,
aiFeedback: null,
aiSuggestions: null,
aiCriteriaDetails: {},
processingTime: null,
evaluationTime: null,
})
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await db.update(schema.flowchartSubmission).set({
status: 0, aiScore: null, aiGrade: null, aiFeedback: null, aiSuggestions: null,
aiCriteriaDetails: {}, processingTime: null, evaluationTime: null,
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
try {
// jobId 必须**正好三段**bullmq 对含 `:` 的自定义 id 有一条兼容老的可重复
// 任务的校验(job.js 的 `split(':').length !== 3`),两段会直接抛
@@ -646,82 +348,28 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
)
} catch (error) {
// 入队失败就落 FAILED,别把提交丢在 PENDING 上 —— 和 POST /flowcharts 同一处理
await db
.update(schema.flowchartSubmission)
.set({ status: 3 })
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
return failure(
c,
502,
"queue-unavailable",
"Evaluation queue is unavailable",
)
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
}
return success(c, {
submissionId: row.flowchart.id,
status: "pending",
} satisfies CreateFlowchartResponse)
return success(c, { submissionId: row.flowchart.id, status: "pending" } satisfies CreateFlowchartResponse)
})
flowchartRoutes.get(
"/problems/:id/flowchart/current",
requireAuth,
async (c) => {
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
const rows = await db
.select({
score: schema.flowchartSubmission.aiScore,
grade: schema.flowchartSubmission.aiGrade,
})
.from(schema.flowchartSubmission)
.where(
and(
eq(schema.flowchartSubmission.userId, c.get("user")!.id),
eq(schema.flowchartSubmission.problemId, problemId),
eq(schema.flowchartSubmission.status, 2),
),
)
const rows = await db.select({ score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade })
.from(schema.flowchartSubmission).where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2)))
.orderBy(desc(schema.flowchartSubmission.createTime))
return success(c, {
count: rows.length,
score: rows[0]?.score ?? 0,
grade: rows[0]?.grade ?? "",
} satisfies FlowchartCurrent)
},
)
return success(c, { count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" } satisfies FlowchartCurrent)
})
flowchartRoutes.get(
"/problems/:id/flowchart/history",
requireAuth,
async (c) => {
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => {
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
const page = queryInteger(c.req.query("page"), 0, { min: 0 })
const rows = await db
.select({
flowchart: schema.flowchartSubmission,
username: schema.user.username,
})
.from(schema.flowchartSubmission)
.innerJoin(
schema.user,
eq(schema.flowchartSubmission.userId, schema.user.id),
)
.where(
and(
eq(schema.flowchartSubmission.userId, c.get("user")!.id),
eq(schema.flowchartSubmission.problemId, problemId),
eq(schema.flowchartSubmission.status, 2),
),
)
const rows = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username })
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
.where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2)))
.orderBy(asc(schema.flowchartSubmission.createTime))
const selected = page === 0 ? rows.at(-1) : rows[page - 1]
if (page > rows.length)
return failure(c, 400, "page-out-of-range", "Page out of range")
return success(c, {
submission: selected
? flowchartData(selected.flowchart, selected.username)
: null,
count: rows.length,
} satisfies FlowchartDetail)
},
)
if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range")
return success(c, { submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length } satisfies FlowchartDetail)
})
+9 -38
View File
@@ -1,6 +1,6 @@
import { ADMIN_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
import { and, count, eq, ilike, notInArray } from "drizzle-orm"
import { and, count, eq, notInArray } from "drizzle-orm"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
@@ -51,9 +51,7 @@ export function stripClassPrefix(
* ****
*
*/
export function asFilterValue<T extends string | number>(
value: string | number,
): T {
export function asFilterValue<T extends string | number>(value: string | number): T {
return value as T
}
@@ -104,6 +102,12 @@ export function publicTemplates(value: unknown) {
return templates
}
export function todayStart() {
const now = new Date()
now.setHours(0, 0, 0, 0)
return now.toISOString()
}
export function rounded(value: number, digits = 2) {
const factor = 10 ** digits
return Math.round(value * factor) / factor
@@ -117,10 +121,7 @@ export function rounded(value: number, digits = 2) {
* / hint
* `hint-locked`
*/
export async function countFailedSubmissions(
userId: number,
problemId: number,
) {
export async function countFailedSubmissions(userId: number, problemId: number) {
const [failed] = await db
.select({ value: count() })
.from(schema.submission)
@@ -133,33 +134,3 @@ export async function countFailedSubmissions(
)
return failed?.value ?? 0
}
/**
* **** id
* ****
*
* `user` `submission.username`
* `ilike submission.username`
*
* 2026-09-0824 85
* `ks249` 0 / 7 48
* `ks248` 20 / 54 13
*
* ****
* +
*
*
* `user_id` id
*/
export async function matchedUsers(username: string) {
return db
.select({
id: schema.user.id,
username: schema.user.username,
className: schema.user.className,
isDisabled: schema.user.isDisabled,
adminType: schema.user.adminType,
})
.from(schema.user)
.where(ilike(schema.user.username, `%${username}%`))
}
+64 -271
View File
@@ -1,11 +1,4 @@
import type {
ProblemAuthor,
ProblemDetail,
ProblemList,
ProblemListItem,
Tag,
YearlyAc,
} from "@oj2/contract"
import type { ProblemAuthor, ProblemDetail, ProblemList, ProblemListItem, Tag, YearlyAc } from "@oj2/contract"
import {
and,
asc,
@@ -28,14 +21,7 @@ import { db, schema } from "../db"
import { astRequirements } from "../judge/ast"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { localTime, shiftMonthsByCalendar, todayStart } from "../time"
import {
asFilterValue,
countFailedSubmissions,
objectValue as toObject,
queryInteger,
sampleUser,
} from "./helpers"
import { asFilterValue, countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers"
export const problemRoutes = new Hono<AppEnv>()
@@ -57,39 +43,24 @@ function publicTemplates(value: unknown) {
async function getProblemStatuses(userId: number | undefined) {
if (!userId) return {}
const [profile] = await db
.select({ value: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile)
.where(eq(schema.userProfile.userId, userId))
.limit(1)
const [profile] = await db.select({ value: schema.userProfile.acmProblemsStatus })
.from(schema.userProfile).where(eq(schema.userProfile.userId, userId)).limit(1)
return toObject(toObject(profile?.value).problems)
}
async function getProblemTags(problemIds: number[]) {
if (problemIds.length === 0) return new Map<number, string[]>()
const rows = await db
.select({
problemId: schema.problemTags.problemId,
name: schema.problemTag.name,
})
const rows = await db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name })
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(inArray(schema.problemTags.problemId, problemIds))
const result = new Map<number, string[]>()
for (const row of rows)
result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name])
for (const row of rows) result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name])
return result
}
function listItem(
row: {
problem: typeof schema.problem.$inferSelect
user: typeof schema.user.$inferSelect
realName: string | null
},
row: { problem: typeof schema.problem.$inferSelect; user: typeof schema.user.$inferSelect; realName: string | null },
tags: Map<number, string[]>,
statuses: Record<string, unknown>,
) {
@@ -114,54 +85,26 @@ function listItem(
problemRoutes.get("/problems", optionalAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = [
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
]
const filters = [eq(schema.problem.visible, true), isNull(schema.problem.contestId)]
const author = c.req.query("author")?.trim()
const keyword = c.req.query("keyword")?.trim()
const difficulty = c.req.query("difficulty")?.trim()
const tag = c.req.query("tag")?.trim()
if (author) filters.push(eq(schema.user.username, author))
if (keyword)
filters.push(
or(
ilike(schema.problem.title, `%${keyword}%`),
ilike(schema.problem.displayId, `%${keyword}%`),
)!,
)
if (difficulty)
filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty)))
if (keyword) filters.push(or(ilike(schema.problem.title, `%${keyword}%`), ilike(schema.problem.displayId, `%${keyword}%`))!)
if (difficulty) filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty)))
if (tag) {
filters.push(
inArray(
schema.problem.id,
db
.select({ id: schema.problemTags.problemId })
.from(schema.problemTags)
.innerJoin(
schema.problemTag,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.where(eq(schema.problemTag.name, tag)),
),
)
filters.push(inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.where(eq(schema.problemTag.name, tag))))
}
const where = and(...filters)
const sort = c.req.query("sort")
const order =
sort === "flowchart"
? [
desc(schema.problem.allowFlowchart),
desc(schema.problem.showFlowchart),
desc(schema.problem.createTime),
]
const order = sort === "flowchart"
? [desc(schema.problem.allowFlowchart), desc(schema.problem.showFlowchart), desc(schema.problem.createTime)]
: sort === "ast"
? [
desc(sql`(${schema.problem.astRules} is not null)`),
desc(schema.problem.createTime),
]
? [desc(sql`(${schema.problem.astRules} is not null)`), desc(schema.problem.createTime)]
: sort === "-accepted_number"
? [desc(schema.problem.acceptedNumber)]
: sort === "accepted_number"
@@ -175,24 +118,13 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
: sort === "create_time"
? [asc(schema.problem.createTime)]
: [desc(schema.problem.createTime)]
const [totalRow] = await db
.select({ value: countDistinct(schema.problem.id) })
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.where(where)
const rows = await db
.select({
problem: schema.problem,
user: schema.user,
realName: schema.userProfile.realName,
})
const [totalRow] = await db.select({ value: countDistinct(schema.problem.id) }).from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)).where(where)
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where)
.orderBy(...order)
.limit(limit)
.offset(offset)
.where(where).orderBy(...order).limit(limit).offset(offset)
const [tags, statuses] = await Promise.all([
getProblemTags(rows.map((row) => row.problem.id)),
getProblemStatuses(c.get("user")?.id),
@@ -207,63 +139,33 @@ problemRoutes.get("/problem-tags", async (c) => {
const keyword = c.req.query("keyword")?.trim()
// 只数公开题库里可见的题:隐藏的题和比赛题都不算,否则标签会出现在
// 首页列表里,点进去却一道题都筛不出来(对齐 /problems 的过滤条件)
const rows = await db
.select({
id: schema.problemTag.id,
name: schema.problemTag.name,
problemCount: countDistinct(schema.problemTags.problemId),
})
const rows = await db.select({ id: schema.problemTag.id, name: schema.problemTag.name, problemCount: countDistinct(schema.problemTags.problemId) })
.from(schema.problemTag)
.innerJoin(
schema.problemTags,
eq(schema.problemTags.problemtagId, schema.problemTag.id),
)
.innerJoin(
schema.problem,
and(
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
.innerJoin(schema.problem, and(
eq(schema.problem.id, schema.problemTags.problemId),
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
),
)
))
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
.groupBy(schema.problemTag.id, schema.problemTag.name)
.having(sql`count(${schema.problemTags.problemId}) > 0`)
.groupBy(schema.problemTag.id, schema.problemTag.name).having(sql`count(${schema.problemTags.problemId}) > 0`)
.orderBy(asc(schema.problemTag.name))
return success(c, rows satisfies Tag[])
})
problemRoutes.get("/problems/random", async (c) => {
const [row] = await db
.select({ displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(eq(schema.problem.visible, true), isNull(schema.problem.contestId)),
)
.orderBy(sql`random()`)
.limit(1)
const [row] = await db.select({ displayId: schema.problem.displayId }).from(schema.problem)
.where(and(eq(schema.problem.visible, true), isNull(schema.problem.contestId))).orderBy(sql`random()`).limit(1)
if (!row) return failure(c, 404, "no-problems", "No problem to pick")
return success(c, row.displayId)
})
problemRoutes.get("/problem-authors", async (c) => {
const showAll = c.req.query("all") === "1"
const rows = await db
.select({
username: schema.user.username,
problemCount: count(schema.problem.id),
})
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.where(
and(
isNull(schema.problem.contestId),
eq(schema.user.isDisabled, false),
showAll ? undefined : eq(schema.problem.visible, true),
),
)
.groupBy(schema.user.username)
.orderBy(desc(count(schema.problem.id)))
const rows = await db.select({ username: schema.user.username, problemCount: count(schema.problem.id) })
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.where(and(isNull(schema.problem.contestId), eq(schema.user.isDisabled, false), showAll ? undefined : eq(schema.problem.visible, true)))
.groupBy(schema.user.username).orderBy(desc(count(schema.problem.id)))
return success(c, rows satisfies ProblemAuthor[])
})
@@ -271,160 +173,55 @@ problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
const user = c.get("user")
if (!user) return success(c, "0")
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [mine] = await db
.select({ value: count() })
.from(schema.submission)
.where(
and(
eq(schema.submission.userId, user.id),
eq(schema.submission.problemId, id),
inArray(schema.submission.result, [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
]),
),
)
const [mine] = await db.select({ value: count() }).from(schema.submission).where(and(
eq(schema.submission.userId, user.id), eq(schema.submission.problemId, id),
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
))
if (!mine?.value) return success(c, "0")
// 「近两年」按东八区日历算到当天零点
const since = todayStart(shiftMonthsByCalendar(new Date(), -24))
const since = new Date(); since.setFullYear(since.getFullYear() - 2); since.setHours(0, 0, 0, 0)
const [active, accepted] = await Promise.all([
db
.select({ value: count() })
.from(schema.user)
.where(
and(
eq(schema.user.isDisabled, false),
gte(schema.user.lastLogin, since),
),
),
db
.select({ value: countDistinct(schema.submission.userId) })
.from(schema.submission)
.where(
and(
eq(schema.submission.problemId, id),
inArray(schema.submission.result, [0, 10]),
gte(schema.submission.createTime, since),
),
),
db.select({ value: count() }).from(schema.user).where(and(eq(schema.user.isDisabled, false), gte(schema.user.lastLogin, since.toISOString()))),
db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(and(
eq(schema.submission.problemId, id), inArray(schema.submission.result, [0, 10]), gte(schema.submission.createTime, since.toISOString()),
)),
])
const total = active[0]?.value ?? 0
const solved = accepted[0]?.value ?? 0
return success(
c,
total > 0 && solved < total
? (((total - solved) / total) * 100).toFixed(2)
: "0",
)
return success(c, total > 0 && solved < total ? (((total - solved) / total) * 100).toFixed(2) : "0")
})
problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
const [target] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
isNull(schema.problem.contestId),
),
)
.limit(1)
const [target] = await db.select({ id: schema.problem.id }).from(schema.problem)
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId))).limit(1)
if (!target) return failure(c, 404, "problem-not-found", "Problem not found")
const targetTags = await db
.select({ id: schema.problemTags.problemtagId })
.from(schema.problemTags)
.where(eq(schema.problemTags.problemId, target.id))
const targetTags = await db.select({ id: schema.problemTags.problemtagId }).from(schema.problemTags).where(eq(schema.problemTags.problemId, target.id))
if (targetTags.length === 0) return success(c, [])
const rows = await db
.select({
problem: schema.problem,
user: schema.user,
realName: schema.userProfile.realName,
})
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
.from(schema.problem)
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(
and(
eq(schema.problem.visible, true),
isNull(schema.problem.contestId),
sql`${schema.problem.id} <> ${target.id}`,
inArray(
schema.problem.id,
db
.select({ id: schema.problemTags.problemId })
.from(schema.problemTags)
.where(
inArray(
schema.problemTags.problemtagId,
targetTags.map((tag) => tag.id),
),
),
),
),
)
.groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName)
.orderBy(asc(schema.problem.difficulty))
.limit(5)
const [tags, statuses] = await Promise.all([
getProblemTags(rows.map((row) => row.problem.id)),
getProblemStatuses(c.get("user")?.id),
])
const filtered = rows.filter(
(row) =>
toObject(statuses[String(row.problem.id)]).status !==
JudgeStatus.ACCEPTED,
)
return success(
c,
filtered.map((row) => listItem(row, tags, statuses)),
)
.where(and(
eq(schema.problem.visible, true), isNull(schema.problem.contestId), sql`${schema.problem.id} <> ${target.id}`,
inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags)
.where(inArray(schema.problemTags.problemtagId, targetTags.map((tag) => tag.id)))),
)).groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName).orderBy(asc(schema.problem.difficulty)).limit(5)
const [tags, statuses] = await Promise.all([getProblemTags(rows.map((row) => row.problem.id)), getProblemStatuses(c.get("user")?.id)])
const filtered = rows.filter((row) => toObject(statuses[String(row.problem.id)]).status !== JudgeStatus.ACCEPTED)
return success(c, filtered.map((row) => listItem(row, tags, statuses)))
})
problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
const [problem] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
.limit(1)
if (!problem)
return failure(c, 404, "problem-not-found", "Problem does not exist")
const year = sql<number>`extract(year from ${localTime(schema.submission.createTime)})::int`
const rows = await db
.select({
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem)
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true))).limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
const year = sql<number>`extract(year from ${schema.submission.createTime})::int`
const rows = await db.select({
year,
total: count(),
accepted: sql<number>`count(*) filter (where ${schema.submission.result} in (0, 10))::int`,
})
.from(schema.submission)
.where(
and(
eq(schema.submission.problemId, problem.id),
isNull(schema.submission.contestId),
notInArray(schema.submission.result, [6, 7]),
),
)
.groupBy(year)
.orderBy(year)
return success(
c,
rows.map(
(row) =>
({
...row,
acRate:
row.total > 0
? Math.round((row.accepted / row.total) * 10_000) / 100
: 0,
}) satisfies YearlyAc,
),
)
}).from(schema.submission).where(and(eq(schema.submission.problemId, problem.id), isNull(schema.submission.contestId), notInArray(schema.submission.result, [6, 7])))
.groupBy(year).orderBy(year)
return success(c, rows.map((row) => ({ ...row, acRate: row.total > 0 ? Math.round(row.accepted / row.total * 10_000) / 100 : 0 } satisfies YearlyAc)))
})
problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
@@ -445,8 +242,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
)
.limit(1)
if (!row)
return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
const tagRows = await db
.select({ name: schema.problemTag.name })
@@ -499,10 +295,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
statisticInfo: objectValue(row.problem.statisticInfo),
contestId: row.problem.contestId,
tags: tagRows.map((tag) => tag.name),
createdBy: sampleUser(
{ id: row.creatorId, username: row.creatorUsername },
null,
),
createdBy: sampleUser({ id: row.creatorId, username: row.creatorUsername }, null),
myStatus,
myFailedCount,
allowFlowchart: row.problem.allowFlowchart,
+180 -358
View File
@@ -1,5 +1,6 @@
import {
joinProblemSetRequestSchema,
updateProblemSetProgressRequestSchema,
type ProblemSet,
type ProblemSetBadge,
type ProblemSetList,
@@ -24,33 +25,27 @@ import {
} from "drizzle-orm"
import { Hono } from "hono"
import {
optionalAuth,
requireAuth,
requireTeacher,
type AppEnv,
} from "../auth/middleware"
import { optionalAuth, requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
import { failure, success } from "../http"
import { computeProgress } from "../services/problemset"
import { JudgeStatus } from "../judge/status"
import { updateAchievementsForProblemSet } from "../services/achievements"
import { computeProgress, eligibleForBadge } from "../services/problemset"
import { asFilterValue, objectValue, queryInteger, sampleUser } from "./helpers"
export const problemsetRoutes = new Hono<AppEnv>()
type ProblemSetRow = typeof schema.problemset.$inferSelect
function progressSummary(
progress: typeof schema.problemsetProgress.$inferSelect | undefined,
) {
return progress
? {
function progressSummary(progress: typeof schema.problemsetProgress.$inferSelect | undefined) {
return progress ? {
isJoined: true,
progressPercentage: progress.progressPercentage,
completedCount: progress.completedProblemsCount,
totalCount: progress.totalProblemsCount,
isCompleted: progress.isCompleted,
}
: {
} : {
isJoined: false,
progressPercentage: 0,
completedCount: 0,
@@ -62,23 +57,14 @@ function progressSummary(
async function problemSetCreators(ids: number[]) {
const map = new Map<number, ReturnType<typeof sampleUser>>()
if (ids.length === 0) return map
const rows = await db
.select({
id: schema.user.id,
username: schema.user.username,
realName: schema.userProfile.realName,
})
.from(schema.user)
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
const rows = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName })
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(inArray(schema.user.id, ids))
for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
return map
}
function badgeData(
badge: typeof schema.problemsetBadge.$inferSelect,
earned?: boolean,
) {
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) {
return {
id: badge.id,
problemsetId: badge.problemsetId,
@@ -104,68 +90,26 @@ async function serializeProblemSets(
) {
if (rows.length === 0) return []
const ids = rows.map((row) => row.id)
const [problemCounts, progresses, badges, earnedRows, creators] =
await Promise.all([
db
.select({
problemsetId: schema.problemsetProblem.problemsetId,
value: count(),
})
.from(schema.problemsetProblem)
.where(inArray(schema.problemsetProblem.problemsetId, ids))
const [problemCounts, progresses, badges, earnedRows, creators] = await Promise.all([
db.select({ problemsetId: schema.problemsetProblem.problemsetId, value: count() })
.from(schema.problemsetProblem).where(inArray(schema.problemsetProblem.problemsetId, ids))
.groupBy(schema.problemsetProblem.problemsetId),
userId
? db
.select()
.from(schema.problemsetProgress)
.where(
and(
inArray(schema.problemsetProgress.problemsetId, ids),
eq(schema.problemsetProgress.userId, userId),
),
)
: Promise.resolve(
[] as (typeof schema.problemsetProgress.$inferSelect)[],
),
includeBadges
? db
.select()
.from(schema.problemsetBadge)
.where(inArray(schema.problemsetBadge.problemsetId, ids))
.orderBy(asc(schema.problemsetBadge.id))
userId ? db.select().from(schema.problemsetProgress)
.where(and(inArray(schema.problemsetProgress.problemsetId, ids), eq(schema.problemsetProgress.userId, userId)))
: Promise.resolve([] as (typeof schema.problemsetProgress.$inferSelect)[]),
includeBadges ? db.select().from(schema.problemsetBadge)
.where(inArray(schema.problemsetBadge.problemsetId, ids)).orderBy(asc(schema.problemsetBadge.id))
: Promise.resolve([] as (typeof schema.problemsetBadge.$inferSelect)[]),
includeBadges && userId
? db
.select({ id: schema.userBadge.badgeId })
.from(schema.userBadge)
.innerJoin(
schema.problemsetBadge,
eq(schema.userBadge.badgeId, schema.problemsetBadge.id),
)
.where(
and(
eq(schema.userBadge.userId, userId),
inArray(schema.problemsetBadge.problemsetId, ids),
),
)
includeBadges && userId ? db.select({ id: schema.userBadge.badgeId }).from(schema.userBadge)
.innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id))
.where(and(eq(schema.userBadge.userId, userId), inArray(schema.problemsetBadge.problemsetId, ids)))
: Promise.resolve([] as { id: number }[]),
problemSetCreators([...new Set(rows.map((row) => row.createdById))]),
])
const countBySet = new Map(
problemCounts.map((item) => [item.problemsetId, item.value]),
)
const progressBySet = new Map(
progresses.map((item) => [item.problemsetId, item]),
)
const badgesBySet = new Map<
number,
(typeof schema.problemsetBadge.$inferSelect)[]
>()
for (const badge of badges)
badgesBySet.set(badge.problemsetId, [
...(badgesBySet.get(badge.problemsetId) ?? []),
badge,
])
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item.value]))
const progressBySet = new Map(progresses.map((item) => [item.problemsetId, item]))
const badgesBySet = new Map<number, (typeof schema.problemsetBadge.$inferSelect)[]>()
for (const badge of badges) badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge])
const earned = new Set(earnedRows.map((item) => item.id))
return rows.map((row) => {
const progress = progressBySet.get(row.id)
@@ -173,9 +117,7 @@ async function serializeProblemSets(
id: row.id,
title: row.title,
description: row.description,
createdBy:
creators.get(row.createdById) ??
sampleUser({ id: row.createdById, username: "" }, null),
createdBy: creators.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime,
difficulty: row.difficulty,
@@ -185,11 +127,7 @@ async function serializeProblemSets(
problemsCount: countBySet.get(row.id) ?? 0,
completedCount: progress?.completedProblemsCount ?? 0,
userProgress: progressSummary(progress),
badges: includeBadges
? (badgesBySet.get(row.id) ?? []).map((badge) =>
badgeData(badge, earned.has(badge.id)),
)
: undefined,
badges: includeBadges ? (badgesBySet.get(row.id) ?? []).map((badge) => badgeData(badge, earned.has(badge.id))) : undefined,
} satisfies ProblemSet
})
}
@@ -197,33 +135,17 @@ async function serializeProblemSets(
problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = [
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
]
const filters = [eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft")]
const keyword = c.req.query("keyword")?.trim()
const difficulty = c.req.query("difficulty")?.trim()
const status = c.req.query("status")?.trim()
if (keyword)
filters.push(
or(
ilike(schema.problemset.title, `%${keyword}%`),
ilike(schema.problemset.description, `%${keyword}%`),
)!,
)
if (difficulty)
filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (keyword) filters.push(or(ilike(schema.problemset.title, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`))!)
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
const where = and(...filters)
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problemset).where(where),
db
.select()
.from(schema.problemset)
.where(where)
.orderBy(desc(schema.problemset.createTime))
.limit(limit)
.offset(offset),
db.select().from(schema.problemset).where(where).orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset),
])
return success(c, {
results: await serializeProblemSets(rows, c.get("user")?.id, true),
@@ -233,17 +155,8 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [row] = await db
.select()
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, id),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
const [row] = await db.select().from(schema.problemset)
.where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const [data] = await serializeProblemSets([row], c.get("user")?.id)
return success(c, data)
@@ -251,17 +164,8 @@ problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problemSet] = await db
.select({ id: schema.problemset.id })
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, id),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset)
.where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
// 只取卡片要渲染的四列。取 schema.problem 整行会把题面、样例、答案、ast_rules、
// flowchart_data、sql_display 一起拉回来,题单页一个都不用。
@@ -269,8 +173,7 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而卡片是按数组
// 下标编号的(#1 #2 #3),题单 8 / 11 / 14 实际就存在 order 重复,不定死的话
// 「第 3 题」指哪道题每次刷新都可能不一样。后台那条列表一直是这么排的。
const rows = await db
.select({
const rows = await db.select({
link: schema.problemsetProblem,
problemId: schema.problem.id,
displayId: schema.problem.displayId,
@@ -278,33 +181,15 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
difficulty: schema.problem.difficulty,
})
.from(schema.problemsetProblem)
.innerJoin(
schema.problem,
eq(schema.problemsetProblem.problemId, schema.problem.id),
)
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
.where(eq(schema.problemsetProblem.problemsetId, id))
.orderBy(
asc(schema.problemsetProblem.order),
asc(schema.problemsetProblem.id),
)
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
const progressRows = c.get("user")
? await db
.select({ detail: schema.problemsetProgress.progressDetail })
.from(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.problemsetId, id),
eq(schema.problemsetProgress.userId, c.get("user")!.id),
),
)
.limit(1)
? await db.select({ detail: schema.problemsetProgress.progressDetail }).from(schema.problemsetProgress)
.where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, c.get("user")!.id))).limit(1)
: []
const completed = objectValue(progressRows[0]?.detail)
return success(
c,
rows.map(
({ link, problemId, displayId, title, difficulty }) =>
({
return success(c, rows.map(({ link, problemId, displayId, title, difficulty }) => ({
id: link.id,
problemsetId: link.problemsetId,
problem: { id: problemId, _id: displayId, title, difficulty },
@@ -313,9 +198,7 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
score: link.score,
hint: link.hint,
isCompleted: String(problemId) in completed,
}) satisfies ProblemSetProblem,
),
)
} satisfies ProblemSetProblem)))
})
async function recomputeProgress(
@@ -323,58 +206,30 @@ async function recomputeProgress(
progress: typeof schema.problemsetProgress.$inferSelect,
detail: Record<string, unknown>,
) {
const links = await tx
.select({
const links = await tx.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
})
.from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份,
// 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed
const update = computeProgress(detail, links, progress.completeTime)
await tx
.update(schema.problemsetProgress)
.set(update)
.where(eq(schema.problemsetProgress.id, progress.id))
await tx.update(schema.problemsetProgress).set(update).where(eq(schema.problemsetProgress.id, progress.id))
return { ...progress, ...update }
}
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
const parsed = joinProblemSetRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success)
return failure(c, 400, "invalid-request", "Invalid problem set")
const parsed = joinProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid problem set")
const user = c.get("user")!
const [problemSet] = await db
.select({ id: schema.problemset.id })
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, parsed.data.problemSetId),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset)
.where(and(eq(schema.problemset.id, parsed.data.problemSetId), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
const [existing] = await db
.select({ id: schema.problemsetProgress.id })
.from(schema.problemsetProgress)
.where(
and(
eq(schema.problemsetProgress.problemsetId, problemSet.id),
eq(schema.problemsetProgress.userId, user.id),
),
)
.limit(1)
const [existing] = await db.select({ id: schema.problemsetProgress.id }).from(schema.problemsetProgress)
.where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id))).limit(1)
if (existing) return failure(c, 409, "already-joined", "已经加入该题单")
await db.transaction(async (tx) => {
const [created] = await tx
.insert(schema.problemsetProgress)
.values({
const [created] = await tx.insert(schema.problemsetProgress).values({
problemsetId: problemSet.id,
userId: user.id,
joinTime: new Date().toISOString(),
@@ -385,112 +240,140 @@ problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
totalProblemsCount: 0,
totalScore: 0,
progressDetail: {},
})
.returning()
}).returning()
if (created) await recomputeProgress(tx, created, {})
})
return success(c, null, 201)
})
problemsetRoutes.put("/problem-set-progress", requireAuth, async (c) => {
const parsed = updateProblemSetProgressRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid progress payload")
const user = c.get("user")!
const result = await db.transaction(async (tx) => {
const [problemSet] = await tx.select().from(schema.problemset).where(and(
eq(schema.problemset.id, parsed.data.problemSetId), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
)).limit(1)
if (!problemSet) return { error: "problem-set-not-found" as const }
const [progress] = await tx.select().from(schema.problemsetProgress).where(and(
eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id),
)).for("update").limit(1)
if (!progress) return { error: "not-joined" as const }
const [submission] = await tx.select().from(schema.submission).where(and(
eq(schema.submission.id, parsed.data.submissionId), eq(schema.submission.userId, user.id), eq(schema.submission.problemId, parsed.data.problemId),
)).limit(1)
if (!submission) return { error: "submission-not-found" as const }
if (![JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(submission.result as 0 | 10)) return { error: "submission-not-accepted" as const }
const [link] = await tx.select().from(schema.problemsetProblem).where(and(
eq(schema.problemsetProblem.problemsetId, problemSet.id), eq(schema.problemsetProblem.problemId, parsed.data.problemId),
)).limit(1)
if (!link) return { error: "problem-not-in-set" as const }
const detail = objectValue(progress.progressDetail)
detail[String(parsed.data.problemId)] = { score: link.score, submit_time: new Date().toISOString() }
const updated = await recomputeProgress(tx, progress, detail)
const [existingSubmission] = await tx.select({ id: schema.problemsetSubmission.id })
.from(schema.problemsetSubmission).where(and(
eq(schema.problemsetSubmission.problemsetId, problemSet.id),
eq(schema.problemsetSubmission.userId, user.id),
eq(schema.problemsetSubmission.problemId, parsed.data.problemId),
)).limit(1)
if (!existingSubmission) {
await tx.insert(schema.problemsetSubmission).values({
problemsetId: problemSet.id,
userId: user.id,
submissionId: submission.id,
problemId: parsed.data.problemId,
})
}
const badges = await tx.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, problemSet.id))
// 判定走 services/problemset.ts 那一份 —— 这里原来是第三份手抄的达标逻辑,
// 后台重算和补发脚本各有各的,改一处规则就会漏掉另外两处
const hits = badges.filter((badge) => eligibleForBadge(badge, updated))
if (hits.length === 0) return { earned: [] as (typeof schema.problemsetBadge.$inferSelect)[] }
// 达标的奖章一次插完,冲突忽略后 returning 回来的就是这次真拿到的
const inserted = await tx.insert(schema.userBadge).values(hits.map((badge) => ({
userId: user.id,
badgeId: badge.id,
earnedTime: new Date().toISOString(),
}))).onConflictDoNothing({ target: [schema.userBadge.badgeId, schema.userBadge.userId] })
.returning({ badgeId: schema.userBadge.badgeId })
const insertedIds = new Set(inserted.map((row) => row.badgeId))
return { earned: hits.filter((badge) => insertedIds.has(badge.id)) }
})
if ("error" in result && result.error) {
const error = result.error
const messages = {
"problem-set-not-found": "题单不存在",
"not-joined": "未加入该题单",
"submission-not-found": "提交记录不存在",
"submission-not-accepted": "只有通过的提交才能更新进度",
"problem-not-in-set": "题目不在题单中",
}
return failure(c, error.endsWith("not-found") ? 404 : 400, error, messages[error])
}
const unlocked = await updateAchievementsForProblemSet(user.id)
await Promise.all([
publishAchievementNotification(user.id, result.earned.map((badge) => ({
id: badge.id,
name: badge.name,
description: badge.description,
icon: badge.icon,
rarity: "bronze",
kind: "badge",
}))),
publishAchievementNotification(user.id, unlocked.map((achievement) => ({
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
}))),
])
return success(c, { earnedBadges: result.earned.map((badge) => badgeData(badge)) })
})
problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
const requested = c.req.param("username")
const username = requested === "me" ? c.get("user")?.username : requested
if (!username)
return failure(c, 401, "login-required", "Authentication required")
const [target] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(
eq(schema.user.username, username),
eq(schema.user.isDisabled, false),
),
)
.limit(1)
if (!username) return failure(c, 401, "login-required", "Authentication required")
const [target] = await db.select({ id: schema.user.id }).from(schema.user)
.where(and(eq(schema.user.username, username), eq(schema.user.isDisabled, false))).limit(1)
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
const rows = await db
.select({
userBadge: schema.userBadge,
badge: schema.problemsetBadge,
problemSet: schema.problemset,
})
.from(schema.userBadge)
.innerJoin(
schema.problemsetBadge,
eq(schema.userBadge.badgeId, schema.problemsetBadge.id),
)
.innerJoin(
schema.problemset,
eq(schema.problemsetBadge.problemsetId, schema.problemset.id),
)
.where(eq(schema.userBadge.userId, target.id))
.orderBy(desc(schema.userBadge.earnedTime))
return success(
c,
rows.map(
({ userBadge, badge, problemSet }) =>
({
const rows = await db.select({ userBadge: schema.userBadge, badge: schema.problemsetBadge, problemSet: schema.problemset })
.from(schema.userBadge).innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id))
.innerJoin(schema.problemset, eq(schema.problemsetBadge.problemsetId, schema.problemset.id))
.where(eq(schema.userBadge.userId, target.id)).orderBy(desc(schema.userBadge.earnedTime))
return success(c, rows.map(({ userBadge, badge, problemSet }) => ({
id: userBadge.id,
userId: userBadge.userId,
badge: badgeData(badge),
earnedTime: userBadge.earnedTime,
problemset: { id: problemSet.id, title: problemSet.title },
}) satisfies UserBadge,
),
)
} satisfies UserBadge)))
})
problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problemSet] = await db
.select({ id: schema.problemset.id })
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, id),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset).where(and(
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
)).limit(1)
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badges = await db
.select()
.from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, id))
return success(
c,
badges.map((badge) => badgeData(badge)),
)
const badges = await db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, id))
return success(c, badges.map((badge) => badgeData(badge)))
})
problemsetRoutes.get(
"/problem-sets/:id/user-progress",
requireTeacher,
async (c) => {
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => {
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
const [problemSet] = await db
.select({
id: schema.problemset.id,
createdById: schema.problemset.createdById,
})
.from(schema.problemset)
.where(
and(
eq(schema.problemset.id, id),
eq(schema.problemset.visible, true),
ne(schema.problemset.status, "draft"),
),
)
.limit(1)
const [problemSet] = await db.select({ id: schema.problemset.id, createdById: schema.problemset.createdById })
.from(schema.problemset).where(and(
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
)).limit(1)
// 归属校验,和后台那条同类接口(admin/problemset.ts 的 loadOwned)一致:超管放行,
// 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。
// 越权报「不存在」,不泄露题单存在与否。
const user = c.get("user")!
if (
!problemSet ||
(user.adminType !== "Super Admin" && problemSet.createdById !== user.id)
) {
if (!problemSet || (user.adminType !== "Super Admin" && problemSet.createdById !== user.id)) {
return failure(c, 404, "problem-set-not-found", "题单不存在")
}
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
@@ -499,77 +382,24 @@ problemsetRoutes.get(
const completion = c.req.query("completionStatus")?.trim()
const filters = [eq(schema.problemsetProgress.problemsetId, id)]
if (className) filters.push(ilike(schema.user.username, `%${className}%`))
if (completion === "completed")
filters.push(eq(schema.problemsetProgress.isCompleted, true))
else if (completion === "in_progress")
filters.push(
and(
eq(schema.problemsetProgress.isCompleted, false),
gt(schema.problemsetProgress.completedProblemsCount, 0),
)!,
)
else if (completion === "not_started")
filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0))
if (completion === "completed") filters.push(eq(schema.problemsetProgress.isCompleted, true))
else if (completion === "in_progress") filters.push(and(eq(schema.problemsetProgress.isCompleted, false), gt(schema.problemsetProgress.completedProblemsCount, 0))!)
else if (completion === "not_started") filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0))
const where = and(...filters)
const [statsRows, rows, problemRows] = await Promise.all([
db
.select({
total: count(),
completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`,
avgProgress: avg(schema.problemsetProgress.progressPercentage),
})
.from(schema.problemsetProgress)
.innerJoin(
schema.user,
eq(schema.problemsetProgress.userId, schema.user.id),
)
.where(where),
db
.select({
progress: schema.problemsetProgress,
user: schema.user,
realName: schema.userProfile.realName,
})
.from(schema.problemsetProgress)
.innerJoin(
schema.user,
eq(schema.problemsetProgress.userId, schema.user.id),
)
.leftJoin(
schema.userProfile,
eq(schema.userProfile.userId, schema.user.id),
)
.where(where)
.orderBy(
desc(schema.problemsetProgress.isCompleted),
desc(schema.problemsetProgress.progressPercentage),
asc(schema.problemsetProgress.joinTime),
)
.limit(limit)
.offset(offset),
db
.select({
id: schema.problem.id,
_id: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.problemsetProblem)
.innerJoin(
schema.problem,
eq(schema.problemsetProblem.problemId, schema.problem.id),
)
db.select({ total: count(), completed: sql<number>`count(*) filter (where ${schema.problemsetProgress.isCompleted})::int`, avgProgress: avg(schema.problemsetProgress.progressPercentage) })
.from(schema.problemsetProgress).innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id)).where(where),
db.select({ progress: schema.problemsetProgress, user: schema.user, realName: schema.userProfile.realName })
.from(schema.problemsetProgress).innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
.orderBy(desc(schema.problemsetProgress.isCompleted), desc(schema.problemsetProgress.progressPercentage), asc(schema.problemsetProgress.joinTime)).limit(limit).offset(offset),
db.select({ id: schema.problem.id, _id: schema.problem.displayId, title: schema.problem.title }).from(schema.problemsetProblem)
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
.where(eq(schema.problemsetProblem.problemsetId, id))
.orderBy(
asc(schema.problemsetProblem.order),
asc(schema.problemsetProblem.id),
),
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)),
])
const problemMap = new Map(
problemRows.map((problem) => [String(problem.id), problem]),
)
const results = rows.map(
({ progress, user: progressUser, realName }) =>
({
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem]))
const results = rows.map(({ progress, user: progressUser, realName }) => ({
id: progress.id,
problemsetId: progress.problemsetId,
user: sampleUser(progressUser, realName),
@@ -580,21 +410,13 @@ problemsetRoutes.get(
completedProblemsCount: progress.completedProblemsCount,
totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore,
completedProblems: Object.keys(
objectValue(progress.progressDetail),
).flatMap((key) => problemMap.get(key) ?? []),
}) satisfies ProblemSetProgress,
)
completedProblems: Object.keys(objectValue(progress.progressDetail)).flatMap((key) => problemMap.get(key) ?? []),
} satisfies ProblemSetProgress))
const stats = statsRows[0]
return success(c, {
results,
total: stats?.total ?? 0,
statistics: {
total: stats?.total ?? 0,
completed: stats?.completed ?? 0,
avgProgress: Number(stats?.avgProgress ?? 0),
},
statistics: { total: stats?.total ?? 0, completed: stats?.completed ?? 0, avgProgress: Number(stats?.avgProgress ?? 0) },
problems: problemRows,
} satisfies ProblemSetProgressList)
},
)
})
+8 -29
View File
@@ -36,10 +36,7 @@ siteRoutes.get("/site/online", async (c) => {
// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里)
const fallbackQuotes = [
{
hitokoto: "程序首先是写给人读的,其次才是让机器执行。",
from: "Structure and Interpretation of Computer Programs",
},
{ hitokoto: "程序首先是写给人读的,其次才是让机器执行。", from: "Structure and Interpretation of Computer Programs" },
{ hitokoto: "把大问题拆成足够小的问题,答案就会浮现。", from: "判题狗" },
{ hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗" },
]
@@ -53,15 +50,10 @@ const sentenceCache = new Map<string, Quote[]>()
async function loadSentences(path: string) {
const cached = sentenceCache.get(path)
if (cached) return cached
const raw = (await Bun.file(
resolve(config.hitokotoDirectory, path),
).json()) as { hitokoto?: unknown; from?: unknown }[]
const raw = await Bun.file(resolve(config.hitokotoDirectory, path)).json() as { hitokoto?: unknown, from?: unknown }[]
const rows = (Array.isArray(raw) ? raw : [])
.filter((it) => typeof it.hitokoto === "string" && it.hitokoto.length > 0)
.map((it) => ({
hitokoto: it.hitokoto as string,
from: typeof it.from === "string" ? it.from : "佚名",
}))
.map((it) => ({ hitokoto: it.hitokoto as string, from: typeof it.from === "string" ? it.from : "佚名" }))
if (rows.length === 0) throw new Error(`empty hitokoto category: ${path}`)
sentenceCache.set(path, rows)
return rows
@@ -69,12 +61,8 @@ async function loadSentences(path: string) {
async function randomQuote() {
if (!categoryPaths) {
const categories = (await Bun.file(
resolve(config.hitokotoDirectory, "categories.json"),
).json()) as { path?: string }[]
const paths = categories
.map((it) => it.path)
.filter((it): it is string => typeof it === "string")
const categories = await Bun.file(resolve(config.hitokotoDirectory, "categories.json")).json() as { path?: string }[]
const paths = categories.map((it) => it.path).filter((it): it is string => typeof it === "string")
if (paths.length === 0) throw new Error("no hitokoto categories")
categoryPaths = paths
}
@@ -87,8 +75,7 @@ siteRoutes.get("/quotes/random", async (c) => {
try {
return success(c, (await randomQuote()) satisfies Quote)
} catch {
const item =
fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]!
const item = fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]!
return success(c, item satisfies Quote)
}
})
@@ -96,12 +83,7 @@ siteRoutes.get("/quotes/random", async (c) => {
siteRoutes.get("/classes/:className/usernames", async (c) => {
const className = c.req.param("className").trim()
if (!/^\d{3,4}$/.test(className)) {
return failure(
c,
400,
"invalid-class",
"Class name must contain 3 or 4 digits",
)
return failure(c, 400, "invalid-class", "Class name must contain 3 or 4 digits")
}
const rows = await db
.select({ username: schema.user.username })
@@ -109,8 +91,5 @@ siteRoutes.get("/classes/:className/usernames", async (c) => {
.where(eq(schema.user.className, className))
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
// 用 stripClassPrefix 而不是 replacereplace 会把中间的匹配也删掉,前缀对不上时截出乱码
return success(
c,
rows.map(({ username }) => stripClassPrefix(username, className)),
)
return success(c, rows.map(({ username }) => stripClassPrefix(username, className)))
})
@@ -1,704 +0,0 @@
/**
* /
*
* submission.ts ****submission.ts
* `route("/", submissionStatisticsRoutes)` `/submissions/:id`
* `/submissions/statistics` id Hono
*/
import {
type SubmissionStatistics,
type SubmissionStatisticsItems,
type TodaySubmissionStatistics,
} from "@oj2/contract"
import {
and,
count,
desc,
eq,
ilike,
inArray,
isNull,
or,
sql,
type SQL,
} from "drizzle-orm"
import { Hono } from "hono"
import { optionalAuth, requireTeacher } from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { failure, success } from "../http"
import {
JudgeStatus,
UNJUDGED_RESULTS,
type JudgeStatusValue,
} from "../judge/status"
import { type ContestEnv } from "../services/contest"
import { getBooleanOption } from "../services/options"
import { localTime, todayStart } from "../time"
import { isAdminRole, matchedUsers, rounded, stripClassPrefix } from "./helpers"
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 正确率。分母是判完的条数,一条都还没判完时给 0 而不是 NaN */
function judgedRate(accepted: number, judged: number) {
return judged > 0 ? rounded((accepted / judged) * 100) : 0
}
/**
* ****
* +
*
* `localTime()` `extract(hour from create_time)`
* UTC 8
*/
submissionStatisticsRoutes.get(
"/submissions/today-statistics",
optionalAuth,
async (c) => {
/**
*
* today-count
* 21 0
*/
const showProblems =
(await getBooleanOption("submission_list_show_all", true)) ||
isAdminRole(c.get("user"))
const where = and(
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} >= ${todayStart()}`,
)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int`
const [[totals], hourRows, languageRows, resultRows, problemRows] =
await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
userCount:
sql<number>`count(distinct ${schema.submission.userId})`.mapWith(
Number,
),
})
.from(schema.submission)
.where(where),
db
.select({ hour, value: count() })
.from(schema.submission)
.where(where)
.groupBy(hour),
db
.select({ language: schema.submission.language, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.language)
.orderBy(desc(count())),
db
.select({ result: schema.submission.result, value: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.result)
.orderBy(desc(count())),
showProblems
? db
.select({
displayId: schema.problem.displayId,
title: schema.problem.title,
value: count(),
accepted: acceptedFilter.mapWith(Number),
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
// 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西
.where(and(where, eq(schema.problem.visible, true)))
.groupBy(
schema.problem.id,
schema.problem.displayId,
schema.problem.title,
)
.orderBy(desc(count()))
.limit(10)
: [],
])
const total = totals?.total ?? 0
const judging = totals?.judging ?? 0
const hours = Array.from({ length: 24 }, () => 0)
for (const row of hourRows) hours[row.hour] = row.value
return success(c, {
total,
accepted: totals?.accepted ?? 0,
judging,
correctRate: judgedRate(totals?.accepted ?? 0, total - judging),
userCount: totals?.userCount ?? 0,
hours,
languages: languageRows.map((row) => ({
language: row.language,
count: row.value,
})),
results: resultRows.map((row) => ({
result: row.result,
count: row.value,
})),
problems: problemRows.map((row) => ({
problem: row.displayId,
problemTitle: row.title,
count: row.value,
acceptedCount: row.accepted,
})),
} satisfies TodaySubmissionStatistics)
},
)
/**
* `end` `start`
*/
function statisticsRange(c: {
req: { query(name: string): string | undefined }
}) {
const end = c.req.query("end")?.trim()
if (!end) return null
const start = c.req.query("start")?.trim()
return { start: start || null, end }
}
/** 一次最多查几道题。课堂上一节课布置三五道,20 是留足了余量的上限 */
const STATISTICS_MAX_PROBLEMS = 20
/**
* `1001,1005,1010`
*
*/
function parseDisplayIds(raw: string) {
const seen = new Set<string>()
const ids: string[] = []
for (const part of raw.split(/[,;\s]+/)) {
const id = part.trim()
if (!id) continue
const key = id.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
ids.push(id)
}
return ids
}
/**
* _id****退
*
*/
async function findPublicProblemsByDisplayIds(displayIds: string[]) {
const lowered = displayIds.map((id) => id.toLowerCase())
const rows = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
inArray(sql`lower(${schema.problem.displayId})`, lowered),
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
const found = new Set(rows.map((row) => row.displayId.toLowerCase()))
const missing = displayIds.find((id) => !found.has(id.toLowerCase()))
return { ids: rows.map((row) => row.id), missing: missing ?? null }
}
/**
* updateExpandedRowKeys key
* ****
*
* data +
* 49108 105631
*/
const STATISTICS_ITEMS_LIMIT = 200
/** 错误摘要截断长度。编译错误能刷几十行,弹层里放不下,也没必要 */
const FAILURE_MESSAGE_LIMIT = 400
/**
* ****
*
* 12
* err_info
* statistic_info
*/
async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
// result 手写成 JudgeStatusValue:这条裸 SQL 读的就是 submission.result 那一列,
// 口径要和列上的 $type 一致
const byUser = new Map<
number,
{
id: string
problem: string
result: JudgeStatusValue
error: string | null
}
>()
if (!userIds.length) return byUser
// 不给 submission 起别名:where 里的条件是 drizzle 拼的,引用的是 "submission"."x"
const rows = await db.execute<{
user_id: number
id: string
problem: string
result: JudgeStatusValue
error: string | null
}>(sql`
select user_id, id, problem, result, error from (
select
${schema.submission.userId} as user_id,
${schema.submission.id} as id,
${schema.problem.displayId} as problem,
${schema.submission.result} as result,
left(${schema.submission.statisticInfo}->>'err_info', ${FAILURE_MESSAGE_LIMIT}) as error,
row_number() over (
partition by ${schema.submission.userId}
order by ${schema.submission.createTime} desc
) as rn
from ${schema.submission}
join ${schema.problem} on ${schema.problem.id} = ${schema.submission.problemId}
where ${and(where, inArray(schema.submission.userId, userIds))}
) t
where rn = 1
`)
for (const row of rows) {
byUser.set(row.user_id, {
id: row.id,
problem: row.problem,
result: row.result,
error: row.error,
})
}
return byUser
}
/**
* AST_CHECK_FAILED
*
* **** AST_CHECK_FAILED ACCEPTED
* ×
* `count(distinct problem_id) filter (result = 10)`
*
* AST_CHECK_FAILED
*
*/
async function astOnlyByUser(where: SQL | undefined, userIds: number[]) {
const byUser = new Map<number, number>()
if (!userIds.length) return byUser
const rows = await db.execute<{ user_id: number; n: number }>(sql`
select user_id, count(*)::int as n from (
select
${schema.submission.userId} as user_id,
bool_or(${schema.submission.result} = ${JudgeStatus.AST_CHECK_FAILED}) as has_ast,
bool_or(${schema.submission.result} = ${JudgeStatus.ACCEPTED}) as has_ac
from ${schema.submission}
where ${and(where, inArray(schema.submission.userId, userIds))}
group by ${schema.submission.userId}, ${schema.submission.problemId}
) t
where has_ast and not has_ac
group by user_id
`)
for (const row of rows) byUser.set(row.user_id, row.n)
return byUser
}
/**
* ****
*
* - `user_id in (改过名的当前用户名匹配到的账号)`
* `ks248` 685
*
* - `submission.username ilike` `user`
*
*
* user_id
*
*
* ** id ** `user_id in (子查询)` OR
* hashed SubPlan OR trigram
* submission_public_metrics_idx + submission_public_username_trgm_idx
* count 65ms 0.6ms`ks2` 退30~50ms
*
*/
export async function usernameFilter(username: string) {
const like = `%${username}%`
const users = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(ilike(schema.user.username, like))
const frozen = ilike(schema.submission.username, like)
return users.length
? or(
inArray(
schema.submission.userId,
users.map((row) => row.id),
),
frozen,
)!
: frozen
}
/**
* problem.id `submission.problem_id`
* join problem `lower(problem._id)` problem
* submission_public_problem_time_idx
*
* `_id`
* 0
* filter
*/
export async function problemFilter(
displayId: string,
contestId: number | null,
) {
const problems = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
contestId === null
? isNull(schema.problem.contestId)
: eq(schema.problem.contestId, contestId),
),
)
return problems.length
? inArray(
schema.submission.problemId,
problems.map((row) => row.id),
)
: sql`false`
}
/**
* + ****
* ilike ks251
* `user` user_id user_id
*/
type StatisticsScope =
| { ok: true; filters: SQL[]; problemCount: number }
| { ok: false; status: 400 | 404; code: string; message: string }
async function statisticsScope(c: {
req: { query(name: string): string | undefined }
}): Promise<StatisticsScope> {
const range = statisticsRange(c)
if (!range) {
return {
ok: false,
status: 400,
code: "invalid-request",
message: "end is required",
}
}
const filters = [
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} <= ${range.end}`,
]
if (range.start)
filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
const displayIds = parseDisplayIds(c.req.query("problemId") ?? "")
if (displayIds.length > STATISTICS_MAX_PROBLEMS) {
return {
ok: false,
status: 400,
code: "invalid-request",
message: `At most ${STATISTICS_MAX_PROBLEMS} problems`,
}
}
if (displayIds.length) {
const { ids, missing } = await findPublicProblemsByDisplayIds(displayIds)
if (missing) {
return {
ok: false,
status: 404,
code: "problem-not-found",
message: `Problem ${missing} does not exist`,
}
}
filters.push(inArray(schema.submission.problemId, ids))
}
return { ok: true, filters, problemCount: displayIds.length }
}
submissionStatisticsRoutes.get(
"/submissions/statistics",
requireTeacher,
async (c) => {
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
const filters = scope.filters
const username = c.req.query("username")?.trim()
// 用户名先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面,
// 但换掉的是下面**四条**语句各一次的 submission 全表扫:`ilike` 走不了索引,
// 换成 `user_id in (...)` 之后四条全走索引(生产快照实测单条 18448 → 537
// buffers;同一个快照上整个接口查一个班 120~250ms → 10ms 上下),多这一次往返是赚的。
const matched = username ? await matchedUsers(username) : []
if (username) {
const matchedIds = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失,
// 「查无此班」会变成「全站统计」
filters.push(
matchedIds.length
? inArray(schema.submission.userId, matchedIds)
: sql`false`,
)
}
const where = and(...filters)
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
const rosterRows = matched.filter(
(row) => !row.isDisabled && row.adminType === "Regular User",
)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
/**
* **** AC
*
* 1
*/
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const [[totals], perUser] = await Promise.all([
db
.select({
total: count(),
accepted: acceptedFilter.mapWith(Number),
judging: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.where(where),
db
.select({
userId: schema.submission.userId,
/**
* **** user join submission.username
*
*
* user 退
* personCount
*/
username: sql<string>`coalesce(${schema.user.username}, max(${schema.submission.username}))`,
className: schema.user.className,
// 不传用户名时「交了没全对」那一栏靠它把教师和禁用账号挡在外面 ——
// 传了用户名时这件事是花名册(rosterRows)做的
isDisabled: schema.user.isDisabled,
adminType: schema.user.adminType,
submissionCount: count(),
acceptedCount: acceptedFilter.mapWith(Number),
solvedCount: solvedFilter.mapWith(Number),
judgingCount: judgingFilter.mapWith(Number),
})
.from(schema.submission)
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(where)
// user_id 定了 user 那一行就定了,把 username / class_name 一起放进 group by
// 不会多分出组来,但省掉再对它们套一层聚合函数
.groupBy(
schema.submission.userId,
schema.user.username,
schema.user.className,
schema.user.isDisabled,
schema.user.adminType,
)
.orderBy(desc(count())),
])
const submissionCount = totals?.total ?? 0
const acceptedCount = totals?.accepted ?? 0
const judgingCount = totals?.judging ?? 0
// 正确率的分母是**判完的条数**,不是总条数
const judgedCount = submissionCount - judgingCount
/**
* ****
*
* `solvedCount`
*
* `solvedCount >= 1` `acceptedCount > 0`
* 退
*/
const requiredSolved = scope.problemCount
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
requiredSolved > 0
? row.solvedCount >= requiredSolved
: row.acceptedCount > 0
/**
* **西**`done`
*
*
* GET /submissions/statistics/items
*
* `done` `data.length`
*/
const doneCount = perUser.filter(isDone).length
// 要等 perUser 回来才能查,所以进不了上面那个 Promise.all
const astOnlyByUserMap = await astOnlyByUser(
where,
perUser.map((row) => row.userId),
)
const submittedUserIds = new Set(perUser.map((row) => row.userId))
const data = perUser.map((row) => ({
username: row.username,
className: row.className,
submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount,
solvedCount: row.solvedCount,
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
judgingCount: row.judgingCount,
correctRate: judgedRate(
row.acceptedCount,
row.submissionCount - row.judgingCount,
),
done: isDone(row),
}))
const dataUnaccepted = rosterRows
.filter((row) => !submittedUserIds.has(row.id))
.map((row) => ({
username: row.username,
realName: stripClassPrefix(row.username, row.className),
}))
/**
*
*
* **** dataUnaccepted
*
*
*
*
* 退
*
*
*
*/
const rosterIds = new Set(rosterRows.map((row) => row.id))
const attemptedRows = perUser.filter((row) => {
if (isDone(row)) return false
return username
? rosterIds.has(row.userId)
: !row.isDisabled && row.adminType === "Regular User"
})
const failureByUser = await lastFailureByUser(
where,
attemptedRows.map((row) => row.userId),
)
const dataAttempted = attemptedRows.map((row) => ({
username: row.username,
/**
* **** `ks251`
*
* perUser join
*/
realName: username
? stripClassPrefix(row.username, row.className)
: row.username,
submissionCount: row.submissionCount,
solvedCount: row.solvedCount,
lastFailure: failureByUser.get(row.userId) ?? null,
}))
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
// 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是
// 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。
let personCount = rosterRows.length
if (personCount && personCount < doneCount) personCount = doneCount
return success(c, {
submissionCount,
acceptedCount,
judgingCount,
correctRate: judgedRate(acceptedCount, judgedCount),
personCount,
data,
dataUnaccepted,
dataAttempted,
} satisfies SubmissionStatistics)
},
)
/**
*
*
* **** ilike `ks251`
* 沿 scope
*
*/
submissionStatisticsRoutes.get(
"/submissions/statistics/items",
requireTeacher,
async (c) => {
const username = c.req.query("username")?.trim()
if (!username)
return failure(c, 400, "invalid-request", "username is required")
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
/**
* **** user_id
* `submission.username`
*
*
* 退
*
*/
const [account] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
const identity = account
? eq(schema.submission.userId, account.id)
: eq(schema.submission.username, username)
// 多取一条,好知道是不是被截断了
// innerJoin 不会漏行:submission.problem_id 是 NOT NULL 且外键是 NO ACTION
// 题目删不掉(真要删会被外键拦住并提示改为隐藏)
const rows = await db
.select({
id: schema.submission.id,
result: schema.submission.result,
createTime: schema.submission.createTime,
problem: schema.problem.displayId,
problemTitle: schema.problem.title,
})
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.problem.id, schema.submission.problemId),
)
.where(and(...scope.filters, identity))
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
.limit(STATISTICS_ITEMS_LIMIT + 1)
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success(c, {
items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
truncated,
} satisfies SubmissionStatisticsItems)
},
)
File diff suppressed because it is too large Load Diff
+2 -6
View File
@@ -31,9 +31,7 @@ export function selfCommand(subcommand: string): string[] {
* docker/compose.dev.yml **** data/test_case cwd
* apps/api/data/
*/
export const pathBase = isCompiled
? process.cwd()
: resolve(import.meta.dir, "../../..")
export const pathBase = isCompiled ? process.cwd() : resolve(import.meta.dir, "../../..")
/**
* `0000_*.sql` + `meta/_journal.json`
@@ -50,6 +48,4 @@ export const pathBase = isCompiled
*/
export const migrationsDir =
process.env.OJ2_MIGRATIONS_DIR ??
(isCompiled
? "/usr/local/share/oj2/migrations"
: resolve(import.meta.dir, "db"))
(isCompiled ? "/usr/local/share/oj2/migrations" : resolve(import.meta.dir, "db"))
@@ -0,0 +1,271 @@
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,
solvedAt: sql<string>`min(${schema.submission.createTime})::text`,
}).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
}
-81
View File
@@ -1,81 +0,0 @@
/**
* AST target `node`
*
* bun run --filter '@oj2/api' check:ast
*
* ##
*
* `node` tree-sitter ****collectNodes
* 使 X使 X
* f-string使 f-stringjudge成通过
*
* `f_string` `format_string`
* tree-sitter-python f-string `string` `interpolation`
* 线56 target
*
* tree-sitter-*
*
*
* **** `while_loop` `for_statement`
*
*/
import { AST_NODE_TARGETS_BY_LANGUAGE } from "@oj2/contract"
import { Language, Parser } from "web-tree-sitter"
import cWasmPath from "tree-sitter-c/tree-sitter-c.wasm" with { type: "file" }
import cppWasmPath from "tree-sitter-cpp/tree-sitter-cpp.wasm" with { type: "file" }
import pythonWasmPath from "tree-sitter-python/tree-sitter-python.wasm" with { type: "file" }
import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { type: "file" }
const WASM_BY_LANGUAGE: Record<string, string> = {
C: cWasmPath,
"C++": cppWasmPath,
Python: pythonWasmPath,
}
await Parser.init({ locateFile: () => treeSitterWasmPath })
let checked = 0
const missing: Array<{ language: string; target: string; node: string }> = []
for (const [language, table] of Object.entries(AST_NODE_TARGETS_BY_LANGUAGE)) {
const wasmPath = WASM_BY_LANGUAGE[language]
if (!wasmPath) {
console.log(
`${language} 在 AST_NODE_TARGETS_BY_LANGUAGE 里,但这个脚本没有它的语法 wasm`,
)
console.log(
` 加语言时记得同步 WASM_BY_LANGUAGE 和 judge/ast.ts 的 loadLanguage`,
)
process.exit(2)
}
const loaded = await Language.load(wasmPath)
// 语法里声明过的全部节点类型名
const declared = new Set<string>()
for (let id = 0; id < loaded.nodeTypeCount; id++) {
const name = loaded.nodeTypeForId(id)
if (name) declared.add(name)
}
for (const [target, entry] of Object.entries(table)) {
checked++
if (!declared.has(entry.node))
missing.push({ language, target, node: entry.node })
}
}
console.log(`检查了 ${checked} 个 AST target 的节点类型`)
if (missing.length === 0) {
console.log("✓ 每个 target 的 node 都在对应语言的语法里真实存在")
process.exit(0)
}
for (const { language, target, node } of missing) {
console.log(`\n⚠ ${language}${target} → "${node}"`)
console.log(
` 这个节点类型在语法里不存在,规则永远失败(或永远通过),且不报错`,
)
console.log(
` 改法:在 packages/contract/src/problem.ts 把它的 node 改成语法里真实的名字`,
)
}
process.exit(1)
+9 -29
View File
@@ -16,8 +16,7 @@
*
* 200
*
* `xxxRoutes.get("字面量", …)`
* `xxxRoutes.route("字面量", 子路由)`
* `xxxRoutes.get("字面量", …)`
*
*/
@@ -64,9 +63,7 @@ export function shadows(pattern: string, target: string) {
function collect(): Route[] {
const routerFile = new Map<string, string>()
for (const file of walk(SRC)) {
for (const m of readFileSync(file, "utf8").matchAll(
/export const (\w+) = new Hono/g,
)) {
for (const m of readFileSync(file, "utf8").matchAll(/export const (\w+) = new Hono/g)) {
routerFile.set(m[1]!, file)
}
}
@@ -75,35 +72,21 @@ function collect(): Route[] {
const file = routerFile.get(router)
if (!file) return []
const text = readFileSync(file, "utf8")
// 直接注册的路由和嵌套挂载(`router.route("/", child)`)放在一起按出现位置排序:
// 子路由挂在哪个位置,它的路由就在哪个位置参与匹配
const pattern = new RegExp(
`${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"|${router}\\.route\\(\\s*"([^"]*)"\\s*,\\s*(\\w+)\\s*\\)`,
"g",
)
return [...text.matchAll(pattern)].flatMap((m) => {
if (m[4]) return routesOf(m[4], prefix + m[3]!)
return [
{
const pattern = new RegExp(`${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"`, "g")
return [...text.matchAll(pattern)].map((m) => ({
method: m[1]!.toUpperCase(),
path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/",
file: file.replace(SRC + "/", ""),
},
]
})
}))
}
// 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平
const index = readFileSync(join(SRC, "index.ts"), "utf8")
const adminIndex = readFileSync(join(SRC, "routes/admin/index.ts"), "utf8")
const adminMounts = [
...adminIndex.matchAll(/\.route\(\s*"([^"]*)"\s*,\s*(\w+)\s*\)/g),
]
const adminMounts = [...adminIndex.matchAll(/\.route\(\s*"([^"]*)"\s*,\s*(\w+)\s*\)/g)]
const all: Route[] = []
for (const m of index.matchAll(
/app\.route\(\s*"([^"]+)"\s*,\s*(\w+)\s*\)/g,
)) {
for (const m of index.matchAll(/app\.route\(\s*"([^"]+)"\s*,\s*(\w+)\s*\)/g)) {
const [, prefix, router] = m
if (router === "adminRoutes") {
for (const a of adminMounts) all.push(...routesOf(a[2]!, prefix! + a[1]!))
@@ -119,8 +102,7 @@ const hits: [Route, Route][] = []
for (let i = 0; i < routes.length; i++) {
for (let j = i + 1; j < routes.length; j++) {
if (routes[i]!.method !== routes[j]!.method) continue
if (shadows(routes[i]!.path, routes[j]!.path))
hits.push([routes[i]!, routes[j]!])
if (shadows(routes[i]!.path, routes[j]!.path)) hits.push([routes[i]!, routes[j]!])
}
}
@@ -131,9 +113,7 @@ if (hits.length === 0) {
}
for (const [first, second] of hits) {
console.log(`\n⚠ ${second.method} ${second.path} ${second.file}`)
console.log(
` 进不去:被先注册的 ${first.method} ${first.path} 吃掉(${first.file}`,
)
console.log(` 进不去:被先注册的 ${first.method} ${first.path} 吃掉(${first.file}`)
console.log(` 改法:把它挪到那条之前注册,或换一个不同形的路径`)
}
process.exit(1)
+31 -192
View File
@@ -3,11 +3,6 @@ import { eq, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { JudgeStatus, isAccepted } from "../judge/status"
import { objectValue } from "../routes/helpers"
import {
metaAchievements,
refreshUnlockedCount,
rescanAchievement,
} from "../services/achievements"
/**
* submission
@@ -21,14 +16,9 @@ import {
* problem.submission_number / accepted_number / statistic_info
* user_profile.submission_number / accepted_number / acm_problems_status
*
* `user_stat.metrics.achievement_unlocked_count`
* user_achievement
* `rescanAchievement` backfilled
* 2026-09-07 269 10 `rescanAchievement`
*
* ****acm_contest_rank submission_info
* achievement.unlock_count0010
* user_achievement services/problemset.ts
* user_achievement backfill-problemsets
*
* --apply migrate
*
@@ -54,11 +44,7 @@ type ProblemExpected = {
* contestId user_profile
*/
async function expectedProblems() {
const rows = await db.execute<{
problem_id: number
result: number
n: number
}>(sql`
const rows = await db.execute<{ problem_id: number; result: number; n: number }>(sql`
select problem_id, result, count(*)::int as n
from submission
where result not in (${UNJUDGED[0]}, ${UNJUDGED[1]})
@@ -101,11 +87,7 @@ type ProfileExpected = {
* create_time
*/
async function expectedProfiles() {
const totals = await db.execute<{
user_id: number
submissions: number
accepted: number
}>(sql`
const totals = await db.execute<{ user_id: number; submissions: number; accepted: number }>(sql`
select user_id,
count(*)::int as submissions,
count(distinct problem_id) filter (where result in (${JudgeStatus.ACCEPTED}, ${JudgeStatus.AST_CHECK_FAILED}))::int as accepted
@@ -134,11 +116,7 @@ async function expectedProfiles() {
`)
const expected = new Map<number, ProfileExpected>()
const blank = (): ProfileExpected => ({
submissionNumber: 0,
acceptedNumber: 0,
status: {},
})
const blank = (): ProfileExpected => ({ submissionNumber: 0, acceptedNumber: 0, status: {} })
for (const row of totals) {
const current = expected.get(row.user_id) ?? blank()
current.submissionNumber = row.submissions
@@ -162,9 +140,7 @@ async function expectedProfiles() {
function stable(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`
if (value && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>).sort(
([a], [b]) => (a < b ? -1 : 1),
)
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : 1))
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(",")}}`
}
return JSON.stringify(value) ?? "null"
@@ -174,111 +150,31 @@ type Diff = { label: string; field: string; before: unknown; after: unknown }
type Plan = {
diffs: Diff[]
problemFixes: { id: number; value: ProblemExpected }[]
profileFixes: {
id: number
value: ProfileExpected & { merged: Record<string, unknown> }
}[]
/** achievement_unlocked_count 不对的用户 */
unlockedCountFixes: number[]
/** 按正确计数已达标、却没持有元成就的 (用户, 元成就) */
metaGrants: { userId: number; achievementId: number }[]
}
/**
* `refreshUnlockedCount` /
* user_stat `rescanAchievement`
*/
async function unlockedCountPlan(plan: Plan) {
const [rows, metas] = await Promise.all([
db.execute<{ user_id: number; counter: unknown; actual: number }>(sql`
select s.user_id, s.metrics -> 'achievement_unlocked_count' as counter, coalesce(c.value, 0) as actual
from user_stat s
left join (
select ua.user_id, count(*)::int as value
from user_achievement ua
join achievement a on a.id = ua.achievement_id
where a.rarity <> 'platinum'
group by ua.user_id
) c on c.user_id = s.user_id
`),
metaAchievements(),
])
const holders = metas.length
? await db
.select({
userId: schema.userAchievement.userId,
achievementId: schema.userAchievement.achievementId,
})
.from(schema.userAchievement)
.where(
sql`${schema.userAchievement.achievementId} in ${metas.map((meta) => meta.id)}`,
)
: []
const held = new Set(
holders.map((row) => `${row.userId}:${row.achievementId}`),
)
for (const row of rows) {
const label = `用户 ${row.user_id}`
if (row.counter !== row.actual) {
plan.diffs.push({
label,
field: "achievement_unlocked_count",
before: row.counter ?? null,
after: row.actual,
})
plan.unlockedCountFixes.push(row.user_id)
}
for (const meta of metas) {
const met =
meta.operator === "gte"
? row.actual >= meta.threshold
: row.actual <= meta.threshold
if (!met || held.has(`${row.user_id}:${meta.id}`)) continue
plan.diffs.push({
label,
field: `成就「${meta.name}`,
before: "未发",
after: "补发",
})
plan.metaGrants.push({ userId: row.user_id, achievementId: meta.id })
}
}
profileFixes: { id: number; value: ProfileExpected & { merged: Record<string, unknown> } }[]
}
/** 只算差异,不写库。预演和落库后的复核共用它 —— 两边口径必须是同一份代码 */
async function computePlan(): Promise<Plan> {
const [problems, profiles, expectedProblem, expectedProfile] =
await Promise.all([
db
.select({
const [problems, profiles, expectedProblem, expectedProfile] = await Promise.all([
db.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
submissionNumber: schema.problem.submissionNumber,
acceptedNumber: schema.problem.acceptedNumber,
statisticInfo: schema.problem.statisticInfo,
})
.from(schema.problem),
db
.select({
}).from(schema.problem),
db.select({
id: schema.userProfile.id,
userId: schema.userProfile.userId,
submissionNumber: schema.userProfile.submissionNumber,
acceptedNumber: schema.userProfile.acceptedNumber,
acmProblemsStatus: schema.userProfile.acmProblemsStatus,
})
.from(schema.userProfile),
}).from(schema.userProfile),
expectedProblems(),
expectedProfiles(),
])
const plan: Plan = {
diffs: [],
problemFixes: [],
profileFixes: [],
unlockedCountFixes: [],
metaGrants: [],
}
const plan: Plan = { diffs: [], problemFixes: [], profileFixes: [] }
for (const problem of problems) {
const want = expectedProblem.get(problem.id) ?? {
@@ -289,30 +185,13 @@ async function computePlan(): Promise<Plan> {
const label = `题目 ${problem.displayId}(id=${problem.id})`
const rows: Diff[] = []
if (problem.submissionNumber !== want.submissionNumber) {
rows.push({
label,
field: "submission_number",
before: problem.submissionNumber,
after: want.submissionNumber,
})
rows.push({ label, field: "submission_number", before: problem.submissionNumber, after: want.submissionNumber })
}
if (problem.acceptedNumber !== want.acceptedNumber) {
rows.push({
label,
field: "accepted_number",
before: problem.acceptedNumber,
after: want.acceptedNumber,
})
rows.push({ label, field: "accepted_number", before: problem.acceptedNumber, after: want.acceptedNumber })
}
if (
stable(objectValue(problem.statisticInfo)) !== stable(want.statisticInfo)
) {
rows.push({
label,
field: "statistic_info",
before: problem.statisticInfo,
after: want.statisticInfo,
})
if (stable(objectValue(problem.statisticInfo)) !== stable(want.statisticInfo)) {
rows.push({ label, field: "statistic_info", before: problem.statisticInfo, after: want.statisticInfo })
}
if (rows.length) {
plan.diffs.push(...rows)
@@ -332,66 +211,41 @@ async function computePlan(): Promise<Plan> {
const merged: Record<string, unknown> = { ...existing }
delete merged.problems
delete merged.contest_problems
for (const [bucket, value] of Object.entries(want.status))
merged[bucket] = value
for (const [bucket, value] of Object.entries(want.status)) merged[bucket] = value
const label = `用户 ${profile.userId}`
const rows: Diff[] = []
if (profile.submissionNumber !== want.submissionNumber) {
rows.push({
label,
field: "submission_number",
before: profile.submissionNumber,
after: want.submissionNumber,
})
rows.push({ label, field: "submission_number", before: profile.submissionNumber, after: want.submissionNumber })
}
if (profile.acceptedNumber !== want.acceptedNumber) {
rows.push({
label,
field: "accepted_number",
before: profile.acceptedNumber,
after: want.acceptedNumber,
})
rows.push({ label, field: "accepted_number", before: profile.acceptedNumber, after: want.acceptedNumber })
}
if (stable(existing) !== stable(merged)) {
const keys = new Set([
...Object.keys(objectValue(existing.problems)),
...Object.keys(want.status.problems ?? {}),
])
rows.push({
label,
field: "acm_problems_status",
before: `${Object.keys(objectValue(existing.problems)).length}`,
after: `${keys.size} 题(含比赛桶重建)`,
})
const keys = new Set([...Object.keys(objectValue(existing.problems)), ...Object.keys(want.status.problems ?? {})])
rows.push({ label, field: "acm_problems_status", before: `${Object.keys(objectValue(existing.problems)).length}`, after: `${keys.size} 题(含比赛桶重建)` })
}
if (rows.length) {
plan.diffs.push(...rows)
plan.profileFixes.push({ id: profile.id, value: { ...want, merged } })
}
}
await unlockedCountPlan(plan)
return plan
}
function report(plan: Plan) {
console.log(
`发现 ${plan.diffs.length} 处不一致(题目 ${plan.problemFixes.length} 道 / 用户 ${plan.profileFixes.length} 人 / 已解锁数 ${plan.unlockedCountFixes.length} 人 / 元成就补发 ${plan.metaGrants.length} 条):`,
)
console.log(`发现 ${plan.diffs.length} 处不一致(题目 ${plan.problemFixes.length} 道 / 用户 ${plan.profileFixes.length} 人):`)
for (const diff of plan.diffs.slice(0, 40)) {
console.log(
` ${diff.label} ${diff.field}: ${JSON.stringify(diff.before)}${JSON.stringify(diff.after)}`,
)
console.log(` ${diff.label} ${diff.field}: ${JSON.stringify(diff.before)}${JSON.stringify(diff.after)}`)
}
if (plan.diffs.length > 40)
console.log(` ……另有 ${plan.diffs.length - 40}`)
if (plan.diffs.length > 40) console.log(` ……另有 ${plan.diffs.length - 40}`)
}
/** 退出码:0 = 一致或预演正常,1 = 落库后复核仍有差异 */
export async function recount(options: { apply: boolean }) {
const plan = await computePlan()
if (plan.diffs.length === 0) {
console.log("计数列与 submission / user_achievement 一致,没有要订正的。")
console.log("计数列与 submission 一致,没有要订正的。")
return 0
}
report(plan)
@@ -403,42 +257,27 @@ export async function recount(options: { apply: boolean }) {
await db.transaction(async (tx) => {
for (const fix of plan.problemFixes) {
await tx
.update(schema.problem)
.set({
await tx.update(schema.problem).set({
submissionNumber: fix.value.submissionNumber,
acceptedNumber: fix.value.acceptedNumber,
statisticInfo: fix.value.statisticInfo,
})
.where(eq(schema.problem.id, fix.id))
}).where(eq(schema.problem.id, fix.id))
}
for (const fix of plan.profileFixes) {
await tx
.update(schema.userProfile)
.set({
await tx.update(schema.userProfile).set({
submissionNumber: fix.value.submissionNumber,
acceptedNumber: fix.value.acceptedNumber,
acmProblemsStatus: fix.value.merged,
})
.where(eq(schema.userProfile.id, fix.id))
}).where(eq(schema.userProfile.id, fix.id))
}
})
// 先改计数、再补发:rescanAchievement 读的是 metrics 里的计数。
// 补发幂等(唯一键 + 冲突忽略),重跑不会重复发
const recounted = await refreshUnlockedCount(plan.unlockedCountFixes)
if (plan.metaGrants.length) {
for (const meta of await metaAchievements())
await rescanAchievement(meta.id)
}
console.log(
`\n已订正题目 ${plan.problemFixes.length} 道、用户 ${plan.profileFixes.length} 人、已解锁数 ${recounted.length} 人,补发元成就 ${plan.metaGrants.length} 条,复核中……`,
)
console.log(`\n已订正题目 ${plan.problemFixes.length} 道、用户 ${plan.profileFixes.length} 人,复核中……`)
// 复核跑的是同一份 computePlan。这里还剩差异说明口径本身有问题(不是数据脏),
// 必须让部署脚本看见非零退出码,而不是打一行字了事。
const after = await computePlan()
if (after.diffs.length === 0) {
console.log("复核通过:计数列与 submission / user_achievement 一致")
console.log("复核通过:计数列与 submission 一致")
return 0
}
console.error(`复核未通过,仍有 ${after.diffs.length} 处差异:`)
+2 -6
View File
@@ -8,9 +8,7 @@ import { db, schema } from "../db"
* raw_password
* DATABASE_URL OJ2_SEED_FORCE=true
*/
const url =
process.env.DATABASE_URL ??
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
const url = process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge"
const host = (() => {
try {
return new URL(url).hostname
@@ -95,9 +93,7 @@ async function seed(account: SeedAccount) {
})
}
console.log(
` ${account.adminType.padEnd(13)} ${user.username} / ${account.password}`,
)
console.log(` ${account.adminType.padEnd(13)} ${user.username} / ${account.password}`)
}
console.log("Seeded development logins:")
+19 -76
View File
@@ -14,85 +14,24 @@ export interface AchievementMetric {
}
export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
{
key: "accepted_count",
name: "AC 题目数",
helpText: "去重后通过的题目数量(不含比赛)",
},
{
key: "mid_ac_count",
name: "中等题 AC 数",
helpText: "去重后通过的中等难度题目数(不含比赛)",
},
{
key: "hard_ac_count",
name: "困难题 AC 数",
helpText: "去重后通过的困难题目数(不含比赛)",
},
{
key: "submission_count",
name: "提交总数",
helpText: "提交次数(不含比赛)",
},
{ key: "accepted_count", name: "AC 题目数", helpText: "去重后通过的题目数量(不含比赛)" },
{ key: "mid_ac_count", name: "中等题 AC 数", helpText: "去重后通过的中等难度题目数(不含比赛)" },
{ key: "hard_ac_count", name: "困难题 AC 数", helpText: "去重后通过的困难题目数(不含比赛)" },
{ key: "submission_count", name: "提交总数", helpText: "提交次数(不含比赛)" },
{ key: "active_days", name: "活跃天数", helpText: "有过提交的累计天数" },
{
key: "max_ac_streak_days",
name: "最长连续 AC 天数",
helpText: "连续每天至少 AC 一题的最长天数",
},
{ key: "max_ac_streak_days", name: "最长连续 AC 天数", helpText: "连续每天至少 AC 一题的最长天数" },
{ key: "languages_used", name: "使用语言数", helpText: "用过多少种编程语言" },
{
key: "contest_joined",
name: "参赛场次",
helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)",
},
{ key: "contest_joined", name: "参赛场次", helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)" },
{ key: "badge_count", name: "题单奖章数", helpText: "获得的题单奖章数量" },
{
key: "problemset_completed",
name: "完成题单数",
helpText: "完成的题单数量",
},
{
key: "first_try_ac_count",
name: "一发入魂次数",
helpText: "首次提交即通过的次数",
},
{
key: "midnight_submissions",
name: "凌晨提交次数",
helpText: "0:005:00 之间的提交次数",
},
{
key: "early_bird_submissions",
name: "早起提交次数",
helpText: "5:007:00 之间的提交次数",
},
{
key: "compile_error_count",
name: "编译错误次数",
helpText: "累计编译错误的次数",
},
{
key: "max_wa_before_ac",
name: "屡败屡战",
helpText: "单题失败最多多少次后终于通过",
},
{
key: "max_ac_in_one_day",
name: "单日最多 AC",
helpText: "一天之内最多通过多少题",
},
{
key: "max_code_lines",
name: "最长代码行数",
helpText: "提交过的最长代码有多少行",
},
{
key: "achievement_unlocked_count",
name: "已解锁成就数",
helpText: "已解锁的成就数量(不含白金档)",
meta: true,
},
{ key: "problemset_completed", name: "完成题单数", helpText: "完成的题单数量" },
{ key: "first_try_ac_count", name: "一发入魂次数", helpText: "首次提交即通过的次数" },
{ key: "midnight_submissions", name: "凌晨提交次数", helpText: "0:005:00 之间的提交次数" },
{ key: "early_bird_submissions", name: "早起提交次数", helpText: "5:007:00 之间的提交次数" },
{ key: "compile_error_count", name: "编译错误次数", helpText: "累计编译错误的次数" },
{ key: "max_wa_before_ac", name: "屡败屡战", helpText: "单题失败最多多少次后终于通过" },
{ key: "max_ac_in_one_day", name: "单日最多 AC", helpText: "一天之内最多通过多少题" },
{ key: "max_code_lines", name: "最长代码行数", helpText: "提交过的最长代码有多少行" },
{ key: "achievement_unlocked_count", name: "已解锁成就数", helpText: "已解锁的成就数量(不含白金档)", meta: true },
]
const BY_KEY = new Map(ACHIEVEMENT_METRICS.map((item) => [item.key, item]))
@@ -104,3 +43,7 @@ export function findMetric(key: string) {
export function metricName(key: string) {
return BY_KEY.get(key)?.name ?? key
}
/** 稀有度四档。乱填的值会让成就汇总接口的分档统计对不上:野值算进总数却不出现在任何一档 */
export const RARITIES = ["bronze", "silver", "gold", "platinum"] as const
export const OPERATORS = ["gte", "lte"] as const
+78 -315
View File
@@ -1,19 +1,7 @@
import {
and,
count,
countDistinct,
eq,
inArray,
isNotNull,
isNull,
ne,
notInArray,
sql,
} from "drizzle-orm"
import { and, count, countDistinct, eq, inArray, isNotNull, isNull, ne, notInArray, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
import { calendarDay, dayNumber, localHour } from "../time"
import { findMetric } from "./achievement-metrics"
import { isAccepted, JudgeStatus } from "../judge/status"
import { objectValue } from "../routes/helpers"
@@ -23,276 +11,154 @@ function numberMetric(metrics: Record<string, unknown>, key: string) {
return typeof value === "number" ? value : 0
}
async function unlockAchievements(
userId: number,
metrics: Record<string, unknown>,
onlyMeta = false,
) {
const unlocked = await db
.select({ id: schema.userAchievement.achievementId })
.from(schema.userAchievement)
function localDate(value: string) {
const date = new Date(value)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `${year}-${month}-${day}`
}
async function unlockAchievements(userId: number, metrics: Record<string, unknown>, onlyMeta = false) {
const unlocked = await db.select({ id: schema.userAchievement.achievementId }).from(schema.userAchievement)
.where(eq(schema.userAchievement.userId, userId))
const filters = [eq(schema.achievement.visible, true)]
if (unlocked.length)
filters.push(
notInArray(
schema.achievement.id,
unlocked.map((row) => row.id),
),
)
if (onlyMeta)
filters.push(eq(schema.achievement.metric, "achievement_unlocked_count"))
if (unlocked.length) filters.push(notInArray(schema.achievement.id, unlocked.map((row) => row.id)))
if (onlyMeta) filters.push(eq(schema.achievement.metric, "achievement_unlocked_count"))
else filters.push(ne(schema.achievement.metric, "achievement_unlocked_count"))
const candidates = await db
.select()
.from(schema.achievement)
.where(and(...filters))
const candidates = await db.select().from(schema.achievement).where(and(...filters))
const hits = candidates.filter((achievement) => {
const value = metrics[achievement.metric]
if (typeof value !== "number") return false
return achievement.operator === "gte"
? value >= achievement.threshold
: value <= achievement.threshold
return achievement.operator === "gte" ? value >= achievement.threshold : value <= achievement.threshold
})
if (hits.length === 0) return []
// 命中的成就一次插完,冲突忽略后 returning 回来的就是「这次真新解锁的」。
// 一个用户对同一个成就只会解锁一次,所以每个成就都恰好 +1,一条 UPDATE 就够。
const inserted = await db
.insert(schema.userAchievement)
.values(
hits.map((achievement) => ({
const inserted = await db.insert(schema.userAchievement).values(hits.map((achievement) => ({
userId,
achievementId: achievement.id,
unlockTime: new Date().toISOString(),
backfilled: false,
notified: false,
})),
)
.onConflictDoNothing({
target: [
schema.userAchievement.achievementId,
schema.userAchievement.userId,
],
})
}))).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] })
.returning({ achievementId: schema.userAchievement.achievementId })
if (inserted.length === 0) return []
const insertedIds = new Set(inserted.map((row) => row.achievementId))
await db
.update(schema.achievement)
.set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` })
await db.update(schema.achievement).set({ unlockCount: sql`${schema.achievement.unlockCount} + 1` })
.where(inArray(schema.achievement.id, [...insertedIds]))
return hits.filter((achievement) => insertedIds.has(achievement.id))
}
export async function updateAchievementsForSubmission(submissionId: string) {
const [row] = await db
.select({ submission: schema.submission, problem: schema.problem })
.from(schema.submission)
.innerJoin(
schema.problem,
eq(schema.submission.problemId, schema.problem.id),
)
.where(eq(schema.submission.id, submissionId))
.limit(1)
const [row] = await db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(eq(schema.submission.id, submissionId)).limit(1)
if (!row || row.submission.contestId !== null) return []
const priorRows = await db
.select({ result: schema.submission.result })
.from(schema.submission)
.where(
and(
const priorRows = await db.select({ result: schema.submission.result }).from(schema.submission).where(and(
eq(schema.submission.userId, row.submission.userId),
eq(schema.submission.problemId, row.submission.problemId),
isNull(schema.submission.contestId),
ne(schema.submission.id, row.submission.id),
),
)
))
const priorAccepted = priorRows.some((item) => isAccepted(item.result))
const accepted = isAccepted(row.submission.result)
const firstAc = accepted && !priorAccepted
const firstTry = accepted && priorRows.length === 0
const date = calendarDay(row.submission.createTime)
const hour = localHour(row.submission.createTime)
const date = localDate(row.submission.createTime)
const hour = new Date(row.submission.createTime).getHours()
const metrics = await db.transaction(async (tx) => {
await tx
.insert(schema.userStat)
.values({
await tx.insert(schema.userStat).values({
userId: row.submission.userId,
metrics: {},
updateTime: new Date().toISOString(),
})
.onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx
.select()
.from(schema.userStat)
.where(eq(schema.userStat.userId, row.submission.userId))
.for("update")
}).onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx.select().from(schema.userStat).where(eq(schema.userStat.userId, row.submission.userId)).for("update")
if (!stat) throw new Error("User achievement stat could not be created")
const value = objectValue(stat.metrics)
value.submission_count = numberMetric(value, "submission_count") + 1
if (firstAc) {
value.accepted_count = numberMetric(value, "accepted_count") + 1
if (row.problem.difficulty === "Mid")
value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1
if (row.problem.difficulty === "High")
value.hard_ac_count = numberMetric(value, "hard_ac_count") + 1
if (firstTry)
value.first_try_ac_count = numberMetric(value, "first_try_ac_count") + 1
value.max_wa_before_ac = Math.max(
numberMetric(value, "max_wa_before_ac"),
priorRows.length,
)
if (row.problem.difficulty === "Mid") value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1
if (row.problem.difficulty === "High") value.hard_ac_count = numberMetric(value, "hard_ac_count") + 1
if (firstTry) value.first_try_ac_count = numberMetric(value, "first_try_ac_count") + 1
value.max_wa_before_ac = Math.max(numberMetric(value, "max_wa_before_ac"), priorRows.length)
const perDay = objectValue(value._ac_per_day)
perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1
value._ac_per_day = perDay
value.max_ac_in_one_day = Math.max(
...Object.values(perDay).filter(
(item): item is number => typeof item === "number",
),
)
value.max_ac_in_one_day = Math.max(...Object.values(perDay).filter((item): item is number => typeof item === "number"))
}
const activeDates = Array.isArray(value._active_dates)
? value._active_dates.filter(
(item): item is string => typeof item === "string",
)
: []
const activeDates = Array.isArray(value._active_dates) ? value._active_dates.filter((item): item is string => typeof item === "string") : []
if (!activeDates.includes(date)) activeDates.push(date)
value._active_dates = activeDates
value.active_days = activeDates.length
if (accepted) {
const last =
typeof value._last_ac_date === "string" ? value._last_ac_date : null
const last = typeof value._last_ac_date === "string" ? value._last_ac_date : null
if (last !== date) {
// 差一天要按日历日算,不能用 Date 相减:夏令时地区相邻两天差 23/25 小时,
// 除 86400000 得到的不是 1,`=== 1` 会静默把连续打卡判成断掉。
const current =
last && dayNumber(date) - dayNumber(last) === 1
const current = last && (Date.parse(`${date}T00:00:00`) - Date.parse(`${last}T00:00:00`)) / 86_400_000 === 1
? numberMetric(value, "_current_ac_streak") + 1
: 1
value._last_ac_date = date
value._current_ac_streak = current
value.max_ac_streak_days = Math.max(
numberMetric(value, "max_ac_streak_days"),
current,
)
value.max_ac_streak_days = Math.max(numberMetric(value, "max_ac_streak_days"), current)
}
}
const languages = Array.isArray(value._languages)
? value._languages.filter(
(item): item is string => typeof item === "string",
)
: []
if (!languages.includes(row.submission.language))
languages.push(row.submission.language)
const languages = Array.isArray(value._languages) ? value._languages.filter((item): item is string => typeof item === "string") : []
if (!languages.includes(row.submission.language)) languages.push(row.submission.language)
value._languages = languages
value.languages_used = languages.length
if (hour < 5)
value.midnight_submissions =
numberMetric(value, "midnight_submissions") + 1
else if (hour < 7)
value.early_bird_submissions =
numberMetric(value, "early_bird_submissions") + 1
if (row.submission.result === JudgeStatus.COMPILE_ERROR)
value.compile_error_count = numberMetric(value, "compile_error_count") + 1
value.max_code_lines = Math.max(
numberMetric(value, "max_code_lines"),
row.submission.code.split(/\r?\n/).length,
)
await tx
.update(schema.userStat)
.set({ metrics: value, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.id, stat.id))
if (hour < 5) value.midnight_submissions = numberMetric(value, "midnight_submissions") + 1
else if (hour < 7) value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1
if (row.submission.result === JudgeStatus.COMPILE_ERROR) value.compile_error_count = numberMetric(value, "compile_error_count") + 1
value.max_code_lines = Math.max(numberMetric(value, "max_code_lines"), row.submission.code.split(/\r?\n/).length)
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() }).where(eq(schema.userStat.id, stat.id))
return value
})
const first = await unlockAchievements(row.submission.userId, metrics)
if (!first.length) return []
const [meta] = await db
.select({ value: count() })
.from(schema.userAchievement)
.innerJoin(
schema.achievement,
eq(schema.userAchievement.achievementId, schema.achievement.id),
)
.where(
and(
eq(schema.userAchievement.userId, row.submission.userId),
ne(schema.achievement.rarity, "platinum"),
),
)
const [meta] = await db.select({ value: count() }).from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(and(eq(schema.userAchievement.userId, row.submission.userId), ne(schema.achievement.rarity, "platinum")))
metrics.achievement_unlocked_count = meta?.value ?? 0
await db
.update(schema.userStat)
.set({ metrics, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.userId, row.submission.userId))
return [
...first,
...(await unlockAchievements(row.submission.userId, metrics, true)),
]
await db.update(schema.userStat).set({ metrics, updateTime: new Date().toISOString() }).where(eq(schema.userStat.userId, row.submission.userId))
return [...first, ...(await unlockAchievements(row.submission.userId, metrics, true))]
}
export async function updateAchievementsForProblemSet(userId: number) {
const [[badgeRow], [completedRow]] = await Promise.all([
db
.select({ value: count() })
.from(schema.userBadge)
.where(eq(schema.userBadge.userId, userId)),
db
.select({ value: count() })
.from(schema.problemsetProgress)
.where(
and(
db.select({ value: count() }).from(schema.userBadge).where(eq(schema.userBadge.userId, userId)),
db.select({ value: count() }).from(schema.problemsetProgress).where(and(
eq(schema.problemsetProgress.userId, userId),
eq(schema.problemsetProgress.isCompleted, true),
),
),
)),
])
const metrics = await db.transaction(async (tx) => {
await tx
.insert(schema.userStat)
.values({
await tx.insert(schema.userStat).values({
userId,
metrics: {},
updateTime: new Date().toISOString(),
})
.onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx
.select()
.from(schema.userStat)
.where(eq(schema.userStat.userId, userId))
.for("update")
.limit(1)
}).onConflictDoNothing({ target: schema.userStat.userId })
const [stat] = await tx.select().from(schema.userStat)
.where(eq(schema.userStat.userId, userId)).for("update").limit(1)
if (!stat) throw new Error("User achievement stat could not be created")
const value = objectValue(stat.metrics)
value.badge_count = badgeRow?.value ?? 0
value.problemset_completed = completedRow?.value ?? 0
await tx
.update(schema.userStat)
.set({ metrics: value, updateTime: new Date().toISOString() })
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.id, stat.id))
return value
})
const first = await unlockAchievements(userId, metrics)
if (!first.length) return []
const [meta] = await db
.select({ value: count() })
.from(schema.userAchievement)
.innerJoin(
schema.achievement,
eq(schema.userAchievement.achievementId, schema.achievement.id),
)
.where(
and(
eq(schema.userAchievement.userId, userId),
ne(schema.achievement.rarity, "platinum"),
),
)
const [meta] = await db.select({ value: count() }).from(schema.userAchievement)
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
.where(and(eq(schema.userAchievement.userId, userId), ne(schema.achievement.rarity, "platinum")))
metrics.achievement_unlocked_count = meta?.value ?? 0
await db
.update(schema.userStat)
.set({ metrics, updateTime: new Date().toISOString() })
await db.update(schema.userStat).set({ metrics, updateTime: new Date().toISOString() })
.where(eq(schema.userStat.userId, userId))
return [...first, ...(await unlockAchievements(userId, metrics, true))]
}
@@ -309,39 +175,22 @@ const USER_ACHIEVEMENT_INSERT_CHUNK = 1000
*
*/
export async function rescanAchievement(achievementId: number) {
const [achievement] = await db
.select()
.from(schema.achievement)
.where(
and(
eq(schema.achievement.id, achievementId),
eq(schema.achievement.visible, true),
),
)
.limit(1)
const [achievement] = await db.select().from(schema.achievement)
.where(and(eq(schema.achievement.id, achievementId), eq(schema.achievement.visible, true))).limit(1)
if (!achievement) return { scanned: 0, unlocked: 0 }
const metric = findMetric(achievement.metric)
if (!metric) return { scanned: 0, unlocked: 0 }
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值)
if (achievement.metric === "contest_joined")
await refreshContestJoinedForAll()
if (achievement.metric === "contest_joined") await refreshContestJoinedForAll()
const already = new Set(
(
await db
.select({ userId: schema.userAchievement.userId })
.from(schema.userAchievement)
.where(eq(schema.userAchievement.achievementId, achievement.id))
).map((row) => row.userId),
(await db.select({ userId: schema.userAchievement.userId }).from(schema.userAchievement)
.where(eq(schema.userAchievement.achievementId, achievement.id))).map((row) => row.userId),
)
const stats = await db
.select({
userId: schema.userStat.userId,
metrics: schema.userStat.metrics,
})
const stats = await db.select({ userId: schema.userStat.userId, metrics: schema.userStat.metrics })
.from(schema.userStat)
const eligible = stats.filter((stat) => {
if (already.has(stat.userId)) return false
@@ -357,116 +206,36 @@ export async function rescanAchievement(achievementId: number) {
// 计数改成一次 +N,通知照旧逐人推(那是 Redis,不是数据库)。
const unlockTime = new Date().toISOString()
const unlockedUserIds: number[] = []
for (
let start = 0;
start < eligible.length;
start += USER_ACHIEVEMENT_INSERT_CHUNK
) {
for (let start = 0; start < eligible.length; start += USER_ACHIEVEMENT_INSERT_CHUNK) {
const chunk = eligible.slice(start, start + USER_ACHIEVEMENT_INSERT_CHUNK)
const inserted = await db
.insert(schema.userAchievement)
.values(
chunk.map((stat) => ({
const inserted = await db.insert(schema.userAchievement).values(chunk.map((stat) => ({
userId: stat.userId,
achievementId: achievement.id,
unlockTime,
backfilled: true,
notified: false,
})),
)
.onConflictDoNothing({
target: [
schema.userAchievement.achievementId,
schema.userAchievement.userId,
],
})
}))).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] })
.returning({ userId: schema.userAchievement.userId })
unlockedUserIds.push(...inserted.map((row) => row.userId))
}
if (unlockedUserIds.length) {
await db
.update(schema.achievement)
.set({
unlockCount: sql`${schema.achievement.unlockCount} + ${unlockedUserIds.length}`,
})
await db.update(schema.achievement)
.set({ unlockCount: sql`${schema.achievement.unlockCount} + ${unlockedUserIds.length}` })
.where(eq(schema.achievement.id, achievement.id))
for (const userId of unlockedUserIds) {
await publishAchievementNotification(userId, [
{
await publishAchievementNotification(userId, [{
id: achievement.id,
name: achievement.name,
description: achievement.description,
icon: achievement.icon,
rarity: achievement.rarity,
kind: "achievement",
},
])
}
// 补发的非白金成就同样计入「已解锁数」,要和判题结算一样接着做第二轮(元成就)判定。
// 旧 `rescan_achievement` 就漏了这步,OJ2 原样搬过来:2026-09-07 一次补发之后
// 269 人的计数停在旧值,其中 10 人实际够了「奖杯收藏家」却一直没发 ——
// 判题结算只在「这次有新解锁」时才重算,被补发的人不再解锁新成就就永远不会自愈。
if (
achievement.rarity !== "platinum" &&
achievement.metric !== "achievement_unlocked_count"
) {
await refreshUnlockedCount(unlockedUserIds)
for (const meta of await metaAchievements())
await rescanAchievement(meta.id)
}])
}
}
return { scanned: stats.length, unlocked: unlockedUserIds.length }
}
/** 以「已解锁数」为指标的元成就(奖杯收藏家)。只取上架的,和 rescan 的口径一致 */
export function metaAchievements() {
return db
.select({
id: schema.achievement.id,
name: schema.achievement.name,
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"),
),
)
}
/**
* `user_achievement` `achievement_unlocked_count` id
*
* **** `jsonb_set`
* `metrics`
* `userIds` `user_stat` `recount`
*/
export async function refreshUnlockedCount(userIds?: number[]) {
if (userIds && userIds.length === 0) return []
const scope = userIds ? sql`and s.user_id in ${userIds}` : sql``
const rows = await db.execute<{ user_id: number }>(sql`
update ${schema.userStat} as target
set metrics = jsonb_set(target.metrics, '{achievement_unlocked_count}', to_jsonb(fresh.value))
from (
select s.id, coalesce(c.value, 0) as value
from ${schema.userStat} s
left join (
select ua.user_id, count(*)::int as value
from ${schema.userAchievement} ua
join ${schema.achievement} a on a.id = ua.achievement_id
where a.rarity <> 'platinum'
group by ua.user_id
) c on c.user_id = s.user_id
where true ${scope}
) fresh
where target.id = fresh.id
and (target.metrics -> 'achievement_unlocked_count') is distinct from to_jsonb(fresh.value)
returning target.user_id
`)
return rows.map((row) => row.user_id)
}
/** 同上,3 个参数一行 */
const STAT_UPSERT_CHUNK = 1000
@@ -488,25 +257,19 @@ const STAT_UPSERT_CHUNK = 1000
*/
async function refreshContestJoinedForAll() {
const rows = await db
.select({
userId: schema.submission.userId,
value: countDistinct(schema.submission.contestId),
})
.select({ userId: schema.submission.userId, value: countDistinct(schema.submission.contestId) })
.from(schema.submission)
.where(isNotNull(schema.submission.contestId))
.groupBy(schema.submission.userId)
const now = new Date().toISOString()
for (let start = 0; start < rows.length; start += STAT_UPSERT_CHUNK) {
const chunk = rows.slice(start, start + STAT_UPSERT_CHUNK)
await db
.insert(schema.userStat)
.values(
chunk.map((row) => ({
await db.insert(schema.userStat)
.values(chunk.map((row) => ({
userId: row.userId,
metrics: { contest_joined: row.value },
updateTime: now,
})),
)
})))
.onConflictDoUpdate({
target: schema.userStat.userId,
set: {
+20 -84
View File
@@ -5,15 +5,13 @@ interface ChatMessage {
content: string
}
function requestBody(messages: ChatMessage[], stream: boolean, json = false) {
function requestBody(messages: ChatMessage[], stream: boolean) {
return {
model: config.aiModel,
messages,
stream,
temperature: 0,
thinking: { type: "disabled" },
// DeepSeek 的 JSON 模式:保证回的是合法 JSON,但 prompt 里得出现「json」字样
...(json ? { response_format: { type: "json_object" } } : {}),
}
}
@@ -24,99 +22,47 @@ function requestBody(messages: ChatMessage[], stream: boolean, json = false) {
*/
const COMPLETE_TIMEOUT_MS = 60_000
export async function completeChat(
system: string,
user: string,
options: { json?: boolean; timeoutMs?: number } = {},
) {
export async function completeChat(system: string, user: string) {
if (!config.aiKey) throw new Error("缺少 AI_KEY")
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
method: "POST",
signal: AbortSignal.timeout(options.timeoutMs ?? COMPLETE_TIMEOUT_MS),
headers: {
"content-type": "application/json",
authorization: `Bearer ${config.aiKey}`,
},
body: JSON.stringify(
requestBody(
[
signal: AbortSignal.timeout(COMPLETE_TIMEOUT_MS),
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
body: JSON.stringify(requestBody([
{ role: "system", content: system },
{ role: "user", content: user },
],
false,
options.json,
),
),
], false)),
})
if (!response.ok)
throw new Error(
`AI provider returned HTTP ${response.status}: ${await response.text()}`,
)
const payload = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>
}
if (!response.ok) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }> }
return payload.choices?.[0]?.message?.content?.trim() ?? ""
}
export interface StreamChatHooks {
/**
* ** `done` **
* 西 ai_hint id
*/
onComplete?: (value: string) => Promise<Record<string, unknown> | void>
/**
* AI_KEYprovider
*
*/
onError?: (message: string) => Promise<void>
}
export function streamChat(
system: string,
user: string,
hooks: StreamChatHooks = {},
onComplete?: (value: string) => Promise<void>,
) {
const encoder = new TextEncoder()
const reportError = (message: string) =>
hooks.onError?.(message).catch((error) => {
console.error("streamChat onError hook failed", error)
})
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (value: string) => controller.enqueue(encoder.encode(value))
if (!config.aiKey) {
await reportError("缺少 AI_KEY")
send(
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
)
send(`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`)
send("event: end\n\n")
controller.close()
return
}
try {
const response = await fetch(
new URL("/chat/completions", config.aiBaseUrl),
{
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${config.aiKey}`,
},
body: JSON.stringify(
requestBody(
[
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
body: JSON.stringify(requestBody([
{ role: "system", content: system },
{ role: "user", content: user },
],
true,
),
),
},
)
if (!response.ok || !response.body)
throw new Error(
`AI provider returned HTTP ${response.status}: ${await response.text()}`,
)
], true)),
})
if (!response.ok || !response.body) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
send("event: start\n\n")
const reader = response.body.getReader()
const decoder = new TextDecoder()
@@ -133,12 +79,7 @@ export function streamChat(
const data = line.slice(5).trim()
if (data === "[DONE]") continue
try {
const item = JSON.parse(data) as {
choices?: Array<{
delta?: { content?: string }
finish_reason?: string | null
}>
}
const item = JSON.parse(data) as { choices?: Array<{ delta?: { content?: string }; finish_reason?: string | null }> }
const choice = item.choices?.[0]
const content = choice?.delta?.content
if (content) {
@@ -152,15 +93,10 @@ export function streamChat(
if (done) break
}
const full = chunks.join("").trim()
const extra = hooks.onComplete
? await hooks.onComplete(full)
: undefined
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
if (onComplete) await onComplete(full)
send(`data: ${JSON.stringify({ type: "done" })}\n\n`)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
// 先留痕再回前端:客户端已经断开的话下面这个 send 自己也会抛
await reportError(message)
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
send(`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`)
} finally {
send("event: end\n\n")
controller.close()
+15 -56
View File
@@ -29,40 +29,23 @@ export function contestStatus(contest: ContestRow) {
return "0" as const
}
export function isContestAdmin(
user: AuthUser | null | undefined,
contest: ContestRow,
) {
return Boolean(
user &&
(user.id === contest.createdById || user.adminType === "Super Admin"),
)
export function isContestAdmin(user: AuthUser | null | undefined, contest: ContestRow) {
return Boolean(user && (user.id === contest.createdById || user.adminType === "Super Admin"))
}
export function contestDetailsAllowed(
user: AuthUser | null | undefined,
contest: ContestRow,
) {
export function contestDetailsAllowed(user: AuthUser | null | undefined, contest: ContestRow) {
return contestStatus(contest) === "-1" || isContestAdmin(user, contest)
}
export function checkContestPassword(
candidate: string | null | undefined,
expected: string | null,
) {
export function checkContestPassword(candidate: string | null | undefined, expected: string | null) {
if (!candidate || !expected) return false
if (candidate === expected) return true
const parts = candidate.split("#")
if (parts.length !== 2) return false
const [signature, expiresAt] = parts
if (!signature || !expiresAt || !/^\d+$/.test(expiresAt)) return false
const expectedSignature = createHash("sha256")
.update(`${expected}${expiresAt}`)
.digest("hex")
.slice(0, 8)
return (
signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
)
const expectedSignature = createHash("sha256").update(`${expected}${expiresAt}`).digest("hex").slice(0, 8)
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
}
/**
@@ -75,15 +58,9 @@ export function checkContestPassword(
*
* 404
*/
export async function findAccessibleContest(
user: AuthUser | null | undefined,
id: number,
) {
const [contest] = await db
.select()
.from(schema.contest)
.where(eq(schema.contest.id, id))
.limit(1)
export async function findAccessibleContest(user: AuthUser | null | undefined, id: number) {
const [contest] = await db.select().from(schema.contest)
.where(eq(schema.contest.id, id)).limit(1)
if (!contest) return null
return contest.visible || isContestAdmin(user, contest) ? contest : null
}
@@ -96,25 +73,16 @@ export async function canAccessContest<E extends AppEnv>(
checkType: "details" | "problems" | "ranks" | "submissions",
) {
const user = c.get("user")
if (!user)
return { ok: false as const, code: "login-required", message: "请先登录" }
if (!user) return { ok: false as const, code: "login-required", message: "请先登录" }
if (isContestAdmin(user, contest)) return { ok: true as const }
if (contest.password) {
const stored = await getContestPassword(c, contest.id)
if (!checkContestPassword(stored, contest.password)) {
return {
ok: false as const,
code: "wrong-password",
message: "Wrong password or password expired",
}
return { ok: false as const, code: "wrong-password", message: "Wrong password or password expired" }
}
}
if (contestStatus(contest) === "1" && checkType !== "details") {
return {
ok: false as const,
code: "contest-not-started",
message: "Contest has not started yet.",
}
return { ok: false as const, code: "contest-not-started", message: "Contest has not started yet." }
}
return { ok: true as const }
}
@@ -136,20 +104,11 @@ export function requireContestAccess(
): MiddlewareHandler<ContestEnv> {
return async (c, next) => {
const id = Number(c.req.param(paramName))
const contest =
Number.isInteger(id) && id > 0
? await findAccessibleContest(c.get("user"), id)
: null
if (!contest)
return failure(c, 404, "contest-not-found", "Contest does not exist")
const contest = Number.isInteger(id) && id > 0 ? await findAccessibleContest(c.get("user"), id) : null
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, checkType)
if (!access.ok) {
return failure(
c,
access.code === "login-required" ? 401 : 403,
access.code,
access.message,
)
return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message)
}
c.set("contest", contest)
await next()
+7 -23
View File
@@ -34,8 +34,7 @@ export function exerciseDataError(
case "mcq": {
const options = strings(data.options)
if (options.length < 2) return "选择题至少要有 2 个选项"
if (options.some((option) => !option.trim()))
return "选择题的选项不能为空"
if (options.some((option) => !option.trim())) return "选择题的选项不能为空"
return indexAnswerError(data.answer, options.length, "正确答案")
}
case "sort": {
@@ -54,19 +53,12 @@ export function exerciseDataError(
case "match": {
const left = strings(data.left)
const right = strings(data.right)
if (left.length < 2 || right.length < 2)
return "连线题左右两列各至少 2 项"
if (left.length < 2 || right.length < 2) return "连线题左右两列各至少 2 项"
if (left.length !== right.length) return "连线题左右两列的行数必须相等"
return indexAnswerError(
data.answer,
right.length,
"连线答案",
left.length,
)
return indexAnswerError(data.answer, right.length, "连线答案", left.length)
}
case "predict": {
if (!(typeof data.code === "string" && data.code.trim()))
return "输出预测题的代码不能为空"
if (!(typeof data.code === "string" && data.code.trim())) return "输出预测题的代码不能为空"
if (strings(data.answer).filter((item) => item.trim()).length === 0) {
return "输出预测题至少要有一个正确输出"
}
@@ -83,13 +75,7 @@ export function exerciseDataError(
if (buckets.length < 2) return "归类题至少要有 2 个分组"
if (items.length === 0) return "归类题至少要有一个项目"
// 归类题的下标**允许重复**:好几个项目落在同一个分组是常态,别顺手加去重
return indexAnswerError(
data.answer,
buckets.length,
"归类答案",
items.length,
false,
)
return indexAnswerError(data.answer, buckets.length, "归类答案", items.length, false)
}
}
}
@@ -136,9 +122,7 @@ function indexAnswerError(
? `请至少勾选一个${label}`
: `${label}的条数(${answer.length})和项目数(${length})对不上`
}
if (answer.some((item) => item < 0 || item >= bound))
return `${label}的下标越界`
if (unique && new Set(answer).size !== answer.length)
return `${label}里有重复的下标`
if (answer.some((item) => item < 0 || item >= bound)) return `${label}的下标越界`
if (unique && new Set(answer).size !== answer.length) return `${label}里有重复的下标`
return null
}
+3 -11
View File
@@ -32,8 +32,7 @@ async function runFormatter(command: string[], code: string) {
}
function formatSql(code: string) {
return (
code
return code
.split(";")
.map((statement) => statement.trim())
.filter(Boolean)
@@ -44,13 +43,9 @@ function formatSql(code: string) {
),
)
.join(";\n\n") + (code.trim().endsWith(";") ? ";" : "")
)
}
export async function formatCode(
code: string,
language: "python" | "c" | "cpp" | "sql",
) {
export async function formatCode(code: string, language: "python" | "c" | "cpp" | "sql") {
if (language === "sql") return formatSql(code)
if (language === "python") {
@@ -59,10 +54,7 @@ export async function formatCode(
code,
)
if (result.exitCode !== 0) {
throw new CodeFormatError(
result.stderr || "Invalid Python syntax",
"syntax",
)
throw new CodeFormatError(result.stderr || "Invalid Python syntax", "syntax")
}
return result.stdout
}
-231
View File
@@ -1,231 +0,0 @@
import { readFile } from "node:fs/promises"
import { resolve } from "node:path"
import {
HINT_ERROR_TAGS,
hintDiagnosisSchema,
type HintDiagnosis,
} from "@oj2/contract"
import { and, desc, eq, isNotNull } from "drizzle-orm"
import { config } from "../config"
import { db, schema } from "../db"
import { JudgeStatus, judgeStatusName } from "../judge/status"
import { objectValue } from "../routes/helpers"
import { completeChat } from "./ai"
import { readInfo } from "./test-case"
/**
* AI prompt AI OJ 2b
*
* ****
* prompt
* system
*
* 1. ****
* `hintDiagnosisSchema` + + safeParse
*
* 2. **** X
* ab
*
* JSON退 prompt
*/
type HintRow = {
submission: typeof schema.submission.$inferSelect
problem: typeof schema.problem.$inferSelect
}
/**
* prompt ai_hint.prompt_version****
* 1 2026-09-19 线
*/
export const HINT_PROMPT_SINGLE = 1
export const HINT_PROMPT_DIAGNOSED = 2
/** 诊断这一段让学生干等着(提示还没开始流),超时就退回单段式,别让按钮一直转 */
const DIAGNOSE_TIMEOUT_MS = 20_000
/** 喂给诊断的测试点输入 / 期望输出各截多少字符。入门题的测试点绝大多数很短 */
const CASE_EXCERPT = 600
const SINGLE_SYSTEM =
"你是编程助教。指出学生代码最关键的一个问题,循序渐进地提示,绝不直接给出核心算法或完整解法。输入读取错误可以直接给出正确片段。使用 Markdown,不超过6句话。"
function errInfo(row: HintRow) {
return String(objectValue(row.submission.statisticInfo).err_info ?? "无")
}
/** 带行号的代码,诊断回的行号和第二段里说的「第几行」都以它为准 */
function numbered(code: string) {
return code
.split("\n")
.map((line, index) => `${String(index + 1).padStart(3)}| ${line}`)
.join("\n")
}
/** 同语言的标准答案优先;没有就拿别的语言的(思路一样,照样能帮诊断);再没有就 null */
function referenceAnswer(row: HintRow) {
const answers = Array.isArray(row.problem.answers)
? row.problem.answers.map((item) => objectValue(item))
: []
const usable = answers.filter(
(item): item is { language: string; code: string } =>
typeof item.language === "string" &&
typeof item.code === "string" &&
item.code.trim() !== "",
)
return (
usable.find((item) => item.language === row.submission.language) ??
usable[0] ??
null
)
}
/**
* ****
* output nullSQL info
* null
*/
async function firstFailedCase(row: HintRow) {
if (row.submission.language === "SQL") return null
const data = objectValue(row.submission.info).data
if (!Array.isArray(data)) return null
const failed = data
.map((item) => objectValue(item))
.find((item) => typeof item.result === "number" && item.result !== 0)
if (!failed || typeof failed.test_case !== "string") return null
try {
const info = await readInfo(row.problem.testCaseId)
const entry = info?.test_cases?.[failed.test_case]
if (!entry) return null
const directory = resolve(config.testCaseDirectory, row.problem.testCaseId)
const [input, output] = await Promise.all([
readFile(resolve(directory, entry.input_name), "utf8"),
readFile(resolve(directory, entry.output_name), "utf8"),
])
return {
index: failed.test_case,
input: input.slice(0, CASE_EXCERPT),
output: output.slice(0, CASE_EXCERPT),
}
} catch {
return null
}
}
const DIAGNOSE_SYSTEM = `你是编程教学的诊断器,只负责给学生代码的错误归类,不和学生对话。
json
{"tag": "<错误类型>", "lines": [, ] null, "confidence": "high" "low"}
tag key
${Object.entries(HINT_ERROR_TAGS)
.map(([key, label]) => `- ${key}${label}`)
.join("\n")}
lines null
`
async function diagnose(
row: HintRow,
): Promise<{ diagnosis: HintDiagnosis } | { error: string }> {
const answer = referenceAnswer(row)
const failedCase = await firstFailedCase(row)
const code = row.submission.code.slice(0, 4000)
const prompt = [
`题目:${row.problem.title}`,
`描述:${row.problem.description.slice(0, 2000)}`,
answer
? `标准答案(${answer.language}):\n${answer.code.slice(0, 3000)}`
: "标准答案:无",
failedCase
? `第一个没通过的测试点(#${failedCase.index}\n输入:\n${failedCase.input}\n期望输出:\n${failedCase.output}`
: "没通过的测试点:无",
`判题结果:${judgeStatusName(row.submission.result)}`,
`报错:${errInfo(row)}`,
`学生代码(${row.submission.language}):\n${numbered(code)}`,
].join("\n\n")
let raw: string
try {
raw = await completeChat(DIAGNOSE_SYSTEM, prompt, {
json: true,
timeoutMs: DIAGNOSE_TIMEOUT_MS,
})
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) }
}
let value: unknown
try {
value = JSON.parse(raw)
} catch {
return { error: `诊断回的不是 JSON${raw.slice(0, 200)}` }
}
const parsed = hintDiagnosisSchema.safeParse(value)
if (!parsed.success)
return {
error: `诊断校验不过:${parsed.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join("; ")}`,
}
// 行号越界或倒过来不算整个诊断失败:类型往往还是对的,只把行号丢掉
const lineCount = code.split("\n").length
const lines = parsed.data.lines
const linesOk =
lines !== null && lines[0] <= lines[1] && lines[1] <= lineCount
return { diagnosis: { ...parsed.data, lines: linesOk ? lines : null } }
}
/**
*
*
* - /
*
* -
*/
export async function hintDiagnosis(row: HintRow): Promise<{
diagnosis: HintDiagnosis | null
error: string | null
}> {
if (
!config.aiHintDiagnose ||
row.submission.result === JudgeStatus.COMPILE_ERROR
)
return { diagnosis: null, error: null }
const [previous] = await db
.select({ diagnosis: schema.aiHint.diagnosis })
.from(schema.aiHint)
.where(
and(
eq(schema.aiHint.submissionId, row.submission.id),
isNotNull(schema.aiHint.diagnosis),
),
)
.orderBy(desc(schema.aiHint.id))
.limit(1)
if (previous?.diagnosis) return { diagnosis: previous.diagnosis, error: null }
const result = await diagnose(row)
return "diagnosis" in result
? { diagnosis: result.diagnosis, error: null }
: { diagnosis: null, error: result.error }
}
/** 第二段(生成提示)的 prompt。**这里永远不放标准答案和测试点原文**,理由见文件头 */
export function hintPrompt(row: HintRow, diagnosis: HintDiagnosis | null) {
if (!diagnosis) {
// 单段式,2026-09-19 起的基线,一个字都别改(要改就换版本号,见上)
const prompt = `题目:${row.problem.title}\n描述:${row.problem.description.slice(0, 2000)}\n语言:${row.submission.language}\n结果:${judgeStatusName(row.submission.result)}\n错误:${errInfo(row)}\n代码:${row.submission.code.slice(0, 2000)}`
return { system: SINGLE_SYSTEM, prompt, version: HINT_PROMPT_SINGLE }
}
const where = diagnosis.lines
? diagnosis.lines[0] === diagnosis.lines[1]
? `,大约在第 ${diagnosis.lines[0]}`
: `,大约在第 ${diagnosis.lines[0]}${diagnosis.lines[1]}`
: ""
const system = `${SINGLE_SYSTEM}\n问题已经定位好了,会在「问题定位」里给出,围绕它来提示。把握低时换个方式问学生,别说得太肯定。不要提到「诊断」「定位」这些说法。`
const prompt = [
`题目:${row.problem.title}`,
`描述:${row.problem.description.slice(0, 2000)}`,
`语言:${row.submission.language}`,
`结果:${judgeStatusName(row.submission.result)}`,
`错误:${errInfo(row)}`,
`问题定位:${HINT_ERROR_TAGS[diagnosis.tag]}${where}(把握:${diagnosis.confidence === "high" ? "高" : "低"}`,
`代码:\n${numbered(row.submission.code.slice(0, 2000))}`,
].join("\n")
return { system, prompt, version: HINT_PROMPT_DIAGNOSED }
}
+3 -11
View File
@@ -15,22 +15,14 @@ export const websiteOptionDefaults = {
export async function getOptions<const T extends readonly string[]>(keys: T) {
const rows = await db
.select({
key: schema.optionsSysoptions.key,
value: schema.optionsSysoptions.value,
})
.select({ key: schema.optionsSysoptions.key, value: schema.optionsSysoptions.value })
.from(schema.optionsSysoptions)
.where(inArray(schema.optionsSysoptions.key, [...keys]))
return Object.fromEntries(rows.map((row) => [row.key, row.value])) as Record<
T[number],
unknown
>
return Object.fromEntries(rows.map((row) => [row.key, row.value])) as Record<T[number], unknown>
}
export async function getWebsiteOptions() {
const keys = Object.keys(websiteOptionDefaults) as Array<
keyof typeof websiteOptionDefaults
>
const keys = Object.keys(websiteOptionDefaults) as Array<keyof typeof websiteOptionDefaults>
const values = await getOptions(keys)
return Object.fromEntries(
keys.map((key) => [key, values[key] ?? websiteOptionDefaults[key]]),
+61 -124
View File
@@ -6,13 +6,8 @@ import { objectValue } from "../routes/helpers"
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
type BadgeCheck = Pick<
ProgressRow,
| "completedProblemsCount"
| "totalProblemsCount"
| "totalScore"
| "progressDetail"
>
type BadgeCheck = Pick<ProgressRow,
"completedProblemsCount" | "totalProblemsCount" | "totalScore" | "progressDetail">
/**
*
@@ -29,9 +24,7 @@ export function computeProgress(
previousCompleteTime: string | null,
now = new Date().toISOString(),
) {
const scoreByProblem = new Map(
links.map((link) => [String(link.problemId), link.score]),
)
const scoreByProblem = new Map(links.map((link) => [String(link.problemId), link.score]))
// 已经移出题单的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多
const kept: Record<string, unknown> = {}
let totalScore = 0
@@ -51,9 +44,7 @@ export function computeProgress(
const required = links.filter((link) => link.isRequired)
const graded = required.length ? required : links
const gradedKeys = new Set(graded.map((link) => String(link.problemId)))
const completed = Object.keys(kept).filter((key) =>
gradedKeys.has(key),
).length
const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length
const total = graded.length
// total > 0 这个前提不能省:0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」,
// 还会写下 complete_time、计进「完成题单数」成就,而且后面补上题目也不会自愈。
@@ -64,8 +55,7 @@ export function computeProgress(
completedProblemsCount: completed,
totalScore,
// 乘 10000 四舍五入再除 100,保留两位小数
progressPercentage:
total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
progressPercentage: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
isCompleted,
// 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。
//
@@ -88,8 +78,7 @@ async function writeProgress(rows: ProgressWrite[]) {
for (let start = 0; start < rows.length; start += 1000) {
const chunk = rows.slice(start, start + 1000)
const values = sql.join(
chunk.map(
(row) => sql`(
chunk.map((row) => sql`(
${row.id}::bigint,
${JSON.stringify(row.progressDetail)}::jsonb,
${row.totalProblemsCount}::int,
@@ -98,8 +87,7 @@ async function writeProgress(rows: ProgressWrite[]) {
${row.progressPercentage}::double precision,
${row.isCompleted}::boolean,
${row.completeTime}::timestamptz
)`,
),
)`),
sql`, `,
)
await db.execute(sql`
@@ -130,19 +118,13 @@ async function writeProgress(rows: ProgressWrite[]) {
*/
export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
if (badge.conditionType === "all_problems") {
return (
progress.totalProblemsCount > 0 &&
return progress.totalProblemsCount > 0 &&
progress.completedProblemsCount === progress.totalProblemsCount
)
}
if (badge.conditionType === "problem_count") {
return (
Object.keys(objectValue(progress.progressDetail)).length >=
badge.conditionValue
)
return Object.keys(objectValue(progress.progressDetail)).length >= badge.conditionValue
}
if (badge.conditionType === "score")
return progress.totalScore >= badge.conditionValue
if (badge.conditionType === "score") return progress.totalScore >= badge.conditionValue
return false
}
@@ -154,45 +136,26 @@ export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
* `known`
*
*/
export async function recalculateBadge(
badge: BadgeRow,
known?: (BadgeCheck & { userId: number })[],
) {
const progresses =
known ??
(await db
.select()
.from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId)))
const eligibleIds = progresses
.filter((item) => eligibleForBadge(badge, item))
.map((item) => item.userId)
export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & { userId: number })[]) {
const progresses = known ?? await db.select().from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId))
const eligibleIds = progresses.filter((item) => eligibleForBadge(badge, item)).map((item) => item.userId)
await db.transaction(async (tx) => {
await tx
.delete(schema.userBadge)
.where(
and(
await tx.delete(schema.userBadge).where(and(
eq(schema.userBadge.badgeId, badge.id),
eligibleIds.length
? notInArray(schema.userBadge.userId, eligibleIds)
: undefined,
),
)
eligibleIds.length ? notInArray(schema.userBadge.userId, eligibleIds) : undefined,
))
if (!eligibleIds.length) return
const existing = await tx
.select({ userId: schema.userBadge.userId })
.from(schema.userBadge)
const existing = await tx.select({ userId: schema.userBadge.userId }).from(schema.userBadge)
.where(eq(schema.userBadge.badgeId, badge.id))
const have = new Set(existing.map((item) => item.userId))
const missing = eligibleIds.filter((id) => !have.has(id))
if (missing.length) {
await tx.insert(schema.userBadge).values(
missing.map((userId) => ({
await tx.insert(schema.userBadge).values(missing.map((userId) => ({
userId,
badgeId: badge.id,
earnedTime: new Date().toISOString(),
})),
)
})))
}
})
}
@@ -211,37 +174,43 @@ export async function recalculateBadge(
*/
export async function resyncProgress(problemsetId: number) {
const [links, progresses, badges] = await Promise.all([
db
.select({
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)
}).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)
db.select().from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, problemsetId)),
])
const now = new Date().toISOString()
const updated = progresses.map((progress) => ({
...progress,
...computeProgress(
objectValue(progress.progressDetail),
links,
progress.completeTime,
now,
),
...computeProgress(objectValue(progress.progressDetail), links, progress.completeTime, now),
}))
if (updated.length) await writeProgress(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,
}
}
/**
*
*
@@ -253,6 +222,8 @@ export async function resyncProgress(problemsetId: number) {
*
*
* visible / status
* scripts/backfill-problemsets.ts
*
*/
export async function recordSolvedProblem(
userId: number,
@@ -263,93 +234,59 @@ export async function recordSolvedProblem(
const joined = await db
.select({ problemsetId: schema.problemsetProgress.problemsetId })
.from(schema.problemsetProgress)
.innerJoin(
schema.problemsetProblem,
and(
eq(
schema.problemsetProblem.problemsetId,
schema.problemsetProgress.problemsetId,
),
.innerJoin(schema.problemsetProblem, and(
eq(schema.problemsetProblem.problemsetId, schema.problemsetProgress.problemsetId),
eq(schema.problemsetProblem.problemId, problemId),
),
)
))
.where(eq(schema.problemsetProgress.userId, userId))
const earned: BadgeRow[] = []
let updated = 0
for (const { problemsetId } of joined) {
const hits = await db.transaction(async (tx) => {
const [progress] = await tx
.select()
.from(schema.problemsetProgress)
.where(
and(
const [progress] = await tx.select().from(schema.problemsetProgress).where(and(
eq(schema.problemsetProgress.problemsetId, problemsetId),
eq(schema.problemsetProgress.userId, userId),
),
)
.for("update")
.limit(1)
)).for("update").limit(1)
if (!progress) return []
// 提交记录先补上,即使这道题早就记过 —— 老数据里有记了进度没记提交的行
const [existing] = await tx
.select({ id: schema.problemsetSubmission.id })
.from(schema.problemsetSubmission)
.where(
and(
const [existing] = await tx.select({ id: schema.problemsetSubmission.id })
.from(schema.problemsetSubmission).where(and(
eq(schema.problemsetSubmission.problemsetId, problemsetId),
eq(schema.problemsetSubmission.userId, userId),
eq(schema.problemsetSubmission.problemId, problemId),
),
)
.limit(1)
)).limit(1)
if (!existing) {
await tx
.insert(schema.problemsetSubmission)
await tx.insert(schema.problemsetSubmission)
.values({ problemsetId, userId, submissionId, problemId })
}
const detail = objectValue(progress.progressDetail)
if (String(problemId) in detail) return []
const links = await tx
.select({
const links = await tx.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
})
.from(schema.problemsetProblem)
}).from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, problemsetId))
const link = links.find((item) => item.problemId === problemId)
if (!link) return []
detail[String(problemId)] = { score: link.score, submit_time: solvedAt }
const update = computeProgress(detail, links, progress.completeTime)
await tx
.update(schema.problemsetProgress)
.set(update)
await tx.update(schema.problemsetProgress).set(update)
.where(eq(schema.problemsetProgress.id, progress.id))
updated += 1
const badges = await tx
.select()
.from(schema.problemsetBadge)
const badges = await tx.select().from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, problemsetId))
const eligible = badges.filter((badge) =>
eligibleForBadge(badge, { ...progress, ...update }),
)
const eligible = badges.filter((badge) => eligibleForBadge(badge, { ...progress, ...update }))
if (eligible.length === 0) return []
// 达标的奖章一次插完,冲突忽略后 returning 回来的就是这次真拿到的
const inserted = await tx
.insert(schema.userBadge)
.values(
eligible.map((badge) => ({
const inserted = await tx.insert(schema.userBadge).values(eligible.map((badge) => ({
userId,
badgeId: badge.id,
earnedTime: new Date().toISOString(),
})),
)
.onConflictDoNothing({
target: [schema.userBadge.badgeId, schema.userBadge.userId],
})
}))).onConflictDoNothing({ target: [schema.userBadge.badgeId, schema.userBadge.userId] })
.returning({ badgeId: schema.userBadge.badgeId })
const ids = new Set(inserted.map((row) => row.badgeId))
return eligible.filter((badge) => ids.has(badge.id))
+1 -4
View File
@@ -3,10 +3,7 @@ import { and, eq } from "drizzle-orm"
import { db, schema } from "../db"
export async function getUserProfileById(
userId: number,
showRealName: boolean,
) {
export async function getUserProfileById(userId: number, showRealName: boolean) {
const [row] = await db
.select({ profile: schema.userProfile, user: schema.user })
.from(schema.userProfile)
+10 -30
View File
@@ -95,20 +95,13 @@ export async function processTestCaseZip(
// 只按「精确文件名」取内容,不遍历压缩包里的条目 ——
// 条目名一律不参与路径拼接,zip slip`../../etc/passwd` 这类条目名)从设计上就进不来。
const names = new Set(
Object.keys(files).filter((name) => /^\d+\.(in|out|sql)$/.test(name)),
)
const names = new Set(Object.keys(files).filter((name) => /^\d+\.(in|out|sql)$/.test(name)))
const selected = options.sql
? collectSqlScripts(names)
: collectPairs(names).flat()
if (selected.length === 0)
throw new TestCaseError("压缩包里没有找到从 1 开始连续编号的测试点")
const selected = options.sql ? collectSqlScripts(names) : collectPairs(names).flat()
if (selected.length === 0) throw new TestCaseError("压缩包里没有找到从 1 开始连续编号的测试点")
if (options.sql && selected.length < 2) {
// 题目页会展示测试点 1 的期望结果,只有一个测试点时学生可以对照着硬编码 AC
throw new TestCaseError(
"SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果",
)
throw new TestCaseError("SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果")
}
let total = 0
@@ -116,16 +109,12 @@ export async function processTestCaseZip(
for (const name of selected) {
const raw = files[name]!
if (raw.length > MAX_ENTRY_BYTES) {
throw new TestCaseError(
`测试点 ${name} 超过 ${MAX_ENTRY_BYTES / 1024 / 1024}MB`,
)
throw new TestCaseError(`测试点 ${name} 超过 ${MAX_ENTRY_BYTES / 1024 / 1024}MB`)
}
const content = normalizeNewlines(raw)
total += content.length
if (total > MAX_TOTAL_BYTES) {
throw new TestCaseError(
`测试点总大小超过 ${MAX_TOTAL_BYTES / 1024 / 1024}MB`,
)
throw new TestCaseError(`测试点总大小超过 ${MAX_TOTAL_BYTES / 1024 / 1024}MB`)
}
contents.set(name, content)
}
@@ -160,9 +149,7 @@ export async function processTestCaseZip(
collectPairs(names).forEach(([input, output], index) => {
const outputContent = contents.get(output)!
const entry: TestCaseEntry = {
stripped_output_md5: createHash("md5")
.update(rstrip(outputContent))
.digest("hex"),
stripped_output_md5: createHash("md5").update(rstrip(outputContent)).digest("hex"),
input_size: contents.get(input)!.length,
output_size: outputContent.length,
input_name: input,
@@ -192,9 +179,7 @@ export async function packTestCaseZip(testCaseId: string) {
throw new TestCaseError("Test case does not exists")
}
const names = new Set(entries)
const isSql = await readInfo(testCaseId)
.then((info) => Boolean(info?.sql))
.catch(() => false)
const isSql = await readInfo(testCaseId).then((info) => Boolean(info?.sql)).catch(() => false)
const selected = isSql ? collectSqlScripts(names) : collectPairs(names).flat()
const bundle: Record<string, Uint8Array> = {}
for (const name of [...selected, "info"]) {
@@ -222,10 +207,7 @@ export async function readSqlScripts(testCaseId: string) {
const names = collectSqlScripts(new Set(await readdir(directory)))
const scripts: { name: string; content: string }[] = []
for (const name of names) {
scripts.push({
name,
content: await readFile(resolve(directory, name), "utf8"),
})
scripts.push({ name, content: await readFile(resolve(directory, name), "utf8") })
}
return scripts
}
@@ -234,7 +216,5 @@ function randomId() {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join(
"",
)
return Array.from(bytes, (value) => alphabet[value % alphabet.length]).join("")
}
+6 -19
View File
@@ -81,17 +81,11 @@ type ThrottleRedis = typeof redis & {
): Promise<[number, string]>
}
function parseBucketConfig(
value: unknown,
fallback: BucketConfig,
): BucketConfig {
if (!value || typeof value !== "object" || Array.isArray(value))
return fallback
function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback
const raw = value as Record<string, unknown>
const pick = (key: keyof BucketConfig) =>
typeof raw[key] === "number" &&
Number.isFinite(raw[key]) &&
(raw[key] as number) > 0
typeof raw[key] === "number" && Number.isFinite(raw[key]) && (raw[key] as number) > 0
? (raw[key] as number)
: fallback[key]
return {
@@ -112,10 +106,7 @@ function parseBucketConfig(
* `throttling`
*/
const BUCKET_CACHE_TTL = 60_000
const bucketCache = new Map<
"user",
{ value: BucketConfig; expiresAt: number }
>()
const bucketCache = new Map<"user", { value: BucketConfig; expiresAt: number }>()
export async function getBucketConfig(scope: "user"): Promise<BucketConfig> {
const cached = bucketCache.get(scope)
@@ -126,13 +117,9 @@ export async function getBucketConfig(scope: "user"): Promise<BucketConfig> {
try {
const values = await getOptions(["throttling"])
const throttling = values.throttling
value =
!throttling || typeof throttling !== "object" || Array.isArray(throttling)
value = !throttling || typeof throttling !== "object" || Array.isArray(throttling)
? fallback
: parseBucketConfig(
(throttling as Record<string, unknown>)[scope],
fallback,
)
: parseBucketConfig((throttling as Record<string, unknown>)[scope], fallback)
} catch {
// 读不到就退回默认值,但**不写缓存** —— 数据库抖一下不该让接下来一整分钟
// 全站都按默认参数限流
+8 -38
View File
@@ -22,44 +22,14 @@ const STOPWORDS = new Set(
)
const CUSTOM_WORDS = [
"循环结构",
"条件判断",
"判断条件",
"结束条件",
"循环条件",
"异常处理",
"边界条件",
"输入输出",
"输入验证",
"开始结束",
"结束节点",
"开始节点",
"判断节点",
"流程走向",
"逻辑错误",
"逻辑缺陷",
"逻辑不清",
"缺少分支",
"缺少步骤",
"缺少判断",
"缺少循环",
"死循环",
"无限循环",
"循环出口",
"循环体",
"条件分支",
"分支结构",
"分支不全",
"分支缺失",
"符号使用",
"符号不规范",
"连线混乱",
"变量初始化",
"赋值操作",
"累加操作",
"终止条件",
"退出条件",
"返回值",
"循环结构", "条件判断", "判断条件", "结束条件", "循环条件",
"异常处理", "边界条件", "输入输出", "输入验证", "开始结束",
"结束节点", "开始节点", "判断节点", "流程走向", "逻辑错误",
"逻辑缺陷", "逻辑不清", "缺少分支", "缺少步骤", "缺少判断",
"缺少循环", "死循环", "无限循环", "循环出口", "循环体",
"条件分支", "分支结构", "分支不全", "分支缺失", "符号使用",
"符号不规范", "连线混乱", "变量初始化", "赋值操作", "累加操作",
"终止条件", "退出条件", "返回值",
]
/**
-114
View File
@@ -1,114 +0,0 @@
import { TIME_ZONE, TIME_ZONE_OFFSET_MINUTES } from "@oj2/contract"
import { sql, type SQLWrapper } from "drizzle-orm"
/**
* `TIME_ZONE`
*
* ** / / **SQL
* `localTime()` `new Date(x).getHours()` / `setHours(0,0,0,0)` /
* `getFullYear()` / `new Date(y, m, d)` ****
* UTC
*/
export { TIME_ZONE }
const OFFSET_MS = TIME_ZONE_OFFSET_MINUTES * 60_000
const DAY_MS = 86_400_000
/**
* `getUTC*`
* UTC API `fromWallClock`
*/
function toWallClock(value: Date | number | string = new Date()): Date {
return new Date(new Date(value).getTime() + OFFSET_MS)
}
function fromWallClock(wall: Date): Date {
return new Date(wall.getTime() - OFFSET_MS)
}
/** 北京时间的日历日,形如 `2026-09-14` */
export function calendarDay(
value: Date | number | string = new Date(),
): string {
return toWallClock(value).toISOString().slice(0, 10)
}
/** 北京时间的钟点,023 */
export function localHour(value: Date | number | string = new Date()): number {
return toWallClock(value).getUTCHours()
}
/** 北京时间的年份 */
export function localYear(value: Date | number | string = new Date()): number {
return toWallClock(value).getUTCFullYear()
}
/**
* 1970-01-01 0
*
* Date
* 23 25 86400000 1`=== 1`
*/
export function dayNumber(day: string): number {
const [year, month, date] = day.split("-").map(Number)
return Date.UTC(year!, month! - 1, date!) / DAY_MS
}
/** 日历日序号 → `YYYY-MM-DD` */
export function dayText(day: number): string {
return new Date(day * DAY_MS).toISOString().slice(0, 10)
}
/** 日历日序号是周几,0 = 周日(和 `Date#getDay()` 同一套编号) */
export function localWeekday(day: number): number {
return new Date(day * DAY_MS).getUTCDay()
}
/** 「东八区今天」的零点,返回 ISO 字符串。提交列表、流程图列表的 `?today=1` 和后台「今日提交数」用它 */
export function todayStart(now: Date | number | string = new Date()): string {
return new Date(
dayNumber(calendarDay(now)) * DAY_MS - OFFSET_MS,
).toISOString()
}
/**
* ISO
*
* `localWeekday` 0 `Date#getDay()`
* 0 7
* `weekday - 1`
*/
export function weekStart(now: Date | number | string = new Date()): string {
const today = dayNumber(calendarDay(now))
const weekday = localWeekday(today) || 7
return new Date((today - (weekday - 1)) * DAY_MS - OFFSET_MS).toISOString()
}
/** 按北京时间的日历做月份平移,日号超出目标月长度时截到月末,时分秒毫秒原样保留 */
export function shiftMonthsByCalendar(instant: Date, months: number): Date {
const wall = toWallClock(instant)
const date = wall.getUTCDate()
wall.setUTCDate(1)
wall.setUTCMonth(wall.getUTCMonth() + months)
const lastDay = new Date(
Date.UTC(wall.getUTCFullYear(), wall.getUTCMonth() + 1, 0),
).getUTCDate()
wall.setUTCDate(Math.min(date, lastDay))
return fromWallClock(wall)
}
/**
* SQL **** select group by
* PG
* must appear in the GROUP BY clause
*/
const TIME_ZONE_SQL = sql.raw(`'${TIME_ZONE}'`)
/**
* `timestamptz` `timestamp` `extract(hour from …)` /
* `date(…)` SQL select group by
*
*/
export function localTime(column: SQLWrapper) {
return sql`(${column} at time zone ${TIME_ZONE_SQL})`
}
+2 -3
View File
@@ -54,9 +54,8 @@ export async function withBuiltinDict(): Promise<JiebaInstance> {
with: { type: "file" },
})
).default as unknown as string
const dictPath = (
await import("@node-rs/jieba/dict.txt", { with: { type: "file" } })
).default as unknown as string
const dictPath = (await import("@node-rs/jieba/dict.txt", { with: { type: "file" } }))
.default as unknown as string
let addon: { Jieba: { withDict(dict: Buffer): JiebaInstance } }
try {
+11 -49
View File
@@ -114,10 +114,7 @@ function consume(bucket: RateBucket, burst: number, refillPerSecond: number) {
/** 文本帧:严格档。会查库,走这一档的都按最坏情况算 */
function allowMessage(ws: Bun.ServerWebSocket<SubmissionSocketData>) {
const bucket = (ws.data.rate ??= {
tokens: RATE_BURST,
updatedAt: Date.now(),
})
const bucket = (ws.data.rate ??= { tokens: RATE_BURST, updatedAt: Date.now() })
return consume(bucket, RATE_BURST, RATE_REFILL_PER_SECOND)
}
@@ -216,10 +213,7 @@ export function submissionWebSocketHandler(): Bun.WebSocketHandler<SubmissionSoc
liveSockets.add(ws)
ws.data.rate = { tokens: RATE_BURST, updatedAt: Date.now() }
if (ws.data.kind === "collab") {
ws.data.binaryRate = {
tokens: COLLAB_BINARY_BURST,
updatedAt: Date.now(),
}
ws.data.binaryRate = { tokens: COLLAB_BINARY_BURST, updatedAt: Date.now() }
handleCollabOpen(ws)
return
}
@@ -298,10 +292,7 @@ async function handleMessage(
ws.send(JSON.stringify({ type: "pong", timestamp: message.timestamp }))
return
}
if (
message.type !== "subscribe" ||
typeof message.submissionId !== "string"
) {
if (message.type !== "subscribe" || typeof message.submissionId !== "string") {
ws.send(JSON.stringify({ type: "error", message: "Invalid message" }))
return
}
@@ -345,43 +336,19 @@ async function handleMessage(
if (!submission) {
const [flowchart] = await db
.select({
id: schema.flowchartSubmission.id,
status: schema.flowchartSubmission.status,
score: schema.flowchartSubmission.aiScore,
grade: schema.flowchartSubmission.aiGrade,
})
.select({ id: schema.flowchartSubmission.id, status: schema.flowchartSubmission.status, score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade })
.from(schema.flowchartSubmission)
.where(
and(
eq(schema.flowchartSubmission.id, message.submissionId),
eq(schema.flowchartSubmission.userId, ws.data.userId),
),
)
.where(and(eq(schema.flowchartSubmission.id, message.submissionId), eq(schema.flowchartSubmission.userId, ws.data.userId)))
.limit(1)
if (!flowchart) {
ws.send(
JSON.stringify({ type: "error", message: "Submission not found" }),
)
ws.send(JSON.stringify({ type: "error", message: "Submission not found" }))
return
}
const replay =
flowchart.status === 2
? {
type: "flowchart_evaluation_completed" as const,
submissionId: flowchart.id,
score: flowchart.score ?? undefined,
grade: flowchart.grade ?? undefined,
}
const replay = flowchart.status === 2
? { type: "flowchart_evaluation_completed" as const, submissionId: flowchart.id, score: flowchart.score ?? undefined, grade: flowchart.grade ?? undefined }
: flowchart.status === 3
? {
type: "flowchart_evaluation_failed" as const,
submissionId: flowchart.id,
}
: {
type: "flowchart_evaluation_update" as const,
submissionId: flowchart.id,
}
? { type: "flowchart_evaluation_failed" as const, submissionId: flowchart.id }
: { type: "flowchart_evaluation_update" as const, submissionId: flowchart.id }
ws.send(JSON.stringify(replay satisfies FlowchartUpdate))
return
}
@@ -440,12 +407,7 @@ export async function bridgeSubmissionEvents(
const [activeUser] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(
and(
eq(schema.user.id, event.userId),
eq(schema.user.isDisabled, false),
),
)
.where(and(eq(schema.user.id, event.userId), eq(schema.user.isDisabled, false)))
.limit(1)
if (!activeUser) return
server.publish(topic, JSON.stringify(event.data))
+5 -15
View File
@@ -20,8 +20,7 @@ const flowchartWorker = new Worker<FlowchartJobData>(
flowchartQueueName,
// attemptsMade 是「此前已经失败过几次」,当前这次还没计进去,
// 所以最后一次尝试的判据是 attemptsMade + 1 >= attempts
async (job) =>
evaluateFlowchart(job.data, {
async (job) => evaluateFlowchart(job.data, {
isFinalAttempt: job.attemptsMade + 1 >= (job.opts.attempts ?? 1),
}),
{ connection: createBlockingRedis(), concurrency: 2 },
@@ -39,24 +38,15 @@ worker.on("failed", async (job, error) => {
try {
await failAbandonedSubmission(submissionId, error)
} catch (markError) {
console.error(
`Failed to mark submission ${submissionId} as system error`,
markError,
)
console.error(`Failed to mark submission ${submissionId} as system error`, markError)
}
})
worker.on("error", (error) => {
console.error("Judge worker error", error)
})
flowchartWorker.on("ready", () =>
console.log("Flowchart worker ready (concurrency=2)"),
)
flowchartWorker.on("failed", (job, error) =>
console.error(`Flowchart job ${job?.id ?? "unknown"} failed`, error),
)
flowchartWorker.on("error", (error) =>
console.error("Flowchart worker error", error),
)
flowchartWorker.on("ready", () => console.log("Flowchart worker ready (concurrency=2)"))
flowchartWorker.on("failed", (job, error) => console.error(`Flowchart job ${job?.id ?? "unknown"} failed`, error))
flowchartWorker.on("error", (error) => console.error("Flowchart worker error", error))
async function shutdown() {
await worker.close()
+14 -38
View File
@@ -8,10 +8,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
`ojnext``../OnlineJudge` 都已下线且**完全冻结,一行都不改**)。Vue 3 + TypeScript
ViteRolldown 内核)、Naive UI、Pinia、Vue Router。
**浏览器基线是 Chrome 105**(机房部分电脑那一档,2026-09-16 从 < 94 上调):
`vite.config.ts``@vitejs/plugin-legacy` 和写死的 polyfill 清单不能删 —— vite 8
默认 target 是 chrome111,比机房高。`mermaid-legacy` 那条 < 94 的 fallback 已删除。
理由写在该文件的注释里,详见 `../CLAUDE.md`
**要兼容机房的老 Chrome< 94**`vite.config.ts` 的 legacy 配置与
`mermaid-legacy` 等 fallback 依赖不能动,理由写在该文件的注释里。
## Commands
@@ -22,15 +20,14 @@ ViteRolldown 内核)、Naive UI、Pinia、Vue Router。
bun run dev # 只起前端 dev server5173),后端得另外起
bun run type-check # 类型检查。改完 .vue / .ts 必须跑这个
bun run build # 生产构建
bun run fmt # Prettier
```
⚠️ **验证只认 `bun run type-check`。** `vue-tsc --noEmit -p tsconfig.json` 会**静默
通过**——那个 tsconfig 是 `files: []` + references 的壳,真正的配置在
`tsconfig.app.json`0.2 秒跑完就是没在检查的信号);`vite build` 也不做类型检查。
不写测试(沿用项目约定),验证靠实跑。lint 只有 Prettier**脚本在仓库根目录**
`cd ../.. && bun run fmt`,一把把后端、契约、前端全格式化)—— 前端这边原来那个
只管 `apps/web``fmt` 已经删掉,配置也收到了根目录的 `.prettierrc.toml`
不写测试(沿用项目约定),验证靠实跑。lint 只有 Prettier
## Architecture
@@ -58,8 +55,8 @@ API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/ap
跨端的(登录、资料、标签、验证码)在 `shared/api.ts`
Shared logic lives in `shared/`:
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `screenMode` (problem split-screen layout), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room), `achievement` (解锁弹窗队列), `myFlowchart` (流程图弹窗的 mermaid 源码)
- `composables/``pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `darkTransition` (View Transitions111 以下走降级分支), `hiddenStudents` (统计面板的「请假隐藏」), `chartTheme`, `breakpoints`, `maxkb`, `learnProgress`, `rarity`
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `screenMode` (problem split-screen layout), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room)
- `composables/``pagination` (URL-synced), `websocket` (reconnect + heartbeat), `collabDoc` (Yjs binding for the collab channel), `configUpdate` (WS-pushed config sync), `useMermaid` (lazy Mermaid render), `breakpoints`, `maxkb`
- `layout/``default.vue` and `admin.vue` layout wrappers
- `api.ts` — shared API calls (auth, profile, tags, captcha)
@@ -130,48 +127,27 @@ return contract("GET /problems/:id", problemDetailSchema, value)
一样会渲染错。收紧任何字段之前,拿根目录那份生产备份把全量数据跑一遍,
尤其要看**空值**而不只是键集合。
### 时间一律按东八区展示,不跟浏览器走
**显示时间走 `utils/functions.ts``parseTime()`;要日历部件走 `zonedParts()` /
`zonedYear()`。** 不要在组件里写 `new Date(x).getFullYear()` / `getMonth()` /
`getDate()` / `toLocaleDateString()` / `toLocaleTimeString()` —— 那些取的是
**浏览器本地**时区。机房电脑、学生手机平时都在东八区所以看不出来,但只要有人
(比如时区没设对的机房机器、或在外地的老师)从别的时区打开,同一张提交记录表就会
显示成另一个时间,和榜单、统计、成就里的日期对不上。
时区常量在契约 `@oj2/contract``TIME_ZONE_OFFSET_MINUTES`,和后端 `time.ts` 共用。
实现是「平移固定偏移 + 读 `getUTC*`」,不用 `Intl` 的时区选项:东八区没有夏令时,
纯算术在表格里逐格调用也不费事,和后端 `time.ts` 算得一模一样。
**`n-date-picker` 要平移**`admin/contest/detail.vue``admin/problemset/edit.vue`):
Naive 的日期选择器按浏览器本地时区渲染、没有 `timezone` 属性,所以绑定值走
`toPickerValue()`,取回来走 `fromPickerValue()`。显示时间不要用这对函数。
### Key Utilities
- `utils/constants.ts` — Judge status codes, language IDs, difficulty levels, contest types
- `utils/types.ts` — 契约类型的派生与前端专有收窄(不是手写的一份平行类型)
- `utils/contract.ts` — 运行时契约闸门,见上
- `utils/functions.ts``parseTime` / `zonedParts` / `zonedYear`(东八区时间口径,见上)、
`duration`、压缩与剪贴板等杂项
- `utils/judge.ts` — Judge-related utilities
- `utils/renders.ts` — Table column render helpers for Naive UI DataTable
### Environment Variables
Variables prefixed with `PUBLIC_` are injected at build time,声明在 `src/env.d.ts`
Env files: `.env`(本机)、`.env.production`(服务器)、`.env.staging` / `.env.test`(机房)。
Variables prefixed with `PUBLIC_` are injected at build time. Env files: `.env`, `.env.staging`, `.env.test`.
| Variable | Purpose |
|---|---|
| `PUBLIC_ENV` | 环境角标:`test` → 「测试版」,`dev` → 「开发版」,其余不显示 |
| `PUBLIC_CODE_URL` | 代码分享服务(提交详情、题目页的「分享」) |
| `PUBLIC_JUDGE0_URL` | Judge0 API`utils/judge.ts` 的在线运行) |
| `PUBLIC_MAXKB_URL` | 知识库问答挂件 |
| `PUBLIC_ICONIFY_URL` | 自建 Iconify 图标源,不设则走公共 CDN |
后端地址**不在这里**`utils/api.ts` 写死 `baseURL: "/api"`dev 由 `vite.config.ts`
proxy 转给 3000,线上由 Caddy 同源伺服。(原来的 `PUBLIC_OJ_URL` / `PUBLIC_WS_URL` 早已不存在。)
| `PUBLIC_OJ_URL` | Backend REST API base URL |
| `PUBLIC_WS_URL` | WebSocket server URL |
| `PUBLIC_ENV` | Environment name (dev/staging/production) |
| `PUBLIC_CODE_URL` | Code execution service |
| `PUBLIC_JUDGE0_URL` | Judge0 API |
| `PUBLIC_MAXKB_URL` | Knowledge base service |
| `PUBLIC_ICONIFY_URL` | Iconify icon CDN |
### Routing
+3
View File
@@ -8,6 +8,7 @@
"build": "vite build",
"build:staging": "vite build --mode staging",
"build:test": "vite build --mode test",
"fmt": "prettier --write src *.ts",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json"
},
"dependencies": {
@@ -42,6 +43,7 @@
"lib0": "0.2.117",
"md-editor-v3": "^6.5.6",
"mermaid": "^11.17.2",
"mermaid-legacy": "npm:mermaid@^9.4.3",
"naive-ui": "^2.45.2",
"nanoid": "^6.0.1",
"normalize.css": "^8.0.1",
@@ -63,6 +65,7 @@
"@vitejs/plugin-legacy": "^8.2.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"prettier": "^3.9.6",
"unplugin-auto-import": "^21.1.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.2.2",
+11 -8
View File
@@ -94,7 +94,9 @@ export function editProblem(problem: AdminProblem | BlankProblem) {
}
export function toggleProblemVisible(problemID: number) {
return api.put<{ visible: boolean }>(`admin/problems/${problemID}/visibility`)
return api.put<{ visible: boolean }>(
`admin/problems/${problemID}/visibility`,
)
}
export function generateFlowchartFromPythonCode(python: string) {
@@ -133,11 +135,7 @@ export function batchTagProblems(
}
// 用户排名(后台版,无 100 名上限;公开榜单是 oj/api.ts 的 getRank
export function getAdminUserRank(
offset: number,
limit: number,
keyword: string,
) {
export function getAdminUserRank(offset: number, limit: number, keyword: string) {
return api.get<AdminUserRank>("admin/rankings/users", {
params: { offset, limit, keyword },
})
@@ -238,7 +236,9 @@ export function previewSQLTestcase(data: {
// 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序)
export function getSQLTestcaseScripts(problemId: number) {
return api.get<SqlTestCaseScript[]>(`admin/problems/${problemId}/sql-scripts`)
return api.get<SqlTestCaseScript[]>(
`admin/problems/${problemId}/sql-scripts`,
)
}
// AI 根据标准答案生成一个 SQL 测试点初始化脚本
@@ -412,7 +412,10 @@ export function createTutorial(data: Partial<Tutorial>) {
}
export function updateTutorial(data: Partial<Tutorial>) {
return api.put<Tutorial>(`admin/tutorials/${data.id}`, toTutorialBody(data))
return api.put<Tutorial>(
`admin/tutorials/${data.id}`,
toTutorialBody(data),
)
}
export function deleteTutorial(id: number) {
+7 -9
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { formatISO } from "date-fns"
import TextEditor from "shared/components/TextEditor.vue"
import { fromPickerValue, parseTime, toPickerValue } from "utils/functions"
import { parseTime } from "utils/functions"
import type { BlankContest } from "utils/types"
import { createContest, editContest, getContest } from "../api"
@@ -27,15 +27,13 @@ watch([waitMins, durationMins], () => {
contest.endTime = formatISO(times[1])
})
// ref n-date-picker utils/functions.ts
// toPickerValue
//
//
const startTime = ref(0)
const endTime = ref(0)
watch([startTime, endTime], (values) => {
contest.startTime = formatISO(fromPickerValue(values[0]))
contest.endTime = formatISO(fromPickerValue(values[1]))
contest.startTime = formatISO(values[0])
contest.endTime = formatISO(values[1])
})
const route = useRoute()
@@ -81,9 +79,9 @@ async function getContestDetail() {
contest.password = data.password
contest.visible = data.visible
//
startTime.value = toPickerValue(Date.parse(data.startTime))
endTime.value = toPickerValue(Date.parse(data.endTime))
//
startTime.value = Date.parse(data.startTime)
endTime.value = Date.parse(data.endTime)
}
async function submit() {
+9 -209
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
import { NProgress, NTag, NText } from "naive-ui"
import { NProgress, NText } from "naive-ui"
import {
getLearnStudents,
getLearnTutorials,
@@ -28,8 +28,6 @@ const type = ref<"python" | "c">("python")
// 3-4 1-2 classFilter
const className = ref("")
const tab = ref("students")
// /
const keyword = ref("")
const loading = ref(false)
const students = ref<LearnStudentProgress[]>([])
@@ -46,92 +44,10 @@ const typeOptions = [
{ label: "C 语言", value: "c" },
]
type StudentStatus = "idle" | "stalled" | "noPractice" | "going" | "done"
const STALL_DAYS = 7
const STATUS_META: Record<
StudentStatus,
{ label: string; type: "default" | "error" | "warning" | "info" | "success" }
> = {
idle: { label: "未开始", type: "error" },
stalled: { label: `${STALL_DAYS} 天没学`, type: "warning" },
noPractice: { label: "只读不练", type: "info" },
going: { label: "进行中", type: "default" },
done: { label: "已学完", type: "success" },
}
//
// > > > >
function statusOf(row: LearnStudentProgress): StudentStatus {
if (row.readCount === 0 && row.totalSeconds === 0 && !row.exerciseTried) {
return "idle"
}
if (tutorialCount.value && row.readCount >= tutorialCount.value) return "done"
if (row.lastViewedAt) {
// time.ts
const days = (Date.now() - Date.parse(row.lastViewedAt)) / 86_400_000
if (days > STALL_DAYS) return "stalled"
}
if (exerciseCount.value && row.readCount > 0 && row.exerciseTried === 0) {
return "noPractice"
}
return "going"
}
const statusFilter = ref<StudentStatus | "all">("all")
const statusCounts = computed(() => {
const counts: Record<StudentStatus, number> = {
idle: 0,
stalled: 0,
noPractice: 0,
going: 0,
done: 0,
}
for (const row of students.value) counts[statusOf(row)]++
return counts
})
const startedCount = computed(
() => students.value.length - statusCounts.value.idle,
() => students.value.filter((row) => row.readCount > 0).length,
)
const avgRead = computed(() =>
students.value.length
? (
students.value.reduce((n, row) => n + row.readCount, 0) /
students.value.length
).toFixed(1)
: "0",
)
// /
const solveRate = computed(() => {
const tried = students.value.reduce((n, row) => n + row.exerciseTried, 0)
const solved = students.value.reduce((n, row) => n + row.exerciseSolved, 0)
return tried ? Math.round((solved / tried) * 100) : null
})
function lastSeen(value: string | null) {
if (!value) return "-"
const days = Math.floor((Date.now() - Date.parse(value)) / 86_400_000)
const absolute = parseTime(value, "M月D日 HH:mm")
return days >= 1 ? `${absolute}${days} 天前)` : absolute
}
//
//
const filteredStudents = computed(() => {
const value = keyword.value.trim().toLowerCase()
return students.value.filter(
(row) =>
(statusFilter.value === "all" || statusOf(row) === statusFilter.value) &&
(!value ||
row.username.toLowerCase().includes(value) ||
(row.realName ?? "").toLowerCase().includes(value)),
)
})
const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
{ title: "班级", key: "className", width: 90, sorter: "default" },
{ title: "学号", key: "username", width: 140 },
@@ -141,19 +57,6 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
width: 110,
render: (row) => row.realName || "-",
},
{
title: "状态",
key: "status",
width: 110,
render: (row) => {
const meta = STATUS_META[statusOf(row)]
return h(
NTag,
{ size: "small", type: meta.type, bordered: false },
() => meta.label,
)
},
},
{
title: `已读(共 ${tutorialCount.value} 课)`,
key: "readCount",
@@ -211,9 +114,10 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
{
title: "最后学习",
key: "lastViewedAt",
width: 210,
width: 170,
sorter: "default",
render: (row) => lastSeen(row.lastViewedAt),
render: (row) =>
row.lastViewedAt ? parseTime(row.lastViewedAt, "M月D日 HH:mm") : "-",
},
])
@@ -264,14 +168,10 @@ const tutorialColumns = computed<DataTableColumn<LearnTutorialProgress>[]>(
const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
() => [
{
type: "expand",
renderExpand: (row) =>
h(ExerciseAttempts, {
{ type: "expand", renderExpand: (row) => h(ExerciseAttempts, {
exerciseId: row.exerciseId,
className: className.value.trim(),
}),
},
}) },
{
title: "课",
key: "tutorialOrder",
@@ -292,31 +192,6 @@ const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
ellipsis: { tooltip: true },
render: (row) => row.question || "(无题干)",
},
{
//
//
title: "提示",
key: "flag",
width: 100,
render: (row) => {
if (row.triedUsers < 3) return null
if (row.firstTryUsers === 0 && row.solvedUsers > 0) {
return h(
NTag,
{ size: "small", type: "warning", bordered: false },
() => "没人一次对",
)
}
if (row.solvedUsers / row.triedUsers < 0.5) {
return h(
NTag,
{ size: "small", type: "error", bordered: false },
() => "多数人卡住",
)
}
return null
},
},
{
title: "做对 / 做过",
key: "solvedUsers",
@@ -365,7 +240,6 @@ const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
async function load() {
loading.value = true
expanded.value = []
statusFilter.value = "all"
const params = { type: type.value, className: className.value.trim() }
try {
// tab
@@ -415,90 +289,16 @@ onMounted(load)
</n-text>
<!-- 口径写在表上方免得老师对着已读 0 / 累计 25 分钟猜是不是坏了 -->
<n-text depth="3" style="font-size: 12px">
已读按累计停留满
{{ TUTORIAL_READ_SECONDS / 60 }} 分钟算不足的只计时长
已读按累计停留满 {{ TUTORIAL_READ_SECONDS / 60 }} 分钟算不足的只计时长
</n-text>
</n-flex>
<n-grid
cols="2 s:3 m:5"
:x-gap="12"
:y-gap="12"
responsive="screen"
style="margin-bottom: 16px"
>
<n-gi>
<n-card size="small" :bordered="true">
<n-statistic label="学生" :value="studentCount" />
</n-card>
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic label="已开始" :value="startedCount">
<template #suffix>/ {{ students.length }}</template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic label="人均已读课数" :value="avgRead">
<template #suffix>/ {{ tutorialCount }}</template>
</n-statistic>
</n-card>
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic
label="练一练做对率"
:value="solveRate === null ? '-' : `${solveRate}%`"
/>
</n-card>
</n-gi>
<n-gi>
<n-card size="small">
<n-statistic label="停滞(7 天没学)" :value="statusCounts.stalled">
<template #suffix></template>
</n-statistic>
</n-card>
</n-gi>
</n-grid>
<n-tabs v-model:value="tab" type="line" animated>
<n-tab-pane name="students" tab="按学生">
<n-flex align="center" style="margin-bottom: 12px">
<n-input
v-model:value="keyword"
placeholder="搜索姓名或学号"
clearable
style="width: 200px"
/>
<n-text v-if="keyword.trim()" depth="3">
找到 {{ filteredStudents.length }}
</n-text>
</n-flex>
<n-flex :size="8" style="margin-bottom: 12px">
<n-tag
checkable
:checked="statusFilter === 'all'"
@update:checked="statusFilter = 'all'"
>
全部 {{ students.length }}
</n-tag>
<n-tag
v-for="(meta, key) in STATUS_META"
:key="key"
checkable
:type="meta.type"
:checked="statusFilter === key"
@update:checked="statusFilter = statusFilter === key ? 'all' : key"
>
{{ meta.label }} {{ statusCounts[key] }}
</n-tag>
</n-flex>
<n-data-table
:loading="loading"
:columns="studentColumns"
:data="filteredStudents"
:data="students"
:row-key="(row: LearnStudentProgress) => row.userId"
striped
:pagination="{ pageSize: 20 }"
+2 -6
View File
@@ -12,7 +12,6 @@ import {
Tooltip,
} from "chart.js"
import { getTopACTrend } from "admin/api"
import { zonedYear } from "utils/functions"
ChartJS.register(
CategoryScale,
@@ -26,10 +25,7 @@ ChartJS.register(
type ProblemTrend = AcTrend
// ac-trend `localYear()`
// `new Date().getFullYear()`
// untilYear
const currentYear = zonedYear()
const currentYear = new Date().getFullYear()
const yearOptions = Array.from({ length: currentYear - 2022 + 1 }, (_, i) => ({
label: String(2022 + i),
value: 2022 + i,
@@ -41,7 +37,7 @@ const minPerYearOptions = [
]
const sinceYear = ref(2023)
const untilYear = ref(currentYear - 1)
const untilYear = ref(new Date().getFullYear() - 1)
const minPerYear = ref(100)
const loading = ref(false)
const data = ref<ProblemTrend[]>([])
@@ -16,7 +16,7 @@ const emit = defineEmits<{
(e: "update:modelValue", value: AstRules | null): void
}>()
// C / Pythonjudge/ast.ts
// C / Python3judge/ast.ts
// loadLanguage null tab
// C++
const supportedLanguages = computed(() =>
@@ -26,7 +26,7 @@ const unsupportedLanguages = computed(() =>
props.languages.filter((lang) => !AST_SUPPORTED_LANGUAGES.includes(lang)),
)
const activeTab = ref(supportedLanguages.value[0] || "Python")
const activeTab = ref(supportedLanguages.value[0] || "Python3")
const ENGINE_OPTIONS: SelectOption[] = [
{
@@ -71,17 +71,14 @@ const ENGINE_OPTIONS: SelectOption[] = [
// 使 f-string
function nodeTargetOptions(lang: string): SelectOption[] {
return Object.entries(AST_NODE_TARGETS_BY_LANGUAGE[lang] ?? {}).map(
([value, entry]) => ({ label: entry.label, value }),
([value, label]) => ({ label, value }),
)
}
// and/or/not C && / || / !
function operatorTargetOptions(lang: string): SelectOption[] {
return Object.entries(AST_OPERATOR_TARGETS_BY_LANGUAGE[lang] ?? {}).map(
([value, label]) => ({
label: label === value ? value : `${label}${value}`,
value,
}),
([value, label]) => ({ label: label === value ? value : `${label}${value}`, value }),
)
}
@@ -176,8 +173,7 @@ function getTargetLabel(
engine: string,
target: string,
): string | undefined {
if (isNodeEngine(engine))
return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]?.label
if (isNodeEngine(engine)) return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]
// labelastOperatorLabel
// label C && and
return undefined
@@ -256,8 +252,7 @@ watch(supportedLanguages, (langs) => {
:bordered="false"
style="margin-bottom: 8px"
>
{{ unsupportedLanguages.join("、") }}
暂不支持代码规则检查判题机只能检查
{{ unsupportedLanguages.join("、") }} 暂不支持代码规则检查判题机只能检查
{{ AST_SUPPORTED_LANGUAGES.join(" / ") }}
</n-alert>
<n-tabs
@@ -398,7 +393,9 @@ watch(supportedLanguages, (langs) => {
<n-empty
v-else
:description="
languages.length ? '当前语言不支持代码规则检查' : '请先选择编程语言'
languages.length
? '当前语言不支持代码规则检查'
: '请先选择编程语言'
"
/>
</n-collapse-item>
@@ -59,7 +59,9 @@ async function submit() {
props.action,
)
const verb = props.action === "add" ? "添加" : "移除"
message.success(`已为 ${res.problemCount} 道题${verb} ${res.tagCount} 个标签`)
message.success(
`已为 ${res.problemCount} 道题${verb} ${res.tagCount} 个标签`,
)
close()
emit("done")
}
@@ -47,7 +47,7 @@ function makeInitialFiles(): FileEntry[] {
const files = ref<FileEntry[]>(makeInitialFiles())
const selectedLanguage = ref<LANGUAGE>("Python")
const selectedLanguage = ref<LANGUAGE>("Python3")
//
const availableLanguages = computed(() =>
@@ -179,7 +179,8 @@ async function run() {
async function upload() {
isUploading.value = true
try {
const data = uploadable.value.flatMap((f, i) => [
const data = uploadable.value
.flatMap((f, i) => [
{ name: `${i + 1}.in`, content: f.in },
{ name: `${i + 1}.out`, content: f.out },
])
+9 -13
View File
@@ -63,7 +63,7 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
difficulty: "Low",
visible: false,
tags: [],
languages: ["Python", "C"] as LANGUAGE[],
languages: ["Python3", "C"] as LANGUAGE[],
template: {} as { [key in LANGUAGE]?: string },
samples: [
{ input: "", output: "" },
@@ -153,8 +153,8 @@ function validateNewTags(v: string[]) {
//
const [needTemplate, toggleNeedTemplate] = useToggle(false)
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
const currentActiveTemplate = ref<LANGUAGE>("Python")
const currentActiveAnswer = ref<LANGUAGE>("Python")
const currentActiveTemplate = ref<LANGUAGE>("Python3")
const currentActiveAnswer = ref<LANGUAGE>("Python3")
// TextEditor
const [ready, toggleReady] = useToggle(false)
@@ -169,7 +169,7 @@ const difficultyOptions: SelectOption[] = [
]
const languageOptions = [
{ label: LANGUAGE_SHOW_VALUE["Python"], value: "Python" },
{ label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" },
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
{ label: LANGUAGE_SHOW_VALUE["SQL"], value: "SQL" },
@@ -511,7 +511,7 @@ async function generateMermaid() {
isAIGenerating.value = true
try {
const res = await generateFlowchartFromPythonCode(
problem.value.answers.filter((a) => a.language === "Python")[0].code,
problem.value.answers.filter((a) => a.language === "Python3")[0].code,
)
problem.value.mermaidCode = res.flowchart
message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法")
@@ -736,7 +736,7 @@ watch(
>
<n-tabs
type="segment"
default-value="Python"
default-value="Python3"
v-model:value="currentActiveAnswer"
>
<n-tab-pane
@@ -760,7 +760,7 @@ watch(
<n-form-item label="编写预制代码">
<n-tabs
type="segment"
default-value="Python"
default-value="Python3"
v-model:value="currentActiveTemplate"
>
<n-tab-pane
@@ -878,7 +878,7 @@ watch(
type="primary"
size="small"
:disabled="
!problem.answers.filter((a) => a.language === 'Python')[0]?.code
!problem.answers.filter((a) => a.language === 'Python3')[0]?.code
.length
"
:loading="isAIGenerating"
@@ -896,11 +896,7 @@ watch(
v-model:value="problem.showFlowchart"
:disabled="problem.allowFlowchart"
/>
<n-text
v-if="problem.allowFlowchart"
depth="3"
style="font-size: 12px"
>
<n-text v-if="problem.allowFlowchart" depth="3" style="font-size: 12px">
让学生自己画图时标准流程图不会下发给学生这个开关没有意义
</n-text>
</n-flex>
+3 -1
View File
@@ -122,7 +122,9 @@ async function saveTag(tag: AdminTag) {
}
const res = await renameTag(tag.id, name)
if (res.merged) {
message.success(`已合并到「${res.name}」,影响 ${res.affectedCount} 道题`)
message.success(
`已合并到「${res.name}」,影响 ${res.affectedCount} 道题`,
)
} else {
message.success("已重命名")
}
+2 -7
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import type { CreateProblemSetData, EditProblemSetData } from "utils/types"
import { fromPickerValue, toPickerValue } from "utils/functions"
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
const route = useRoute()
@@ -19,15 +18,11 @@ const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
endTime: null,
})
// n-date-picker utils/functions.ts
// toPickerValue`formData.endTime` ****
const endTimeTimestamp = computed({
get: () =>
formData.value.endTime
? toPickerValue(formData.value.endTime.getTime())
: null,
formData.value.endTime ? new Date(formData.value.endTime).getTime() : null,
set: (val: number | null) => {
formData.value.endTime = val ? new Date(fromPickerValue(val)) : null
formData.value.endTime = val ? new Date(val) : null
},
})
+2 -67
View File
@@ -125,67 +125,6 @@ const websiteConfig = reactive<WebsiteConfig>({
enableMaxkb: true,
})
// PK options
//
const dragFrom = ref<number | null>(null)
const dragOver = ref<number | null>(null)
function moveClass(from: number, to: number) {
const list = [...websiteConfig.classList]
const [moved] = list.splice(from, 1)
list.splice(to, 0, moved)
websiteConfig.classList = list
}
function renderClassTag(tag: string | { label: string }, index: number) {
const label = typeof tag === "string" ? tag : tag.label
return h(
NTag,
{
key: index,
closable: true,
draggable: true,
type: dragOver.value === index ? "primary" : "default",
style: {
cursor: "move",
opacity: dragFrom.value === index ? 0.4 : 1,
},
onClose: () => {
websiteConfig.classList = websiteConfig.classList.filter(
(_, i) => i !== index,
)
},
onDragstart: (e: DragEvent) => {
dragFrom.value = index
// Firefox setData
e.dataTransfer?.setData("text/plain", label)
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"
},
onDragover: (e: DragEvent) => {
if (dragFrom.value === null) return
e.preventDefault()
dragOver.value = index
},
onDragleave: () => {
if (dragOver.value === index) dragOver.value = null
},
onDrop: (e: DragEvent) => {
e.preventDefault()
if (dragFrom.value !== null && dragFrom.value !== index) {
moveClass(dragFrom.value, index)
}
dragFrom.value = null
dragOver.value = null
},
onDragend: () => {
dragFrom.value = null
dragOver.value = null
},
},
() => label,
)
}
async function getWebsiteConfig() {
const res = await getWebsite()
websiteConfig.websiteBaseUrl = res.websiteBaseUrl
@@ -286,14 +225,10 @@ onMounted(() => {
<n-form label-placement="left">
<n-form-item label="班级列表">
<n-flex vertical size="small">
<n-dynamic-tags
v-model:value="websiteConfig.classList"
:render-tag="renderClassTag"
/>
<n-dynamic-tags v-model:value="websiteConfig.classList" />
<n-text depth="3" style="font-size: 12px">
{{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
位数字 2512510要和用户名里 ks 后面那段一致
拖动标签调整顺序登录页等下拉框按此顺序改完点保存
位数字 2512510要和用户名里 ks 后面那段一致
</n-text>
</n-flex>
</n-form-item>
+3 -1
View File
@@ -110,7 +110,9 @@ function startRolling(finalName: string) {
async function getRandom() {
const res = await randomUser10(query.classroom)
const names = (res as string[]).map((name) => name.split(query.classroom)[1])
const names = (res as string[]).map(
(name) => name.split(query.classroom)[1],
)
rollingNames.value = names
const finalName = names[names.length - 1]
startRolling(finalName)
@@ -391,7 +391,7 @@ function typeTagType(type: ExerciseType) {
{{ typeName(ex.type) }}
</n-tag>
<n-text style="margin-left: 10px">
{{ (ex.data as { question?: string }).question }}
{{ (ex.data as any).question }}
</n-text>
</div>
<n-space :size="8">

Some files were not shown because too many files have changed in this diff Show More