Compare commits
140
Commits
8861393529
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b4d8899f9 | ||
|
|
a872e8365b | ||
|
|
b4c0f89291 | ||
|
|
06ad6745b7 | ||
|
|
47d8f46bdb | ||
|
|
a0ef204bd2 | ||
|
|
20a6ddc79c | ||
|
|
c228b164cf | ||
|
|
b5ba56ccd0 | ||
|
|
f90d01338e | ||
|
|
3cc2be77a9 | ||
|
|
905ba2ee03 | ||
|
|
a8de8f3932 | ||
|
|
24af385f33 | ||
|
|
ad858ed864 | ||
|
|
88b695d34f | ||
|
|
ed56a209ea | ||
|
|
e600fd24cf | ||
|
|
5eee13fe81 | ||
|
|
6fdc3c588e | ||
|
|
a8408c0bb5 | ||
|
|
3559ae4d6f | ||
|
|
688005c081 | ||
|
|
ed3b2cf6de | ||
|
|
4189c59162 | ||
|
|
cc51e305cf | ||
|
|
3921c496cb | ||
|
|
f6995c841b | ||
|
|
4c0c38445c | ||
|
|
6e63866cc9 | ||
|
|
fe4fc46243 | ||
|
|
271123179a | ||
|
|
9132901cc7 | ||
|
|
a833819679 | ||
|
|
2b07040aee | ||
|
|
8eae4bea7b | ||
|
|
f581029eb2 | ||
|
|
eda1eb7eee | ||
|
|
79bfd28b07 | ||
|
|
91712b482d | ||
|
|
a6ba5cdf07 | ||
|
|
8520192a98 | ||
|
|
ab47e71d6f | ||
|
|
b9a80d62bc | ||
|
|
7d15e6aeaa | ||
|
|
684f2d29a5 | ||
|
|
5da661d7f0 | ||
|
|
de34a47996 | ||
|
|
aab0404ed7 | ||
|
|
a475cac128 | ||
|
|
78a42a42e7 | ||
|
|
26b23aa7c0 | ||
|
|
6bef55904f | ||
|
|
80f3b21e95 | ||
|
|
a5b57d8ab8 | ||
|
|
64ad6139f5 | ||
|
|
a89eed7bdd | ||
|
|
9c5a1d551d | ||
|
|
fdfb064d0c | ||
|
|
2bf0e7bfc1 | ||
|
|
f66bafafaf | ||
|
|
3a8f4fcd51 | ||
|
|
6a438872b9 | ||
|
|
856b7a280e | ||
|
|
5222e012e1 | ||
|
|
ddc3f05bc3 | ||
|
|
5d60bb15bb | ||
|
|
01d7924faa | ||
|
|
57bc652629 | ||
|
|
ec1509c46d | ||
|
|
22a7700b89 | ||
|
|
25b86ec17e | ||
|
|
2256597838 | ||
|
|
d1d071ff7b | ||
|
|
ca29fcb554 | ||
|
|
ab0d585939 | ||
|
|
498fc1ceca | ||
|
|
bb0f5ec5ef | ||
|
|
2e78b6efdf | ||
|
|
583ac2cee5 | ||
|
|
ba2a47836e | ||
|
|
b130d805be | ||
|
|
1729b9db4d | ||
|
|
37861800e0 | ||
|
|
8a88fe8d99 | ||
|
|
4d7be969d9 | ||
|
|
69883bd016 | ||
|
|
7129f1a12d | ||
|
|
fafeebd281 | ||
|
|
2c4d56b29a | ||
|
|
e624c62502 | ||
|
|
cf1eea0810 | ||
|
|
286487acf3 | ||
|
|
9675dfbd42 | ||
|
|
8cd4459964 | ||
|
|
bd84599174 | ||
|
|
49681d04a6 | ||
|
|
912dbb7f73 | ||
|
|
4654615f89 | ||
|
|
047e6dd7d3 | ||
|
|
ec35400249 | ||
|
|
a2f67eebf6 | ||
|
|
e5a6f2d1e7 | ||
|
|
9d9e104df6 | ||
|
|
74b97c610f | ||
|
|
d9e6a2a3f0 | ||
|
|
d7a6414735 | ||
|
|
05cf8011e3 | ||
|
|
f9354b0df1 | ||
|
|
ff31f7abd1 | ||
|
|
2031e6a434 | ||
|
|
e62f41f6c7 | ||
|
|
cbe6955c26 | ||
|
|
0e083225f1 | ||
|
|
38b81881d5 | ||
|
|
2f9b6dbf03 | ||
|
|
ffabcd4a0d | ||
|
|
4abaf9c7e4 | ||
|
|
66a564710f | ||
|
|
bb33e0f0e5 | ||
|
|
4b6242ba70 | ||
|
|
c7132025f7 | ||
|
|
de4e745d90 | ||
|
|
d1fc6349b7 | ||
|
|
86857dedf0 | ||
|
|
bfe4468fdb | ||
|
|
6f873be367 | ||
|
|
ab8fcc2d42 | ||
|
|
17aa69f8a7 | ||
|
|
aef687bcfc | ||
|
|
457df1ef3c | ||
|
|
b14639d890 | ||
|
|
1db2c49b87 | ||
|
|
c04570b4ad | ||
|
|
59191c9433 | ||
|
|
275e70e23a | ||
|
|
f38444c97a | ||
|
|
280443c892 | ||
|
|
77717e439d | ||
|
|
6c2381ad2c |
@@ -0,0 +1,7 @@
|
|||||||
|
# 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
|
||||||
@@ -1,28 +1,28 @@
|
|||||||
# CLAUDE.md
|
# CLAUDE.md
|
||||||
|
|
||||||
OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScript,前后端同仓。
|
OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScript,前后端同仓。
|
||||||
上一代在 `../OnlineJudge/`(Django)和 `../ojnext/`(Vue SPA),**仍然完全冻结、
|
上一代在 `../OnlineJudge/`(Django)和 `../ojnext/`(Vue SPA)。
|
||||||
一行都不改**。
|
|
||||||
|
|
||||||
> **2026-08-26:回滚路径已废弃,且已经不可逆。** 旧 Django 后端确认不再使用,
|
> **旧栈已不可逆地下线**(`0002_drop_django_leftovers` 删掉了 Django 的框架表并已在生产库
|
||||||
> `0002_drop_django_leftovers` 删掉了它的 7 张框架表(含 `django_session`、
|
> 执行完毕,漏网的一张空 `django_migrations` 由 `0014` 补删)。所以「停新栈起旧栈」已经
|
||||||
> `django_migrations`)。**这条迁移已在生产库执行完毕**
|
> 不是退路,**唯一退路是从数据库备份恢复**。
|
||||||
> (`docker exec oj-api oj2-api migrate` 回「没有待执行的迁移」)。
|
|
||||||
>
|
>
|
||||||
> 所以旧栈现在**起不来**了:「停新栈起旧栈」「把 NPM 上游改回 8080」都已失效,
|
> **旧仓库仍然零改动**,没有例外 —— 包括修 bug、包括不影响外部接口的内部小修。
|
||||||
> 唯一退路是从数据库备份恢复。切换手册里的「回滚保证」那节只剩历史价值。
|
> 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、是否
|
||||||
>
|
> 重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户「未处理,按当前政策不动旧仓库」,
|
||||||
> 「改 schema 要考虑回滚」这条约束随之解除,schema 归 OJ2 独占,
|
> 不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」,不再是回滚保证。
|
||||||
> 走 drizzle migration 正常演进即可。
|
|
||||||
|
|
||||||
> **旧仓库仍然零改动**,没有例外——包括修 bug、包括不影响外部接口的内部小修。
|
细节文档(`CLAUDE.md` 只留日常要记住的,展开都在这几份里):
|
||||||
> 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、
|
|
||||||
> 是否重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策
|
|
||||||
> 不动旧仓库",不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」,
|
|
||||||
> 不再是回滚保证。
|
|
||||||
|
|
||||||
设计文档:`docs/specs/2026-08-06-bun-backend-rewrite-design.md`
|
| 文档 | 什么时候读 |
|
||||||
切换手册:`docs/specs/phase5-cutover-runbook.md` ← 上线当天照这份走
|
|---|---|
|
||||||
|
| `docs/deploy.md` | 部署、上线、备份恢复 |
|
||||||
|
| `docs/database.md` | 写迁移、给新库打基线、drizzle-kit 抽风 |
|
||||||
|
| `docs/timezone.md` | 动日历口径、动时间出参格式 |
|
||||||
|
| `docs/contract.md` | 动 zod 契约、想给某个字段加校验 |
|
||||||
|
| `docs/ast-rules.md` | 动 AST 代码规则、升级 tree-sitter |
|
||||||
|
| `docker/judge/README.md` | 换判题沙箱镜像、升语言版本(gcc / Python / Node …) |
|
||||||
|
| `docs/specs/` | 两份设计文档:后端重写、课堂求助与协作编辑 |
|
||||||
|
|
||||||
## 仓库结构
|
## 仓库结构
|
||||||
|
|
||||||
@@ -31,18 +31,18 @@ OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScrip
|
|||||||
| `apps/api/` | 后端。Hono + Drizzle + BullMQ,编译成单二进制 |
|
| `apps/api/` | 后端。Hono + Drizzle + BullMQ,编译成单二进制 |
|
||||||
| `apps/web/` | 前端。从 ojnext 原样搬来的 Vue 3 SPA |
|
| `apps/web/` | 前端。从 ojnext 原样搬来的 Vue 3 SPA |
|
||||||
| `packages/contract/` | 前后端共用的 Zod 契约 |
|
| `packages/contract/` | 前后端共用的 Zod 契约 |
|
||||||
| `docker/` | Dockerfile + 三套 compose(dev / debian / school) |
|
| `docker/` | Dockerfile + 三套 compose(dev / debian / school)+ 部署与运维脚本 |
|
||||||
| `docs/specs/` | 设计、端点清单、各阶段评审报告与演练报告 |
|
| `docs/` | 上面那几份专题文档 + `specs/` 里的设计文档 |
|
||||||
|
|
||||||
## 本机环境
|
## 本机环境
|
||||||
|
|
||||||
**Docker 可用,全套依赖都能在本机跑起来**(PostgreSQL、Redis、判题沙箱),
|
**Docker 可用,全套依赖都能在本机跑起来**(PostgreSQL、Redis、判题沙箱),
|
||||||
镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用"本机跑不起来后端"
|
镜像也能在本机构建并完整演练上线。这一点和上一代不同,别沿用「本机跑不起来后端」的旧假设。
|
||||||
的旧假设。
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun install
|
bun install
|
||||||
bun run db:up # 起 postgres(5433) / redis(6380) / 判题沙箱(8081)
|
bun run db:up # 起 postgres(5433) / redis(6380) / 判题沙箱(8081)
|
||||||
|
bun run db:migrate # 空库会从 0000 自举出全部结构
|
||||||
bun run dev # api(3000) + worker + web(5173) 一起起
|
bun run dev # api(3000) + worker + web(5173) 一起起
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -52,28 +52,43 @@ bun run dev # api(3000) + worker + web(5173) 一起起
|
|||||||
常用检查:
|
常用检查:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bunx tsc --noEmit -p apps/api # 后端类型检查
|
bun run --filter '@oj2/api' typecheck # 后端类型检查
|
||||||
bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路由跑一下
|
bun run --filter '@oj2/api' check:routes # 路由遮蔽检查,加完路由跑一下
|
||||||
cd apps/web && bun run build # 前端构建(vite 不做类型检查,构建即验证)
|
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 别拿构建当验证。
|
||||||
|
|
||||||
**不要写测试** —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。
|
**不要写测试** —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。
|
||||||
|
本机 Docker 全套都能起,实跑的成本比想象中低。
|
||||||
|
|
||||||
## 几件必须知道的事
|
## 几件必须知道的事
|
||||||
|
|
||||||
### 单二进制是有代价的
|
### 单二进制是有代价的
|
||||||
|
|
||||||
`apps/api` 编译成 `bun build --compile` 的单二进制,所以**运行时不能依赖
|
`apps/api` 编译成 `bun build --compile` 的单二进制,所以**运行时不能依赖 node_modules**。
|
||||||
node_modules**。任何 `require.resolve` / `Bun.resolveSync` / `__dirname` 去找文件的
|
任何 `require.resolve` / `Bun.resolveSync` / `__dirname` 去找文件的写法,本地都正常、编译后
|
||||||
写法,本地都正常、编译后都会炸,而且**只在离开仓库目录后才炸**(在仓库里跑时它顺着
|
都会炸,而且**只在离开仓库目录后才炸**(在仓库里跑时它顺着 cwd 摸到了 node_modules,
|
||||||
cwd 摸到了 node_modules,假装没事)。
|
假装没事)。
|
||||||
|
|
||||||
资源要用 `with { type: "file" }` 内嵌。`.node` 原生模块还要额外注意:这个写法
|
资源要用 `with { type: "file" }` 内嵌。`.node` 原生模块还要额外注意:这个写法只有打包器认、
|
||||||
只有打包器认、`bun run` 不认,所以必须按形态分叉 —— 见 `apps/api/src/vendor/jieba.ts`
|
`bun run` 不认,所以必须按形态分叉 —— 见 `apps/api/src/vendor/jieba.ts` 的注释,
|
||||||
的注释,那里把坑写全了。
|
那里把坑写全了。
|
||||||
|
|
||||||
**改完这类代码,dev 和编译两种形态都要跑一遍。** 我吃过亏:只验了编译产物,
|
**改完这类代码,dev 和编译两种形态都要跑一遍。** 我吃过亏:只验了编译产物,dev 直接起不来。
|
||||||
dev 直接起不来。
|
|
||||||
|
|
||||||
### 路径解析看 `runtime.ts`
|
### 路径解析看 `runtime.ts`
|
||||||
|
|
||||||
@@ -82,17 +97,15 @@ dev 直接起不来。
|
|||||||
|
|
||||||
### SQL 判题会 spawn「自己」
|
### SQL 判题会 spawn「自己」
|
||||||
|
|
||||||
`judge/sql/index.ts` 起的子进程是二进制自身 + `sql-child` 子命令(因为编译后磁盘上
|
`judge/sql/index.ts` 起的子进程是二进制自身 + `sql-child` 子命令(因为编译后磁盘上没有
|
||||||
没有 child.ts 可以 spawn)。所以**入口必须有 argv 分发**,否则「起自己」变成
|
child.ts 可以 spawn)。所以**入口必须有 argv 分发**,否则「起自己」变成「把整个程序再跑
|
||||||
「把整个程序再跑一遍」→ 指数级 fork。这不是假想,开发时炸过一次开发机。
|
一遍」→ 指数级 fork。这不是假想,开发时炸过一次开发机。`OJ2_SQL_CHILD` 那道递归闸不要删。
|
||||||
`OJ2_SQL_CHILD` 那道递归闸不要删。
|
|
||||||
|
|
||||||
### 加路由要防遮蔽
|
### 加路由要防遮蔽
|
||||||
|
|
||||||
**Hono 按注册顺序匹配,不是静态优先**(实测确认过,别凭直觉)。`/problems/:id`
|
**Hono 按注册顺序匹配,不是静态优先**(实测确认过,别凭直觉)。`/problems/:id` 注册在
|
||||||
注册在 `/problems/random` 前面的话,后者永远进不去 —— 而且不报错、不警告,
|
`/problems/random` 前面的话,后者永远进不去 —— 而且不报错、不警告,只是静默走进前一条的
|
||||||
只是静默走进前一条的 handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉,
|
handler。阶段 4 真实发生过一次,两个教师用的分析端点被吃掉,一直到评审才发现。
|
||||||
一直到评审才发现。
|
|
||||||
|
|
||||||
加完路由跑 `bun run --filter '@oj2/api' check:routes`。
|
加完路由跑 `bun run --filter '@oj2/api' check:routes`。
|
||||||
|
|
||||||
@@ -102,172 +115,145 @@ dev 直接起不来。
|
|||||||
这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是
|
这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是
|
||||||
这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。
|
这套编码,所以只能新增、不能改已有的含义。题目表情 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 —— 这条规矩是被四次这样的线上故障换来的。
|
||||||
|
|
||||||
|
**闸设在写入侧**:入参 `safeParse`(58 处)、`db/schema.ts` 的 `.$type<>()` 列收窄、
|
||||||
|
语义校验函数(`astRulesError()` / `exerciseDataError`)。JSONB 原文
|
||||||
|
(`submission.info` / `statistic_info` / `exercise.data`)一律放行,它们的形状真相在判题机
|
||||||
|
那边。query 的筛选值走 `routes/helpers.ts` 的 `asFilterValue()`,那是纯类型交接、不加校验。
|
||||||
|
|
||||||
|
四次故障的细节、`.$type<>()` 断言该怎么核,见 `docs/contract.md`;
|
||||||
|
前端为什么只在三处挂运行时闸门,见 `apps/web/CLAUDE.md`。
|
||||||
|
|
||||||
|
### AST 代码规则:一张表,外加一个机器检查
|
||||||
|
|
||||||
|
契约的 `AST_NODE_TARGETS_BY_LANGUAGE` 是**唯一**一张表(`label` 给界面、`node` 给判题机),
|
||||||
|
判题机侧没有第二张表,所以加 target 漏配节点类型在结构上不可能。但**配错**仍然可能,
|
||||||
|
而且完全静默 —— 节点类型对不上就是「必须使用 X」永远失败、「不能使用 X」永远通过。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run --filter '@oj2/api' check:ast # 升级 tree-sitter-* 之后一定要跑
|
||||||
|
```
|
||||||
|
|
||||||
|
判题机只认 C / C++ / Python(`AST_SUPPORTED_LANGUAGES`),别的语言配了规则一条都不会跑,
|
||||||
|
所以后台不给它们开 tab —— **看得见却不检查**比没有更糟。C++ 的调用形态和 C 不一样、
|
||||||
|
规则的语义校验为什么不挂在 zod 上,见 `docs/ast-rules.md`。
|
||||||
|
|
||||||
### 比赛只有 ACM 模式
|
### 比赛只有 ACM 模式
|
||||||
|
|
||||||
没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要"顺手补回来"。
|
没有 OI。上一代残留的 OI 分支在阶段 0 已经砍掉,不要「顺手补回来」。
|
||||||
|
|
||||||
### 前端要兼容老 Chrome
|
### 前端基线是 Chrome 105(2026-09-16 从 < 94 上调)
|
||||||
|
|
||||||
机房电脑 Chrome < 94。`mermaid-legacy` 等 fallback 依赖和 vite 的构建 target
|
机房**部分**电脑是 Chrome 105,其余更新 —— 按最低那档定基线。
|
||||||
不能动,`vite.config.ts` 里有注释说明。
|
|
||||||
|
- **`@vitejs/plugin-legacy` 留着,别删**:vite 8 的默认构建 target 是 `chrome111`,比 105 高。
|
||||||
|
这个插件同时把 `build.target` 压到 `es2020/chrome105`、给现代产物补 core-js polyfill
|
||||||
|
(`toSorted` / `Set` 运算 / 迭代器辅助那批是 Chrome 110+ 才有的)。`modernTargets` 不写,
|
||||||
|
用插件自带的基线(`chrome>=105`),正好是这一档。polyfill 清单写死在 `vite.config.ts`,
|
||||||
|
**升级前端依赖后重新审计**:`DEBUG=vite:legacy bun run build` 会打印探测到的全集。
|
||||||
|
- **Chrome < 94 那套删掉了**:`mermaid-legacy`(mermaid@9)、cytoscape 的 UMD→ESM 别名、
|
||||||
|
`useMermaid.ts` 里按 UA 分叉的 v9 回调式 render —— 105 用得上 mermaid 11。
|
||||||
|
- **View Transitions 要 111,105 没有**,`darkTransition.ts` 的降级分支是真在用的。
|
||||||
|
|
||||||
|
### 时间只有一个锚点:`apps/api/src/time.ts`
|
||||||
|
|
||||||
|
**凡是要把一个时刻换算成「哪一天 / 几点 / 哪一年」,一律走那个模块。** 不要写
|
||||||
|
`new Date(x).getHours()`、`setHours(0,0,0,0)`、`getFullYear()`、`new Date(y, m, d)` 这类跟
|
||||||
|
**进程时区**走的代码 —— 容器是 UTC、开发机是本机时区,两边答案不同而且不报错。
|
||||||
|
SQL 里要按日历切,用 `localTime(列)`(生成 `列 at time zone 'Asia/Shanghai'`),
|
||||||
|
别依赖数据库会话时区。
|
||||||
|
|
||||||
|
**分层:存 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` 是**刻意不设**的,别「顺手补上」。
|
||||||
|
|
||||||
## 数据库
|
## 数据库
|
||||||
|
|
||||||
Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它长得像 Django 建的表
|
Drizzle schema 最初是 `drizzle-kit pull` 从生产库拉出来的,所以它长得像 Django 建的表
|
||||||
(表名、bigint/int4 混用、外键全是 NO ACTION),`schema.ts` 顶部记了哪些地方是手工修的。
|
(表名、bigint/int4 混用),`schema.ts` 顶部记了哪些地方是手工修的。
|
||||||
|
**schema 现在归 OJ2 独占**,结构变更走 migration 正常演进。
|
||||||
|
|
||||||
**schema 现在归 OJ2 独占。** 旧后端已下线,「改 schema 要考虑回滚」这条约束不再存在,
|
**外键的删除动作从 0010 起是显式的**,不再是 Django 留下的一律 NO ACTION:
|
||||||
结构变更走下面的 migration 正常演进即可。
|
|
||||||
|
- **CASCADE**:父行消失后子行必然无意义、且不构成「学生做过什么」的证据 —— 中间表
|
||||||
|
(problem_tags)、题单/教程/成就的组成部分、一对一附属(user_profile)与可重算的缓存
|
||||||
|
(user_stat)。
|
||||||
|
- **NO ACTION(即拦住)**:需要人看见的删除 —— `submission.problem_id`、以及 `user` 的绝大
|
||||||
|
多数外键。删用户撞外键会被 handler 翻译成「请改为禁用账号」,这是有意的。
|
||||||
|
|
||||||
|
**加新子表时必须回来想一遍该走哪一档**,别默认新外键会自己连坐 —— drizzle 不写
|
||||||
|
`.onDelete()` 就是 NO ACTION,而 0010 只改了当时存在的那批。
|
||||||
|
|
||||||
### 改 schema 走 drizzle migration
|
### 改 schema 走 drizzle migration
|
||||||
|
|
||||||
`bun run db:generate`(造迁移文件)→ `bun run db:migrate`(按 `drizzle.__drizzle_migrations`
|
`bun run db:generate`(造迁移文件)→ `bun run db:migrate`(按
|
||||||
增量执行),就是 Django `makemigrations` / `migrate` 的等价物。索引/结构变更走这条,
|
`drizzle.__drizzle_migrations` 增量执行),就是 Django `makemigrations` / `migrate` 的
|
||||||
不要再手写 SQL 往 `docs/specs/` 里塞。
|
等价物。索引/结构变更走这条,不要再手写 SQL 往 `docs/` 里塞。
|
||||||
|
|
||||||
**部署时自动执行。** `docker/deploy.sh` 在「构建镜像」之后、「起栈」之前会跑
|
- **执行器是自己的**(`db/migrate.ts`,一条迁移一个事务),不是 drizzle 那个,
|
||||||
`oj2-api migrate`,失败就中止部署(旧容器原样还在跑)。CI 走的也是 deploy.sh,
|
`db:migrate` 和线上 `oj2-api migrate` 是同一条代码路径。
|
||||||
所以不需要给 GitHub 配数据库凭据,也不用把生产库对外开放。
|
- **部署时自动执行**:`docker/deploy.sh` 在构建镜像之后、起栈之前跑,失败就中止部署。
|
||||||
|
- 迁移文件**不内嵌进二进制**,随镜像装在 `/usr/local/share/oj2/migrations`
|
||||||
|
(见 `runtime.ts` 的 `migrationsDir`),所以新增迁移不用改任何代码。
|
||||||
|
- **破坏性迁移默认拦截**(`DROP TABLE` / `DROP COLUMN` / `ALTER COLUMN ... TYPE` /
|
||||||
|
`TRUNCATE`),退出 4,要显式放行:`OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh`。
|
||||||
|
- **空库能自举**,直接从 `0000` 建起,新环境不需要先灌 schema dump。
|
||||||
|
|
||||||
迁移文件**不内嵌进二进制**,随镜像装在 `/usr/local/share/oj2/migrations`
|
`CREATE INDEX CONCURRENTLY` 怎么写、给已有库打基线的 SQL、`.op()` 会吞掉索引方向这类
|
||||||
(见 `runtime.ts` 的 `migrationsDir`、Dockerfile 里那两条 COPY)。这样 drizzle 的
|
drizzle-kit 的坑,全在 `docs/database.md`。
|
||||||
`migrate()` 能原样用——它靠 `meta/_journal.json` 自动发现迁移,**新增迁移不用改任何
|
|
||||||
代码**。内嵌就得为每条迁移手写一行 import,那是迟早会漏的账。
|
|
||||||
|
|
||||||
**破坏性迁移默认拦截。** 含 `DROP TABLE` / `DROP COLUMN` / `DROP SCHEMA` /
|
|
||||||
`ALTER COLUMN ... TYPE` / `TRUNCATE` 的迁移会让部署停在迁移这步并退出 4,
|
|
||||||
需要确认备份后显式放行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
`DROP INDEX` / `DROP CONSTRAINT` 不算——它们不掉数据,拦了只会让人习惯性带上放行开关。
|
|
||||||
**空库自举时这道闸不生效**:没有数据可丢,0002 那串 `DROP ... IF EXISTS` 全是空转,
|
|
||||||
拦下来只会逼每个新环境都带一次放行开关,把它训练成习惯动作。
|
|
||||||
|
|
||||||
**空库能自举了。** `oj2-api migrate` 指向一个空库时直接从 `0000` 建起:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DATABASE_URL=postgres://... oj2-api migrate
|
|
||||||
# 空库,从 0000 开始自举。
|
|
||||||
# 待执行 3 条迁移,开始。
|
|
||||||
# ✓ 0000_crazy_gateway
|
|
||||||
# ✓ 0001_add_submission_public_create_time_idx
|
|
||||||
# ✓ 0002_drop_django_leftovers
|
|
||||||
```
|
|
||||||
|
|
||||||
`0000_crazy_gateway.sql` 原本是 `drizzle-kit pull` 的产物、整份被 `/* */` 包着、可执行
|
|
||||||
语句 0 条,所以以前新库只能先手工 `psql -f docs/specs/schema.sql`。现在它的内容由那份
|
|
||||||
生产 dump 机械转换而来(去掉 psql 专有指令、去掉 7 张 Django 遗留表及其索引外键,
|
|
||||||
其余原样保留)。**实测**:空库自举出来的结构,和「灌 schema.sql + 打基线 + 跑迁移」
|
|
||||||
这条老路子跑出来的结构,`pg_dump --schema-only` 逐字节一致(734 行,零差异)。
|
|
||||||
|
|
||||||
改 0000 对生产库没有影响 —— migrator 只比 `created_at`、**从不校验 hash**
|
|
||||||
(`pg-core/dialect.js` 里就一句 `Number(lastDbMigration.created_at) < migration.folderMillis`),
|
|
||||||
而生产库那行 `baseline-0000-faked` 早把它挡在门外了。
|
|
||||||
|
|
||||||
⚠️ **0000 的注释里不要出现 statement-breakpoint 那个分隔标记的字面量。**
|
|
||||||
`readMigrationFiles` 是纯文本切分,不管它在不在注释里,照切不误 —— 注释被从中间切开,
|
|
||||||
后半截当成 SQL 发出去,报的是 `syntax error at or near "。"` 这种和真实原因毫不相干的错。
|
|
||||||
|
|
||||||
**给一个已经存在的库做基线**:drizzle 没有 `--fake-initial`,`migrate` 见到空的
|
|
||||||
`__drizzle_migrations`、库里却已经有表,会拒绝执行并 exit 3(裸跑 `drizzle-kit migrate`
|
|
||||||
的话则是从 `0000` 撞上已存在的表、整个事务回滚,**而且 exit 1 却一个错误都不打印**)。
|
|
||||||
对已有数据的库第一次跑之前,先手插一行把 `0000` 标记成已执行:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE SCHEMA IF NOT EXISTS drizzle;
|
|
||||||
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
|
||||||
id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint);
|
|
||||||
INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
|
|
||||||
VALUES ('baseline-0000-faked', 1786070652521); -- = meta/_journal.json 里 0000 的 when
|
|
||||||
```
|
|
||||||
|
|
||||||
migrator 只比 `created_at`,不校验 hash,所以 hash 随便填。
|
|
||||||
|
|
||||||
**已知的三个坑**(`meta/0000_snapshot.json` 是 `pull` 出来的,没法无损还原 Django 建的
|
|
||||||
schema,下面三处已经修过了,别让它们回潮):
|
|
||||||
|
|
||||||
- ~~**快照里的 Django 序列**~~:已随 `0002_drop_django_leftovers` 删表一并解决,
|
|
||||||
`tablesFilter` 也移除了。(历史原因:`tablesFilter` 只过滤表、不过滤它们的序列,
|
|
||||||
于是 `generate` 会吐出 5 条 `DROP SEQUENCE`。)
|
|
||||||
- **bigint 上限精度**:`pull` 生成的 `maxValue: 9223372036854775807` 是 JS number 字面量,
|
|
||||||
round-trip 成 `...776000`,每次 generate 都会多出 10 条 `ALTER COLUMN ... SET MAXVALUE`。
|
|
||||||
已改成字符串。
|
|
||||||
- **表达式索引的 opclass**:`problem_tag_name_ci_unique` 在快照里带 `opclass`,但 drizzle
|
|
||||||
自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。
|
|
||||||
|
|
||||||
**还有一个写代码时要绕开的**:
|
|
||||||
|
|
||||||
- **`.op()` 会吞掉索引方向**:真正的根因不是 `.desc()`,是 opclass。drizzle-kit 的
|
|
||||||
`CreatePgIndexConvertor` 里那个三元一旦走进 opclass 分支就回不到方向分支:
|
|
||||||
`${it.opclass ? ` ${it.opclass}` : it.asc ? "" : " DESC"}`。而 `drizzle-kit pull`
|
|
||||||
给**每一列**都挂了 `.op(...)`,所以本仓库里"写了 `.desc()` 却生成不出 DESC"每次都会重演。
|
|
||||||
|
|
||||||
**要方向就别写 `.op()`。** 不写没有任何代价——`int4_ops` / `timestamptz_ops` 本来就是
|
|
||||||
这些类型的默认 opclass,写了等于没写。实测(drizzle-kit 0.31.10,探针索引跑过 generate):
|
|
||||||
|
|
||||||
| schema.ts | 生成的 SQL |
|
|
||||||
|---|---|
|
|
||||||
| `.desc().nullsFirst().op("timestamptz_ops")` | `"create_time" timestamptz_ops` ← 方向丢了 |
|
|
||||||
| `.desc().nullsFirst()` | `"create_time" DESC NULLS FIRST` ✅ |
|
|
||||||
| `.desc()` | `"create_time" DESC NULLS LAST` ✅ |
|
|
||||||
|
|
||||||
所以**多列混合方向的索引可以正常 generate**,不必手写。
|
|
||||||
|
|
||||||
假 diff 的机制也要理解对:带 `.op()` 时快照记的是 `asc: false`,SQL 建出来却是 ASC,
|
|
||||||
**分歧在快照和真实库之间**,不在快照和 schema.ts 之间——所以再跑 generate 是干净的,
|
|
||||||
要等到下次 pull 才炸出来。这是当初难定位的原因。
|
|
||||||
|
|
||||||
### 迁移执行器是自己的,不是 drizzle 那个
|
|
||||||
|
|
||||||
`db/migrate.ts` 不调用 drizzle 的 `migrate()`,自己按 journal 逐条执行。换掉它是因为
|
|
||||||
`pg-core/dialect.js` 里那个实现有两条硬伤:
|
|
||||||
|
|
||||||
1. **所有待执行的迁移共用一个事务**,第 3 条失败会把第 1、2 条一起回滚。现在是**一条一个
|
|
||||||
事务**,语义和 Django `migrate` 一致,失败时也说得清库停在哪儿。
|
|
||||||
2. 正因为全在事务里,`CREATE INDEX CONCURRENTLY` 一律跑不了,没有开关。
|
|
||||||
|
|
||||||
记账行的写法和 drizzle 完全一致(`hash` = 整个文件的 sha256,`created_at` = journal 的
|
|
||||||
`when`),而 migrator 只比 `created_at`、不校验 hash,所以两套执行器可以互换,不会看不懂
|
|
||||||
对方写的记录。
|
|
||||||
|
|
||||||
**`CREATE INDEX CONCURRENTLY` 现在能跑了。** 在迁移文件**第一行**写上标记:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- oj2:no-transaction
|
|
||||||
CREATE INDEX CONCURRENTLY "xxx_idx" ON "submission" USING btree ("language");
|
|
||||||
```
|
|
||||||
|
|
||||||
这条迁移就走裸执行(简单查询协议,不包事务)。代价是**没有回滚**:中途失败时前面的语句
|
|
||||||
已经生效,而且 CONCURRENTLY 失败会在库里留下一个 INVALID 索引,要先
|
|
||||||
`DROP INDEX` 再重来(`select indexrelid::regclass from pg_index where not indisvalid`
|
|
||||||
能找出来)。所以**这种迁移一个文件只放一条语句**。
|
|
||||||
|
|
||||||
要不要用是另一回事:参考量级是 12.3 万行的部分索引,普通 `CREATE INDEX` 只锁 74ms,
|
|
||||||
一般不用纠结,CONCURRENTLY 留给真扛不住锁写窗口的场合。
|
|
||||||
|
|
||||||
退出码:2 = 配置/文件问题,3 = 基线不对,4 = 撞上破坏性迁移,5 = 某条迁移执行失败。
|
|
||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
三套 compose 在 `docker/`:`dev`(本机)、`debian`(服务器)、`school`(机房)。
|
三套 compose 在 `docker/`:`dev`(本机)、`debian`(服务器)、`school`(机房)。
|
||||||
|
|
||||||
**机房那套没有 postgres,连的是服务器的库。** 两个站点共用一个数据库,
|
**机房那套没有 postgres,连的是服务器的库。** 两个站点共用一个数据库,但各有各的 Redis
|
||||||
但各有各的 Redis 和判题沙箱 —— 所以上线那天**两边必须一起切**。
|
和判题沙箱 —— 所以涉及两边的变更要一起做。
|
||||||
|
|
||||||
`compose.debian.yml` 有两种形态,靠 env 切换:
|
`compose.debian.yml` 靠 env 切形态:设 `DATA_DIR` / `DB_HOST` / `REDIS_HOST` 就是接现有的库
|
||||||
|
(线上就是这个),留空并加 `--profile local-data` 就是自带 postgres / redis。
|
||||||
|
|
||||||
- **只换前后端**(上线用这个):设 `DATA_DIR` / `DB_HOST` / `REDIS_HOST`,
|
⚠️ **`DATA_DIR` 默认值 `../data` 是 `OJ2/data`,不是部署目录的 `data/`。** 沿用旧数据却忘了
|
||||||
沿用旧栈已经在跑的 postgres 和 redis,只起 api / worker / web / judge。
|
设它,会静默起一套空数据(空库、没测试点、图片 404),而且**不报错** —— 这是整个部署里
|
||||||
- **自带数据**(本机、演练):不设那几个变量,起栈时加 `--profile local-data`。
|
唯一会静默走歪的地方,`deploy.sh` 为它专门设了一道自检。
|
||||||
- **并行试跑**(上线前先挂 `oj2.xuyue.cc` 跑几天):在「只换前后端」基础上再加
|
|
||||||
`WEB_PORT`(8080 被旧 backend 占着)和 `JUDGE_STATE_DIR`(两个判题机不能共用运行目录)。
|
|
||||||
这种形态下旧栈一个容器都不用停,正式切换退化成改一行 NPM 上游。
|
|
||||||
|
|
||||||
⚠️ `DATA_DIR` 默认值 `../data` 是 **`OJ2/data`**,不是部署目录的 `data/`。
|
上线两条路(push 触发 CI / 手工 `docker/deploy.sh`)、部署后的验证清单、NPM 反代那两个
|
||||||
沿用旧数据却忘了设它,会静默起一套空数据(空库、没测试点、图片 404),
|
不能关的开关、备份恢复的两个坑,都在 `docs/deploy.md`。
|
||||||
而且**不报错** —— 这是切换当天唯一会静默走歪的地方。
|
|
||||||
|
|
||||||
细节和演练结果都在 `docs/specs/phase5-cutover-runbook.md`。
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export default defineConfig({
|
|||||||
schema: "./src/db/schema.ts",
|
schema: "./src/db/schema.ts",
|
||||||
out: "./src/db",
|
out: "./src/db",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL ?? "postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge",
|
url:
|
||||||
|
process.env.DATABASE_URL ??
|
||||||
|
"postgres://onlinejudge:onlinejudge@localhost:5433/onlinejudge",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,11 +11,13 @@
|
|||||||
"worker": "bun src/main.ts worker",
|
"worker": "bun src/main.ts worker",
|
||||||
"build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api",
|
"build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api",
|
||||||
"seed:dev": "bun src/scripts/seed-dev.ts",
|
"seed:dev": "bun src/scripts/seed-dev.ts",
|
||||||
|
"recount": "bun --env-file=../../.env src/main.ts recount",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"check:routes": "bun src/scripts/check-route-shadowing.ts",
|
"check:routes": "bun src/scripts/check-route-shadowing.ts",
|
||||||
|
"check:ast": "bun src/scripts/check-ast-targets.ts",
|
||||||
"db:pull": "drizzle-kit pull",
|
"db:pull": "drizzle-kit pull",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate"
|
"db:migrate": "bun --env-file=../../.env src/main.ts migrate"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/jieba": "^2.0.2",
|
"@node-rs/jieba": "^2.0.2",
|
||||||
@@ -29,6 +31,7 @@
|
|||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"sql.js": "^1.14.2",
|
"sql.js": "^1.14.2",
|
||||||
"tree-sitter-c": "^0.24.1",
|
"tree-sitter-c": "^0.24.1",
|
||||||
|
"tree-sitter-cpp": "^0.23.4",
|
||||||
"tree-sitter-python": "^0.25.0",
|
"tree-sitter-python": "^0.25.0",
|
||||||
"web-tree-sitter": "^0.26.13",
|
"web-tree-sitter": "^0.26.13",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ADMIN_ROLES, TEACHER_ROLES } from "@oj2/contract"
|
||||||
import type { Context, MiddlewareHandler } from "hono"
|
import type { Context, MiddlewareHandler } from "hono"
|
||||||
|
|
||||||
import { failure } from "../http"
|
import { failure } from "../http"
|
||||||
@@ -50,23 +51,27 @@ function requireRole(
|
|||||||
return async (c, next) => {
|
return async (c, next) => {
|
||||||
const session = await resolveSession(c)
|
const session = await resolveSession(c)
|
||||||
if (!session.user) return denied(c, session.reason)
|
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)
|
c.set("user", session.user)
|
||||||
await next()
|
await next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADMIN_ROLES = ["Student Admin", "Teacher Admin", "Super Admin"]
|
|
||||||
const TEACHER_ROLES = ["Teacher Admin", "Super Admin"]
|
|
||||||
|
|
||||||
/** 旧 `@admin_role_required` */
|
/** 旧 `@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` */
|
/** 旧 `@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` */
|
/** 旧 `@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。
|
* 旧 `@problem_permission_required`:先要是管理员,再要 problem_permission 不为 None。
|
||||||
@@ -74,5 +79,6 @@ export const requireSuperAdmin = requireRole((user) => user.adminType === "Super
|
|||||||
* created_by 过滤 —— 旧后端也是这么分工的,别把两件事混在一起。
|
* created_by 过滤 —— 旧后端也是这么分工的,别把两件事混在一起。
|
||||||
*/
|
*/
|
||||||
export const requireProblemPermission = requireRole(
|
export const requireProblemPermission = requireRole(
|
||||||
(user) => ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== "None",
|
(user) =>
|
||||||
|
ADMIN_ROLES.includes(user.adminType) && user.problemPermission !== "None",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ async function verifyDjangoPbkdf2(password: string, encoded: string) {
|
|||||||
|
|
||||||
const iterations = Number(iterationsText)
|
const iterations = Number(iterationsText)
|
||||||
const expected = Buffer.from(digestText, "base64")
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +57,22 @@ export async function verifyPassword(password: string, encoded: string) {
|
|||||||
return { valid: false, needsUpgrade: false }
|
return { valid: false, needsUpgrade: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 参数是显式写死的,不用 Bun 的默认值(`m=65536, t=2, p=1`,即每次哈希占 64 MiB)。
|
||||||
|
* 取的是 OWASP 对 argon2id 的推荐下限 `m=19MiB, t=2, p=1`:
|
||||||
|
*
|
||||||
|
* - 批量导入一个班要连算几十次哈希,64 MiB 那档单次 ~140ms,而 `oj-api` 的
|
||||||
|
* mem_limit 只有 512m(docker/compose.debian.yml),并发度被内存卡死。
|
||||||
|
* 19 MiB 这档单次 ~20ms,并发 4 路的峰值也才 76 MiB。
|
||||||
|
* - 参数是编码进哈希串本身的(`$argon2id$v=19$m=19456,t=2,p=1$...`),所以**存量
|
||||||
|
* 账号一个都不用迁移**,Bun.password.verify 读串里的参数验,改这里只影响此后新写的哈希。
|
||||||
|
*/
|
||||||
|
const ARGON2_OPTIONS = {
|
||||||
|
algorithm: "argon2id",
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 写密码的**唯一入口**。五个调用方都走这里:注册、管理员改密码、批量导入用户、
|
* 写密码的**唯一入口**。五个调用方都走这里:注册、管理员改密码、批量导入用户、
|
||||||
* 重置密码、登录时升级存量 pbkdf2。
|
* 重置密码、登录时升级存量 pbkdf2。
|
||||||
@@ -68,5 +88,5 @@ export async function verifyPassword(password: string, encoded: string) {
|
|||||||
* 账号;靠开关只能拦住将来,修不了已经发生的。
|
* 账号;靠开关只能拦住将来,修不了已经发生的。
|
||||||
*/
|
*/
|
||||||
export function hashPassword(password: string) {
|
export function hashPassword(password: string) {
|
||||||
return Bun.password.hash(password, { algorithm: "argon2id" })
|
return Bun.password.hash(password, ARGON2_OPTIONS)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { ChainableCommander } from "ioredis"
|
||||||
|
|
||||||
|
import { redis } from "../redis"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「谁现在在线」。member = userId,score = 最后一次活动的毫秒时间戳。
|
||||||
|
*
|
||||||
|
* 会话本身判定不了在线:`session:<token>` 的 TTL 是 7 天且每次请求都续期,
|
||||||
|
* 「有会话」只说明这人一周内来过。所以这里单独记一个活动时间戳 ——
|
||||||
|
* 写入一律搭在已有的 pipeline 上(登录、每个带鉴权的请求、WebSocket 巡检),
|
||||||
|
* 不多一趟往返。
|
||||||
|
*/
|
||||||
|
const PRESENCE_KEY = "online-users"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多久没动就算离线。挂着页面不操作的人靠 sweepSessions 每 60 秒续一次
|
||||||
|
* (见 websocket.ts),窗口必须明显大于那个间隔,否则开着页面的学生会一闪一闪。
|
||||||
|
*/
|
||||||
|
const ONLINE_WINDOW_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
/** 记一笔活动。传 pipeline 而不是自己发命令:调用点都在热路径上 */
|
||||||
|
export function markOnline(pipeline: ChainableCommander, userId: number) {
|
||||||
|
pipeline.zadd(PRESENCE_KEY, Date.now(), String(userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前在线的用户 id。
|
||||||
|
*
|
||||||
|
* 顺手把过期成员删掉 —— 这是唯一的清理时机(整个 key 不能设 TTL:ZADD 不会重置
|
||||||
|
* key 的 TTL,到期会把还在线的人一起抹掉)。读这张表的只有后台用户列表,
|
||||||
|
* 不清理最坏也就是攒下全站用户数量级的成员,远谈不上要单开一个定时任务。
|
||||||
|
*/
|
||||||
|
export async function onlineUserIds() {
|
||||||
|
const cutoff = Date.now() - ONLINE_WINDOW_MS
|
||||||
|
const results = await redis
|
||||||
|
.pipeline()
|
||||||
|
.zremrangebyscore(PRESENCE_KEY, "-inf", `(${cutoff}`)
|
||||||
|
.zrange(PRESENCE_KEY, "0", "-1")
|
||||||
|
.exec()
|
||||||
|
const members = (results?.[1]?.[1] ?? []) as string[]
|
||||||
|
return new Set(members.map(Number).filter(Number.isInteger))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在线人数。前台榜单页要的就是这一个数 —— 不必像 onlineUserIds 那样把成员全拉回来,
|
||||||
|
* ZCOUNT 让 Redis 自己数(O(log N))。这里不顺手清过期成员:清理是写操作,
|
||||||
|
* 而这个端点是匿名可访问的。
|
||||||
|
*/
|
||||||
|
export async function onlineCount() {
|
||||||
|
return redis.zcount(PRESENCE_KEY, Date.now() - ONLINE_WINDOW_MS, "+inf")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登出、被禁用、被踢下线:立刻从在线名单里摘掉,别等窗口自然过期 */
|
||||||
|
export async function clearOnline(userId: number) {
|
||||||
|
await redis.zrem(PRESENCE_KEY, String(userId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个用户在不在线。列表页用上面那个,别在循环里调这个 */
|
||||||
|
export async function isUserOnline(userId: number) {
|
||||||
|
const score = await redis.zscore(PRESENCE_KEY, String(userId))
|
||||||
|
return score !== null && Number(score) >= Date.now() - ONLINE_WINDOW_MS
|
||||||
|
}
|
||||||
+135
-17
@@ -1,14 +1,24 @@
|
|||||||
import { randomBytes } from "node:crypto"
|
import { randomBytes } from "node:crypto"
|
||||||
|
|
||||||
|
import {
|
||||||
|
toAdminType,
|
||||||
|
toProblemPermission,
|
||||||
|
type AdminType,
|
||||||
|
type ProblemPermission,
|
||||||
|
} from "@oj2/contract"
|
||||||
|
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import type { Context } from "hono"
|
import type { Context } from "hono"
|
||||||
import { deleteCookie, getCookie, setCookie } from "hono/cookie"
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie"
|
||||||
|
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
|
import { publishSessionRevoked, type SessionRevokedReason } from "../events"
|
||||||
|
import { clearOnline, markOnline } from "./presence"
|
||||||
import { redis } from "../redis"
|
import { redis } from "../redis"
|
||||||
|
|
||||||
const SESSION_PREFIX = "session:"
|
const SESSION_PREFIX = "session:"
|
||||||
|
const USER_SESSIONS_PREFIX = "user-sessions:"
|
||||||
|
|
||||||
interface StoredSession {
|
interface StoredSession {
|
||||||
userId: number
|
userId: number
|
||||||
@@ -17,12 +27,17 @@ interface StoredSession {
|
|||||||
contestPasswords: Record<string, string>
|
contestPasswords: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话里的用户。`adminType` / `problemPermission` 是**联合类型而不是 string** ——
|
||||||
|
* 全仓二十多处 `user.adminType === "Super Admin"` 靠它兜底,拼错一个字母就编译不过。
|
||||||
|
* 收窄发生在下面读库那一处,是整个后端唯一一个把裸字符串变成角色的地方。
|
||||||
|
*/
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: number
|
id: number
|
||||||
username: string
|
username: string
|
||||||
email: string | null
|
email: string | null
|
||||||
adminType: string
|
adminType: AdminType
|
||||||
problemPermission: string
|
problemPermission: ProblemPermission
|
||||||
isDisabled: boolean
|
isDisabled: boolean
|
||||||
className: string | null
|
className: string | null
|
||||||
}
|
}
|
||||||
@@ -31,6 +46,21 @@ function sessionKey(token: string) {
|
|||||||
return `${SESSION_PREFIX}${token}`
|
return `${SESSION_PREFIX}${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某个用户名下所有还活着的会话 token。
|
||||||
|
*
|
||||||
|
* 会话本体是 `session:<token>`,里面记着 userId —— 从 token 找人很快,从人找 token
|
||||||
|
* 却只能 SCAN 整个 Redis。改密码、重置密码、禁用账号这三件事都要求「把这个人所有
|
||||||
|
* 设备上的会话立刻作废」,所以额外维护这张反向索引。
|
||||||
|
*
|
||||||
|
* **它是索引,不是真相**:成员可能指向已经过期的 token(集合成员没有各自的 TTL),
|
||||||
|
* 吊销时按成员逐个 DEL 即可,删到不存在的 key 没有代价。集合自己跟着会话续期,
|
||||||
|
* 整个人不活动满一个 TTL 之后自然消失。
|
||||||
|
*/
|
||||||
|
function userSessionsKey(userId: number) {
|
||||||
|
return `${USER_SESSIONS_PREFIX}${userId}`
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSession(
|
export async function createSession(
|
||||||
c: Context,
|
c: Context,
|
||||||
userId: number,
|
userId: number,
|
||||||
@@ -43,12 +73,20 @@ export async function createSession(
|
|||||||
previousLogin,
|
previousLogin,
|
||||||
contestPasswords: {},
|
contestPasswords: {},
|
||||||
}
|
}
|
||||||
await redis.set(
|
// 三条写进一个 pipeline:一个班四十号人同时登录时,三趟往返和一趟的差别
|
||||||
sessionKey(token),
|
// 全压在登录这一下上
|
||||||
JSON.stringify(value),
|
const pipeline = redis
|
||||||
"EX",
|
.pipeline()
|
||||||
config.sessionTtlSeconds,
|
.set(
|
||||||
)
|
sessionKey(token),
|
||||||
|
JSON.stringify(value),
|
||||||
|
"EX",
|
||||||
|
config.sessionTtlSeconds,
|
||||||
|
)
|
||||||
|
.sadd(userSessionsKey(userId), token)
|
||||||
|
.expire(userSessionsKey(userId), config.sessionTtlSeconds)
|
||||||
|
markOnline(pipeline, userId)
|
||||||
|
await pipeline.exec()
|
||||||
setCookie(c, config.sessionCookie, token, {
|
setCookie(c, config.sessionCookie, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "Lax",
|
sameSite: "Lax",
|
||||||
@@ -61,11 +99,50 @@ export async function createSession(
|
|||||||
/** 返回被删掉的 token:调用方要拿它去广播会话吊销,好断掉同一浏览器里其他标签页的连接 */
|
/** 返回被删掉的 token:调用方要拿它去广播会话吊销,好断掉同一浏览器里其他标签页的连接 */
|
||||||
export async function destroySession(c: Context) {
|
export async function destroySession(c: Context) {
|
||||||
const token = getCookie(c, config.sessionCookie)
|
const token = getCookie(c, config.sessionCookie)
|
||||||
if (token) await redis.del(sessionKey(token))
|
if (token) {
|
||||||
|
// 先读出 userId 再删,否则反向索引里会留下一个永远清不掉的成员
|
||||||
|
const userId = await sessionUserId(token)
|
||||||
|
await redis.del(sessionKey(token))
|
||||||
|
if (userId !== null) {
|
||||||
|
await redis.srem(userSessionsKey(userId), token)
|
||||||
|
await clearOnline(userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
deleteCookie(c, config.sessionCookie, { path: "/" })
|
deleteCookie(c, config.sessionCookie, { path: "/" })
|
||||||
return token ?? null
|
return token ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sessionUserId(token: string) {
|
||||||
|
const raw = await redis.get(sessionKey(token))
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
const value = JSON.parse(raw) as StoredSession
|
||||||
|
return Number.isInteger(value.userId) ? value.userId : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一个用户所有设备上的会话真的删掉,并广播给还挂着的 WebSocket。
|
||||||
|
*
|
||||||
|
* 光广播是不够的:`publishSessionRevoked` 只断 WebSocket,HTTP 请求照样能拿着
|
||||||
|
* 那张 cookie 继续用。改密码之后旧密码登出来的会话必须立刻失效,否则「改密码」
|
||||||
|
* 对已经被别人登着的账号毫无作用 —— 而学生密码是明文存着给老师查的,
|
||||||
|
* 改密码正是发现密码泄露之后唯一的补救手段。
|
||||||
|
*/
|
||||||
|
export async function revokeUserSessions(
|
||||||
|
userId: number,
|
||||||
|
reason: SessionRevokedReason,
|
||||||
|
) {
|
||||||
|
const tokens = await redis.smembers(userSessionsKey(userId))
|
||||||
|
if (tokens.length) await redis.del(...tokens.map(sessionKey))
|
||||||
|
await redis.del(userSessionsKey(userId))
|
||||||
|
await clearOnline(userId)
|
||||||
|
await publishSessionRevoked({ userId }, reason)
|
||||||
|
return tokens.length
|
||||||
|
}
|
||||||
|
|
||||||
function readCookie(request: Request, name: string) {
|
function readCookie(request: Request, name: string) {
|
||||||
const header = request.headers.get("cookie")
|
const header = request.headers.get("cookie")
|
||||||
if (!header) return undefined
|
if (!header) return undefined
|
||||||
@@ -84,7 +161,9 @@ export type SessionResult =
|
|||||||
| { user: AuthUser; reason?: undefined }
|
| { user: AuthUser; reason?: undefined }
|
||||||
| { user: null; reason: "anonymous" | "disabled" }
|
| { 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" }
|
if (!token) return { user: null, reason: "anonymous" }
|
||||||
|
|
||||||
const raw = await redis.get(sessionKey(token))
|
const raw = await redis.get(sessionKey(token))
|
||||||
@@ -114,6 +193,7 @@ async function getUserByToken(token: string | undefined): Promise<SessionResult>
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
await redis.del(sessionKey(token))
|
await redis.del(sessionKey(token))
|
||||||
|
await redis.srem(userSessionsKey(session.userId), token)
|
||||||
return { user: null, reason: "anonymous" }
|
return { user: null, reason: "anonymous" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,11 +202,28 @@ async function getUserByToken(token: string | undefined): Promise<SessionResult>
|
|||||||
// 都返回 null 的话,中途被禁用的学生看到的是 401 login-required,
|
// 都返回 null 的话,中途被禁用的学生看到的是 401 login-required,
|
||||||
// 前端据此弹登录框,登进去又被弹 —— 死循环,而且看不出发生了什么。
|
// 前端据此弹登录框,登进去又被弹 —— 死循环,而且看不出发生了什么。
|
||||||
await redis.del(sessionKey(token))
|
await redis.del(sessionKey(token))
|
||||||
|
await redis.srem(userSessionsKey(session.userId), token)
|
||||||
return { user: null, reason: "disabled" }
|
return { user: null, reason: "disabled" }
|
||||||
}
|
}
|
||||||
|
|
||||||
await redis.expire(sessionKey(token), config.sessionTtlSeconds)
|
// 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期,
|
||||||
return { user }
|
// 之后再吊销就找不到这张会话了。两条走一次 pipeline —— 这是全后端最热的 Redis
|
||||||
|
// 路径,每个带鉴权的请求都要走一趟,形状和 touchSession 里那对保持一致
|
||||||
|
const renew = redis
|
||||||
|
.pipeline()
|
||||||
|
.expire(sessionKey(token), config.sessionTtlSeconds)
|
||||||
|
.expire(userSessionsKey(session.userId), config.sessionTtlSeconds)
|
||||||
|
// 在线状态就是搭在这条 pipeline 上记的,见 presence.ts
|
||||||
|
markOnline(renew, session.userId)
|
||||||
|
await renew.exec()
|
||||||
|
// 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
...user,
|
||||||
|
adminType: toAdminType(user.adminType),
|
||||||
|
problemPermission: toProblemPermission(user.problemPermission),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 要区分「未登录」和「已被禁用」的用这个 —— 目前只有鉴权中间件需要 */
|
/** 要区分「未登录」和「已被禁用」的用这个 —— 目前只有鉴权中间件需要 */
|
||||||
@@ -154,13 +251,30 @@ export function readRequestSessionToken(request: Request) {
|
|||||||
/**
|
/**
|
||||||
* 会话还在就续期并返回 true,已登出或已过期返回 false。
|
* 会话还在就续期并返回 true,已登出或已过期返回 false。
|
||||||
*
|
*
|
||||||
* 用 EXPIRE 一条命令同时完成「判断存在」和「续期」,比 GET + EXPIRE 少一趟往返。
|
* 用 EXPIRE 同时完成「判断存在」和「续期」,比 GET + EXPIRE 少一趟往返;两条 EXPIRE
|
||||||
* 续期这件事本身也是要的:HTTP 请求会走 getUserByToken 里的 redis.expire 续期,
|
* 走一次 pipeline,仍然只有一趟。
|
||||||
|
*
|
||||||
|
* 续期这件事本身是要的:HTTP 请求会走 getUserByToken 里的 redis.expire 续期,
|
||||||
* 而只开着页面挂 WebSocket 的人一次请求都不发,不该因此被算成不活跃踢下线。
|
* 而只开着页面挂 WebSocket 的人一次请求都不发,不该因此被算成不活跃踢下线。
|
||||||
|
*
|
||||||
|
* **反向索引必须跟着一起续。** 走到这里的正是那种一次 HTTP 请求都不发的连接,
|
||||||
|
* 它碰不到 getUserByToken 里那两条并排的 expire。只续会话不续索引的话,索引先到期、
|
||||||
|
* 会话却被巡检一直续着,之后改密码 / 禁用账号走 revokeUserSessions 就 SMEMBERS
|
||||||
|
* 不到这张 token —— WebSocket 那边还有 publishSessionRevoked 按 userId 兜底能断掉,
|
||||||
|
* 但 HTTP 一侧拿着那张 cookie 照用不误,而改密码要的恰恰是让 HTTP 立刻失效。
|
||||||
*/
|
*/
|
||||||
export async function touchSession(token: string) {
|
export async function touchSession(token: string, userId: number) {
|
||||||
if (!token) return false
|
if (!token) return false
|
||||||
return (await redis.expire(sessionKey(token), config.sessionTtlSeconds)) === 1
|
const pipeline = redis
|
||||||
|
.pipeline()
|
||||||
|
.expire(sessionKey(token), config.sessionTtlSeconds)
|
||||||
|
.expire(userSessionsKey(userId), config.sessionTtlSeconds)
|
||||||
|
// 只挂着 WebSocket 不发请求的人,在线状态全靠这里(sweepSessions 每 60 秒一轮)
|
||||||
|
markOnline(pipeline, userId)
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
// 索引那条的返回值不看:存量会话(反向索引上线之前签发的)本来就没有索引键,
|
||||||
|
// 续不到很正常,不能因此判定会话已死
|
||||||
|
return results?.[0]?.[1] === 1
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStoredSession(c: Context) {
|
async function getStoredSession(c: Context) {
|
||||||
@@ -178,7 +292,11 @@ 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)
|
const session = await getStoredSession(c)
|
||||||
if (!session) return false
|
if (!session) return false
|
||||||
session.value.contestPasswords[String(contestId)] = password
|
session.value.contestPasswords[String(contestId)] = password
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
import { and, eq, isNull } from "drizzle-orm"
|
||||||
|
|
||||||
|
import { touchSession } from "../auth/session"
|
||||||
|
import { db, schema } from "../db"
|
||||||
|
import { toAdminType } from "@oj2/contract"
|
||||||
|
|
||||||
|
import { TEACHER_ROLES } from "../routes/helpers"
|
||||||
|
import {
|
||||||
|
addRequest,
|
||||||
|
addTeacher,
|
||||||
|
closeRoom,
|
||||||
|
getRequest,
|
||||||
|
getRoom,
|
||||||
|
hasTeacherOnline,
|
||||||
|
listRequests,
|
||||||
|
normalizeCollabLanguage,
|
||||||
|
openRoom,
|
||||||
|
queueAheadOf,
|
||||||
|
removeRequest,
|
||||||
|
removeTeacher,
|
||||||
|
roomOf,
|
||||||
|
teacherSockets,
|
||||||
|
type CollabSocket,
|
||||||
|
type HelpRequest,
|
||||||
|
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 ?? ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 推给老师的列表条目。不含 socket,也不含任何代码内容 */
|
||||||
|
function serializeRequest(request: HelpRequest) {
|
||||||
|
return {
|
||||||
|
studentId: request.studentId,
|
||||||
|
studentName: request.studentName,
|
||||||
|
className: request.className,
|
||||||
|
problemId: request.problemId,
|
||||||
|
problemTitle: request.problemTitle,
|
||||||
|
createdAt: request.createdAt,
|
||||||
|
status: request.status,
|
||||||
|
teacherName: request.teacherName ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把最新的排队位置推给每个还在等的学生。
|
||||||
|
*
|
||||||
|
* queueAhead 原来只在「建请求 / 重连 / 退回排队」这三处推过,前面的人被接走或被
|
||||||
|
* 取消之后不重算 —— 五个人排队、前四个都处理完了,第五个还一直显示「前面还有 4 人」。
|
||||||
|
*/
|
||||||
|
function broadcastQueuePositions() {
|
||||||
|
for (const request of listRequests()) {
|
||||||
|
if (request.status !== "pending") continue
|
||||||
|
sendHelpStatus(request.socket, "pending", {
|
||||||
|
queueAhead: queueAheadOf(request.studentId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 队列变了:老师端收全量列表,排队中的学生各自收自己的新位置。
|
||||||
|
*
|
||||||
|
* 两件事捏在一起是因为它们永远同时发生 —— 拆成两个函数分别调,迟早会在某条
|
||||||
|
* 路径上漏掉一个。
|
||||||
|
*/
|
||||||
|
export function broadcastRequests() {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
type: "requests",
|
||||||
|
list: listRequests().map(serializeRequest),
|
||||||
|
})
|
||||||
|
for (const ws of teacherSockets()) ws.send(payload)
|
||||||
|
broadcastQueuePositions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendHelpStatus(
|
||||||
|
ws: CollabSocket,
|
||||||
|
status: "pending" | "active" | "cancelled" | "no_teacher",
|
||||||
|
extra: Record<string, unknown> = {},
|
||||||
|
) {
|
||||||
|
ws.send(JSON.stringify({ type: "help_status", status, ...extra }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleCollabOpen(ws: CollabSocket) {
|
||||||
|
if (isTeacher(ws)) {
|
||||||
|
addTeacher(ws)
|
||||||
|
// 新上线的老师要立刻看到当前队列,不能等下一次变更
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "requests",
|
||||||
|
list: listRequests().map(serializeRequest),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学生(重)连:如果这个账号名下已经有一条请求(掉线重连回来,或者干脆是
|
||||||
|
// 同账号第二个标签页),把它迁移到这条新连接上,并把当前状态补发回去 ——
|
||||||
|
// 前端 onConnected 时会先把本地状态清空等着这条补发,不发的话就永远卡在
|
||||||
|
// idle;不迁移 socket 归属的话,sendHelpStatus/accept 等后续推送会发到一条
|
||||||
|
// 已经不用的旧连接上,新连接(新标签页)什么都收不到
|
||||||
|
const request = getRequest(ws.data.userId)
|
||||||
|
if (!request) return
|
||||||
|
request.socket = ws
|
||||||
|
|
||||||
|
const room = getRoom(ws.data.userId)
|
||||||
|
if (room && room.studentSocket !== ws) {
|
||||||
|
// 协作中的学生换了一条连接。**不迁移房间,直接拆掉。**
|
||||||
|
//
|
||||||
|
// 原来这里是把 studentSocket 换成新连接就算完,转发确实转到新连接了,
|
||||||
|
// 但客户端接不住:前端每次连接建立都会把 room 清成 null(旧连接的状态
|
||||||
|
// 不该越过重连活下来),而这里只补发了 help_status,没补 room_open ——
|
||||||
|
// 于是学生页面显示「老师正在帮你」、编辑器却早就把 yCollab 摘了,
|
||||||
|
// 老师照常敲字、一个字也到不了对面。正是 handleCollabBinary 注释里说的
|
||||||
|
// 「看起来在协作、其实各看各的」,比老实断开更糟。
|
||||||
|
//
|
||||||
|
// 而补发 room_open 也修不好:Yjs 的文档状态跟着旧连接一起没了,新连接
|
||||||
|
// 只能新建 Y.Doc,再拿学生编辑器里的内容当种子插进去,就会和老师那份
|
||||||
|
// 已有内容合并成重复文本(两份 doc 的 item 身份不同,CRDT 不去重)。
|
||||||
|
// 续接一个 CRDT 会话不是哑转发层做得到的事。
|
||||||
|
//
|
||||||
|
// 所以退回排队,老师再点一次 —— 和老师掉线走的是同一条路子。学生的代码
|
||||||
|
// 一直在他自己的编辑器里,不受影响。
|
||||||
|
closeRoom(room.studentId)
|
||||||
|
room.studentSocket.data.roomOwnerId = undefined
|
||||||
|
room.teacherSocket.data.roomOwnerId = undefined
|
||||||
|
room.teacherSocket.send(
|
||||||
|
JSON.stringify({ type: "room_closed", reason: "peer_offline" }),
|
||||||
|
)
|
||||||
|
request.status = "pending"
|
||||||
|
request.teacherId = undefined
|
||||||
|
request.teacherName = undefined
|
||||||
|
broadcastRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status === "pending") {
|
||||||
|
sendHelpStatus(ws, "pending", { queueAhead: queueAheadOf(ws.data.userId) })
|
||||||
|
} else if (request.status === "active") {
|
||||||
|
sendHelpStatus(ws, "active", { teacherName: request.teacherName ?? "" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 老师从房间消失(掉线,或发送失败被判定为事实上不可达):请求退回排队,
|
||||||
|
* 学生不必重新点 —— 可能只是网络抖了一下 */
|
||||||
|
function requeueAfterTeacherGone(studentId: number) {
|
||||||
|
const request = getRequest(studentId)
|
||||||
|
if (request) {
|
||||||
|
request.status = "pending"
|
||||||
|
request.teacherId = undefined
|
||||||
|
request.teacherName = undefined
|
||||||
|
sendHelpStatus(request.socket, "pending", {
|
||||||
|
queueAhead: queueAheadOf(studentId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleCollabClose(ws: CollabSocket) {
|
||||||
|
if (isTeacher(ws)) removeTeacher(ws)
|
||||||
|
|
||||||
|
const room = roomOf(ws)
|
||||||
|
if (room) {
|
||||||
|
closeRoom(room.studentId)
|
||||||
|
room.studentSocket.data.roomOwnerId = undefined
|
||||||
|
room.teacherSocket.data.roomOwnerId = undefined
|
||||||
|
const peer =
|
||||||
|
ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
|
||||||
|
peer.send(JSON.stringify({ type: "room_closed", reason: "peer_offline" }))
|
||||||
|
|
||||||
|
if (ws === room.teacherSocket) {
|
||||||
|
requeueAfterTeacherGone(room.studentId)
|
||||||
|
} else {
|
||||||
|
// 学生掉线:请求随人走
|
||||||
|
removeRequest(room.studentId)
|
||||||
|
}
|
||||||
|
} else if (!isTeacher(ws)) {
|
||||||
|
// 还在排队时关掉页面,请求也该消失 —— 但只能收自己这条。同一账号可能开了两个
|
||||||
|
// 标签页,另一个标签页可能已经把请求接成 active(甚至已经换了一拨新请求),
|
||||||
|
// 不加 socket 归属和状态检查,这里会把活跃房间的请求记录连根拔起
|
||||||
|
const request = getRequest(ws.data.userId)
|
||||||
|
if (request && request.socket === ws && request.status !== "active") {
|
||||||
|
removeRequest(ws.data.userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleCollabMessage(ws: CollabSocket, raw: string) {
|
||||||
|
let message: {
|
||||||
|
type?: unknown
|
||||||
|
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: "消息格式不对,请刷新页面重试",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心跳不查库,和 /ws/submissions 的处理一致
|
||||||
|
if (message.type === "ping") {
|
||||||
|
ws.send(JSON.stringify({ type: "pong", timestamp: message.timestamp }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 握手时校验过一次不算数 —— 这条连接能挂几个小时
|
||||||
|
if (!(await touchSession(ws.data.token, ws.data.userId))) {
|
||||||
|
ws.close(1008, "Session expired")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (message.type) {
|
||||||
|
case "help_request":
|
||||||
|
await handleHelpRequest(ws, message.problemId, message.language)
|
||||||
|
return
|
||||||
|
case "help_language":
|
||||||
|
handleHelpLanguage(ws, message.language)
|
||||||
|
return
|
||||||
|
case "help_cancel":
|
||||||
|
handleHelpCancel(ws)
|
||||||
|
return
|
||||||
|
case "accept":
|
||||||
|
await handleAccept(ws, message.studentId)
|
||||||
|
return
|
||||||
|
case "reject":
|
||||||
|
await handleReject(ws, message.studentId)
|
||||||
|
return
|
||||||
|
case "leave":
|
||||||
|
handleLeave(ws, message.reason)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "不认识的操作,请刷新页面重试",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHelpRequest(
|
||||||
|
ws: CollabSocket,
|
||||||
|
problemId: unknown,
|
||||||
|
language: unknown,
|
||||||
|
) {
|
||||||
|
if (typeof problemId !== "string" || !problemId) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "error", message: "题号不对,请刷新页面重试" }),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isTeacher(ws)) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: "教师不能发起求助" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!hasTeacherOnline()) {
|
||||||
|
sendHelpStatus(ws, "no_teacher")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只认非比赛题:contest_id 为空的那条。比赛题不提供求助
|
||||||
|
const [problem] = await db
|
||||||
|
.select({ title: schema.problem.title })
|
||||||
|
.from(schema.problem)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.problem.displayId, problemId),
|
||||||
|
isNull(schema.problem.contestId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
if (!problem) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({ type: "error", message: "题目不存在或不支持求助" }),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = getRequest(ws.data.userId)
|
||||||
|
// 已经在协作中就不重复登记,否则会把正在进行的房间挤掉
|
||||||
|
if (existing?.status === "active") return
|
||||||
|
|
||||||
|
const [student] = await db
|
||||||
|
.select({ className: schema.user.className })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(eq(schema.user.id, ws.data.userId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
addRequest({
|
||||||
|
studentId: ws.data.userId,
|
||||||
|
studentName: ws.data.username ?? "",
|
||||||
|
className: student?.className ?? null,
|
||||||
|
problemId,
|
||||||
|
problemTitle: problem.title,
|
||||||
|
language: normalizeCollabLanguage(language),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
status: "pending",
|
||||||
|
socket: ws,
|
||||||
|
})
|
||||||
|
sendHelpStatus(ws, "pending", { queueAhead: queueAheadOf(ws.data.userId) })
|
||||||
|
broadcastRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生在求助期间换了语言。
|
||||||
|
*
|
||||||
|
* 只更新语言,不动队列位置、不动房间 —— 换语言不该让他重新排队。已经在协作中的
|
||||||
|
* 话再推一条 room_language,老师端的高亮和补全立刻跟着换;不推的话老师会拿着
|
||||||
|
* 建房那一刻的语言,对着一套错的补全替学生写代码。
|
||||||
|
*/
|
||||||
|
function handleHelpLanguage(ws: CollabSocket, language: unknown) {
|
||||||
|
const request = getRequest(ws.data.userId)
|
||||||
|
// 比对 socket 归属:同账号的另一个标签页停在别的题上切语言,不该改这条求助
|
||||||
|
if (!request || request.socket !== ws) return
|
||||||
|
const next = normalizeCollabLanguage(language)
|
||||||
|
if (request.language === next) return
|
||||||
|
request.language = next
|
||||||
|
|
||||||
|
const room = getRoom(ws.data.userId)
|
||||||
|
if (!room) return
|
||||||
|
room.language = next
|
||||||
|
room.teacherSocket.send(
|
||||||
|
JSON.stringify({ type: "room_language", language: next }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHelpCancel(ws: CollabSocket) {
|
||||||
|
const request = getRequest(ws.data.userId)
|
||||||
|
// 不比对 socket 归属:取消的是这个学生自己的求助,不管从他哪个标签页发起都
|
||||||
|
// 合法——getRequest(ws.data.userId) 已经把范围锁在这一个用户上了,不是跨用户
|
||||||
|
// 操作。这里和 handleCollabClose 的排队分支不是同一类问题:那边关闭事件是
|
||||||
|
// 「顺带」触发的,必须认出是不是本人这条连接;这里是用户主动点了取消
|
||||||
|
if (!request || request.status === "active") return
|
||||||
|
removeRequest(ws.data.userId)
|
||||||
|
broadcastRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAccept(ws: CollabSocket, studentId: unknown) {
|
||||||
|
if (!isTeacher(ws)) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: "无权限" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (typeof studentId !== "number") {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "学生标识不对,请刷新页面重试",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 握手时的 adminType 是那一刻的快照,接单前按库里的真实身份复核一次。
|
||||||
|
// 注意读的是库,不是前端传的任何东西 —— 前端的演示模式在这里没有意义
|
||||||
|
const [teacher] = await db
|
||||||
|
.select({ adminType: schema.user.adminType })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.user.id, ws.data.userId),
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
if (!teacher || !TEACHER_ROLES.includes(toAdminType(teacher.adminType))) {
|
||||||
|
ws.close(1008, "Permission revoked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上面这次查询是个 await 点,等待期间这条连接可能已经断开——断线时
|
||||||
|
// handleCollabClose 已经把它从 teacherSockets 摘掉了,用它来判断这次 accept
|
||||||
|
// 还作不作数。continuation 里不能再对着一个死 socket 建房间
|
||||||
|
if (!teacherSockets().has(ws)) return
|
||||||
|
|
||||||
|
// 老师同时只能在一个房间
|
||||||
|
if (roomOf(ws)) {
|
||||||
|
ws.send(JSON.stringify({ type: "error", message: "请先结束当前协作" }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = getRequest(studentId)
|
||||||
|
if (!request || request.status === "active" || getRoom(studentId)) {
|
||||||
|
// 被别人接走了、学生已经撤销,或者这个学生 id 名下已经有一个房间在挂着
|
||||||
|
// (正常路径走不到,是两个标签页 + 断线重连缝隙的最后一道闸)——
|
||||||
|
// 回一份最新列表让老师端自己纠正
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "requests",
|
||||||
|
list: listRequests().map(serializeRequest),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
request.status = "active"
|
||||||
|
request.teacherId = ws.data.userId
|
||||||
|
request.teacherName = ws.data.username ?? ""
|
||||||
|
|
||||||
|
ws.data.roomOwnerId = studentId
|
||||||
|
request.socket.data.roomOwnerId = studentId
|
||||||
|
openRoom({
|
||||||
|
studentId,
|
||||||
|
teacherId: ws.data.userId,
|
||||||
|
studentSocket: request.socket,
|
||||||
|
teacherSocket: ws,
|
||||||
|
problemId: request.problemId,
|
||||||
|
language: request.language,
|
||||||
|
})
|
||||||
|
|
||||||
|
const openFrame = (peerName: string, peerRole: "student" | "teacher") =>
|
||||||
|
JSON.stringify({
|
||||||
|
type: "room_open",
|
||||||
|
peer: { name: peerName, role: peerRole },
|
||||||
|
problemId: request.problemId,
|
||||||
|
language: request.language,
|
||||||
|
})
|
||||||
|
request.socket.send(openFrame(request.teacherName, "teacher"))
|
||||||
|
ws.send(openFrame(request.studentName, "student"))
|
||||||
|
sendHelpStatus(request.socket, "active", { teacherName: request.teacherName })
|
||||||
|
broadcastRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject(ws: CollabSocket, studentId: unknown) {
|
||||||
|
if (!isTeacher(ws) || typeof studentId !== "number") return
|
||||||
|
|
||||||
|
// reject 很少见,多这一次查询不心疼;不然握手快照挡不住"连接活着期间被降级
|
||||||
|
// 或禁用"的老师继续掐掉排队中的求助
|
||||||
|
const [teacher] = await db
|
||||||
|
.select({ adminType: schema.user.adminType })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.user.id, ws.data.userId),
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
if (!teacher || !TEACHER_ROLES.includes(toAdminType(teacher.adminType))) {
|
||||||
|
ws.close(1008, "Permission revoked")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = getRequest(studentId)
|
||||||
|
// 已经在协作中的不能靠 reject 掐掉,那是 leave 的事
|
||||||
|
if (!request || request.status === "active") return
|
||||||
|
removeRequest(studentId)
|
||||||
|
sendHelpStatus(request.socket, "cancelled")
|
||||||
|
broadcastRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动退出房间。**两种语义,靠 reason 分**:
|
||||||
|
*
|
||||||
|
* - 不带 reason(或 `"done"`)—— 有人点了「结束协作」,这次帮忙到此结束,
|
||||||
|
* 求助记录一并清掉;
|
||||||
|
* - `"left"` —— 人只是离开了这道题的页面(教师端「页面即协作现场」,跳走就不在
|
||||||
|
* 房间里了)。**这跟他掉线是同一件事**,所以走同一条收尾:教师离开 → 求助退回
|
||||||
|
* 排队,学生不用重新举手,老师回来再点一次就接上;学生离开 → 求助随人清掉。
|
||||||
|
*
|
||||||
|
* 分开是因为两者对学生的意义完全不同:前者是「搞定了」,后者是「老师先走一下」,
|
||||||
|
* 而原来都按前者处理 —— 老师点一下「提交信息」,学生就得重新举手。
|
||||||
|
*/
|
||||||
|
function handleLeave(ws: CollabSocket, reason: unknown) {
|
||||||
|
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`。对他来说这是「我自己干的」,不该看到一句
|
||||||
|
* 「对方离开了」,也不该看到「老师已结束这次帮忙」。
|
||||||
|
*/
|
||||||
|
function teardownRoom(
|
||||||
|
room: Room,
|
||||||
|
reason: "done" | "peer_offline" | "peer_left",
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (reason === "done") {
|
||||||
|
removeRequest(room.studentId)
|
||||||
|
} else if (offlineSide === "teacher") {
|
||||||
|
requeueAfterTeacherGone(room.studentId)
|
||||||
|
} else if (offlineSide === "student") {
|
||||||
|
removeRequest(room.studentId)
|
||||||
|
}
|
||||||
|
broadcastRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yjs 的 update / awareness 帧。服务端不解析、不留存,只转发给房间里的另一个人。
|
||||||
|
*
|
||||||
|
* 「服务端不知道代码内容」是有意的:这个通道要做的事只有认证和分房间,
|
||||||
|
* 权限由 accept 时的库查询决定,与帧里装的是什么无关。
|
||||||
|
*/
|
||||||
|
export function handleCollabBinary(
|
||||||
|
ws: CollabSocket,
|
||||||
|
data: Buffer | Uint8Array,
|
||||||
|
) {
|
||||||
|
// 空帧:Bun.serve 探测过,send() 对 0 字节帧也回 0(同一个返回值,
|
||||||
|
// 真实送达和真实丢弃分不清),不转发、不参与下面的失败判定,直接忽略。
|
||||||
|
// 否则任何一方发一个 0 字节二进制帧就能把整间房拆掉
|
||||||
|
if (data.length === 0) return
|
||||||
|
|
||||||
|
const room = roomOf(ws)
|
||||||
|
if (!room) return
|
||||||
|
const peer =
|
||||||
|
ws === room.teacherSocket ? room.studentSocket : room.teacherSocket
|
||||||
|
const sent = peer.send(data)
|
||||||
|
// Bun.serve 探测过:-1 不代表失败,是背压——消息已排队,最终会送达(实测 8MB
|
||||||
|
// 帧照样完整到达);只有 0 才是真的丢了(对端事实上已经断开)。之前把 <= 0
|
||||||
|
// 当成失败,慢网/大粘贴一触发背压就把正常房间拆掉,是本该保护的场景反而先死
|
||||||
|
if (sent === 0) {
|
||||||
|
// 真丢帧:两边的 Yjs 文档会从此悄悄分叉——教学工具里"看起来在协作、其实
|
||||||
|
// 各看各的代码"比老实断开更糟,不做续传,直接拆房间。和教师断线走同一条
|
||||||
|
// 收尾路径:老师那侧消失就把请求退回排队,不让学生卡死在 active 出不来
|
||||||
|
console.error("Collab binary forward failed, tearing down room", {
|
||||||
|
studentId: room.studentId,
|
||||||
|
})
|
||||||
|
const offlineSide = peer === room.teacherSocket ? "teacher" : "student"
|
||||||
|
teardownRoom(room, "peer_offline", offlineSide)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* 课堂求助的内存状态。
|
||||||
|
*
|
||||||
|
* 不落库是有意的:求助是课堂上的即时行为,学生关掉页面这条请求就该消失。
|
||||||
|
* 服务端只有一个 serve 进程(main.ts 单二进制 + 子命令,compose 里 oj-api 一个容器),
|
||||||
|
* 所以内存态够用,不需要 Redis 同步。进程重启丢掉全部状态,两端重连后回到干净状态。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { normalizeLanguage } from "@oj2/contract"
|
||||||
|
|
||||||
|
export type CollabSocket = Bun.ServerWebSocket<
|
||||||
|
import("../websocket").SubmissionSocketData
|
||||||
|
>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协作支持的语言。和前端 utils/types.ts 里的 LANGUAGE 对齐,去掉 Flowchart ——
|
||||||
|
* 流程图题没有代码编辑器,求助入口本身就是隐藏的。
|
||||||
|
*/
|
||||||
|
export const COLLAB_LANGUAGES = [
|
||||||
|
"C",
|
||||||
|
"C++",
|
||||||
|
"Python",
|
||||||
|
"Java",
|
||||||
|
"JavaScript",
|
||||||
|
"Golang",
|
||||||
|
"SQL",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelpRequest {
|
||||||
|
studentId: number
|
||||||
|
studentName: string
|
||||||
|
className: string | null
|
||||||
|
/** 题目的展示号(problem._id 列,前端一路用的都是它),不是自增主键 */
|
||||||
|
problemId: string
|
||||||
|
problemTitle: string
|
||||||
|
/**
|
||||||
|
* 学生编辑器当前的语言。决定教师端弹框用哪套高亮和补全 —— 求助时带上,
|
||||||
|
* 协作期间学生切语言会用 help_language 更新这里
|
||||||
|
*/
|
||||||
|
language: CollabLanguage
|
||||||
|
createdAt: number
|
||||||
|
status: "pending" | "active"
|
||||||
|
teacherId?: number
|
||||||
|
teacherName?: string
|
||||||
|
socket: CollabSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 求助表,以学生为键 —— 一个学生同时只有一个求助 */
|
||||||
|
const requests = new Map<number, HelpRequest>()
|
||||||
|
|
||||||
|
/** 在线老师的连接。用于推列表,也用于判断 no_teacher */
|
||||||
|
const teachers = new Set<CollabSocket>()
|
||||||
|
|
||||||
|
export function addRequest(request: HelpRequest) {
|
||||||
|
requests.set(request.studentId, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRequest(studentId: number) {
|
||||||
|
return requests.get(studentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeRequest(studentId: number) {
|
||||||
|
return requests.delete(studentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按发起时间正序。老师端按等待时长排序展示,不强制先来先到 */
|
||||||
|
export function listRequests() {
|
||||||
|
return Array.from(requests.values()).sort((a, b) => a.createdAt - b.createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 比自己早创建、且仍在排队的请求数 */
|
||||||
|
export function queueAheadOf(studentId: number) {
|
||||||
|
const self = requests.get(studentId)
|
||||||
|
if (!self) return 0
|
||||||
|
let ahead = 0
|
||||||
|
for (const request of requests.values()) {
|
||||||
|
if (request.status === "pending" && request.createdAt < self.createdAt)
|
||||||
|
ahead += 1
|
||||||
|
}
|
||||||
|
return ahead
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addTeacher(ws: CollabSocket) {
|
||||||
|
teachers.add(ws)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTeacher(ws: CollabSocket) {
|
||||||
|
teachers.delete(ws)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasTeacherOnline() {
|
||||||
|
return teachers.size > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function teacherSockets() {
|
||||||
|
return teachers
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Room {
|
||||||
|
/** 房主 = 学生。房间以学生为键,因为学生的代码是内容源 */
|
||||||
|
studentId: number
|
||||||
|
teacherId: number
|
||||||
|
studentSocket: CollabSocket
|
||||||
|
teacherSocket: CollabSocket
|
||||||
|
problemId: string
|
||||||
|
/** 建房那一刻学生的语言,之后跟着 help_language 走 */
|
||||||
|
language: CollabLanguage
|
||||||
|
}
|
||||||
|
|
||||||
|
const rooms = new Map<number, Room>()
|
||||||
|
|
||||||
|
export function openRoom(room: Room) {
|
||||||
|
rooms.set(room.studentId, room)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRoom(studentId: number) {
|
||||||
|
return rooms.get(studentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeRoom(studentId: number) {
|
||||||
|
return rooms.delete(studentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这条连接当前所在的房间。ws.data.roomOwnerId 是房主(学生)的 id */
|
||||||
|
export function roomOf(ws: CollabSocket) {
|
||||||
|
const ownerId = ws.data.roomOwnerId
|
||||||
|
return ownerId === undefined ? undefined : rooms.get(ownerId)
|
||||||
|
}
|
||||||
+22
-5
@@ -25,7 +25,10 @@ function loadRepoRootEnv() {
|
|||||||
if (eq <= 0) continue
|
if (eq <= 0) continue
|
||||||
const key = trimmed.slice(0, eq).trim()
|
const key = trimmed.slice(0, eq).trim()
|
||||||
if (process.env[key] !== undefined) continue
|
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 {
|
} catch {
|
||||||
// 根目录没有 .env 是正常情况(例如生产用真实环境变量注入),静默跳过
|
// 根目录没有 .env 是正常情况(例如生产用真实环境变量注入),静默跳过
|
||||||
@@ -64,18 +67,24 @@ export const config = {
|
|||||||
port: Number(process.env.PORT ?? 3000),
|
port: Number(process.env.PORT ?? 3000),
|
||||||
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6380",
|
redisUrl: process.env.REDIS_URL ?? "redis://localhost:6380",
|
||||||
sessionCookie: "oj2_session",
|
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",
|
secureCookies: process.env.COOKIE_SECURE === "true",
|
||||||
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
|
judgeServerUrl: process.env.JUDGE_SERVER_URL ?? "http://localhost:8081",
|
||||||
judgeServerToken: judgeServerToken(),
|
judgeServerToken: judgeServerToken(),
|
||||||
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
|
judgeConcurrency: Number(process.env.JUDGE_CONCURRENCY ?? 2),
|
||||||
avatarDirectory: repoPath(process.env.AVATAR_DIRECTORY ?? "data/avatar"),
|
avatarDirectory: repoPath(process.env.AVATAR_DIRECTORY ?? "data/avatar"),
|
||||||
// 判题沙箱把这个目录挂成只读的 /test_case,两边必须指同一处
|
// 判题沙箱把这个目录挂成只读的 /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"),
|
uploadDirectory: repoPath(process.env.UPLOAD_DIRECTORY ?? "data/upload"),
|
||||||
// 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。
|
// 一言数据集(hitokoto.cn 官方导出),和旧后端读同一份:容器里是 /data/hitokoto。
|
||||||
// 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。
|
// 本机 dev 默认路径下没有这份数据,读不到就回落到内置的几条,不影响启动。
|
||||||
hitokotoDirectory: repoPath(process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto"),
|
hitokotoDirectory: repoPath(
|
||||||
|
process.env.HITOKOTO_DIRECTORY ?? "data/hitokoto",
|
||||||
|
),
|
||||||
/**
|
/**
|
||||||
* WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。
|
* WebSocket 升级时额外放行的来源(逗号分隔的完整 origin,如 https://oj.example.com)。
|
||||||
* 同源本来就放行,只有前后端分处不同域名时才需要配。
|
* 同源本来就放行,只有前后端分处不同域名时才需要配。
|
||||||
@@ -87,8 +96,16 @@ export const config = {
|
|||||||
uploadUriPrefix: process.env.UPLOAD_URI_PREFIX ?? "/public/upload",
|
uploadUriPrefix: process.env.UPLOAD_URI_PREFIX ?? "/public/upload",
|
||||||
avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar",
|
avatarUriPrefix: process.env.AVATAR_URI_PREFIX ?? "/public/avatar",
|
||||||
aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com",
|
aiBaseUrl: process.env.AI_BASE_URL ?? "https://api.deepseek.com",
|
||||||
|
/** 只用来写 ai_analysis.provider 这一列,换 provider 时和 AI_BASE_URL 一起改 */
|
||||||
|
aiProvider: process.env.AI_PROVIDER ?? "deepseek",
|
||||||
aiKey: process.env.AI_KEY ?? "",
|
aiKey: process.env.AI_KEY ?? "",
|
||||||
aiModel: process.env.AI_MODEL ?? "deepseek-v4-flash",
|
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",
|
ruffPath: process.env.RUFF_PATH ?? "ruff",
|
||||||
clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format",
|
clangFormatPath: process.env.CLANG_FORMAT_PATH ?? "clang-format",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 把公开提交列表的部分索引从 (create_time) 换成 (create_time, id)。
|
||||||
|
--
|
||||||
|
-- 为什么要加 id:列表分页改用「offset → 游标」两步查询(见 routes/submission.ts 的
|
||||||
|
-- paginateSubmissionRows)。create_time 由 `new Date().toISOString()` 生成,只有毫秒
|
||||||
|
-- 精度,同毫秒的两条提交分不出先后,游标回查时上一页末行会重复出现在下一页页首。
|
||||||
|
-- 加上 id 让排序变成全序,两步走同一个顺序,翻页结果精确。
|
||||||
|
--
|
||||||
|
-- 两列都是 ASC:查询 `ORDER BY create_time DESC, id DESC` 靠 Index Only Scan Backward
|
||||||
|
-- 反着扫这条索引。写成 (create_time DESC, id DESC) 反而用不上——ORDER BY 的 DESC 默认
|
||||||
|
-- NULLS FIRST,索引的 DESC 默认 NULLS LAST,规划器认为出不了序,会退化成全量排序。
|
||||||
|
--
|
||||||
|
-- 锁窗口:这里是普通 CREATE INDEX(不是 CONCURRENTLY),建索引期间**阻塞写入**。
|
||||||
|
-- 生产快照 12.3 万行 / 171MB 上实测不到 1 秒,且部署本来就在停机窗口里做,够用。
|
||||||
|
-- 真要热更再拆成两条带 `oj2:no-transaction` 的迁移。
|
||||||
|
--
|
||||||
|
-- 先 DROP 再 CREATE 是安全的:两条语句在同一个事务里(migrate.ts 一条迁移一个事务),
|
||||||
|
-- 中途失败会整体回滚,不会留下「老的没了、新的没建成」的中间态。
|
||||||
|
|
||||||
|
DROP INDEX "submission_public_create_time_idx";--> statement-breakpoint
|
||||||
|
CREATE INDEX "submission_public_create_time_id_idx" ON "submission" USING btree ("create_time","id") WHERE "submission"."contest_id" is null;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "tutorial_progress" (
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"tutorial_id" integer NOT NULL,
|
||||||
|
"view_count" integer DEFAULT 0 NOT NULL,
|
||||||
|
"total_seconds" integer DEFAULT 0 NOT NULL,
|
||||||
|
"first_viewed_at" timestamp with time zone NOT NULL,
|
||||||
|
"last_viewed_at" timestamp with time zone NOT NULL,
|
||||||
|
CONSTRAINT "tutorial_progress_pkey" PRIMARY KEY("user_id","tutorial_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "tutorial_progress" ADD CONSTRAINT "tutorial_progress_user_id_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tutorial_progress" ADD CONSTRAINT "tutorial_progress_tutorial_id_fk_tutorial_id" FOREIGN KEY ("tutorial_id") REFERENCES "public"."tutorial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "tutorial_progress_tutorial_id_idx" ON "tutorial_progress" USING btree ("tutorial_id");
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE "exercise_attempt" (
|
||||||
|
"user_id" integer NOT NULL,
|
||||||
|
"exercise_id" integer NOT NULL,
|
||||||
|
"attempts" integer DEFAULT 0 NOT NULL,
|
||||||
|
"wrong_attempts" integer DEFAULT 0 NOT NULL,
|
||||||
|
"solved" boolean DEFAULT false NOT NULL,
|
||||||
|
"attempts_to_solve" integer,
|
||||||
|
"last_wrong_answer" text,
|
||||||
|
"first_attempt_at" timestamp with time zone NOT NULL,
|
||||||
|
"last_attempt_at" timestamp with time zone NOT NULL,
|
||||||
|
"solved_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "exercise_attempt_pkey" PRIMARY KEY("user_id","exercise_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "exercise_attempt" ADD CONSTRAINT "exercise_attempt_user_id_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "exercise_attempt" ADD CONSTRAINT "exercise_attempt_exercise_id_fk_exercise_id" FOREIGN KEY ("exercise_id") REFERENCES "public"."exercise"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "exercise_attempt_exercise_id_idx" ON "exercise_attempt" USING btree ("exercise_id");
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- 删掉比赛公告表。旧 Django 栈有「比赛公告」这个功能,OJ2 从头到尾没有搬:
|
||||||
|
-- 没有任何路由、契约或前端页面引用它,表建在那里纯粹是 introspect 0000 时一起拉进来的。
|
||||||
|
--
|
||||||
|
-- 已核实:
|
||||||
|
-- * 没有任何表外键引用 contest_announcement,它只有两条指向 contest / user 的出边,
|
||||||
|
-- 删掉是自洽的;
|
||||||
|
-- * 序列 contest_announcement_id_seq 由本表 owned,随 DROP TABLE 一并消失;
|
||||||
|
-- * 数据:生产快照(db_backup_2026_08_07)里只有 1 行,是 2022 年 4 月挂在 contest 1 上的
|
||||||
|
-- 一条测试公告(「四月月赛」)。OJ2 侧没有写入路径,这个数字不会再增长。
|
||||||
|
--
|
||||||
|
-- 不写 CASCADE,同 0002:万一将来真有别的东西引用了,宁可这里报错,也别被悄悄级联掉。
|
||||||
|
DROP TABLE IF EXISTS contest_announcement;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
ALTER TABLE "submission" ADD COLUMN "problemset_id" bigint;--> statement-breakpoint
|
||||||
|
ALTER TABLE "submission" ADD CONSTRAINT "submission_problemset_id_fk_problemset_id" FOREIGN KEY ("problemset_id") REFERENCES "public"."problemset"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "submission_problemset_id_idx" ON "submission" USING btree ("problemset_id") WHERE "submission"."problemset_id" is not null;--> statement-breakpoint
|
||||||
|
-- 历史回填。老提交没有入口信息,唯一可考的是 problemset_submission:它记的是
|
||||||
|
-- 「这条提交让这道题在这个题单里算完成了」,本来就是刷题单刷出来的那一条,标出来不算冤枉。
|
||||||
|
-- 覆盖不到的是同一道题在此之前的 WA 和之后的重复 AC —— 那些只能留空,往后新提交才准。
|
||||||
|
-- 一条提交在多个题单里都记过账时(recordSolvedProblem 会记进所有已加入的题单),
|
||||||
|
-- 任取其一:来源入口只有一个,但事后已经分不出是哪个了。
|
||||||
|
UPDATE "submission" SET "problemset_id" = "ps"."problemset_id"
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON ("submission_id") "submission_id", "problemset_id"
|
||||||
|
FROM "problemset_submission" ORDER BY "submission_id", "problemset_id"
|
||||||
|
) AS "ps"
|
||||||
|
WHERE "ps"."submission_id" = "submission"."id" AND "submission"."problemset_id" IS NULL;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "contest" DROP COLUMN "allowed_ip_ranges";--> statement-breakpoint
|
||||||
|
ALTER TABLE "submission" DROP COLUMN "ip";
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
ALTER TABLE "user" DROP COLUMN "auth_token";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "open_api";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "open_api_appkey";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" DROP COLUMN "session_keys";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_profile" DROP COLUMN "blog";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_profile" DROP COLUMN "github";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_profile" DROP COLUMN "school";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_profile" DROP COLUMN "major";--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_profile" DROP COLUMN "language";
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
ALTER TABLE "exercise" DROP CONSTRAINT "exercise_tutorial_id_6fd04055_fk_tutorial_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "flowchart_submission" DROP CONSTRAINT "flowchart_submission_problem_id_8551edbf_fk_problem_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "message" DROP CONSTRAINT "message_submission_id_2fdf8a47_fk_submission_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "problem_tags" DROP CONSTRAINT "problem_tags_problem_id_866ecb8d_fk_problem_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "problem_tags" DROP CONSTRAINT "problem_tags_problemtag_id_72d20571_fk_problem_tag_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_badge" DROP CONSTRAINT "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_problem" DROP CONSTRAINT "problemset_problem_problem_id_fff2d686_fk_problem_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_problem" DROP CONSTRAINT "problemset_problem_problemset_id_350d17fb_fk_problemset_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_progress" DROP CONSTRAINT "problemset_progress_problemset_id_20a9632e_fk_problemset_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_submission" DROP CONSTRAINT "problemset_submission_problem_id_5629b105_fk_problem_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_submission" DROP CONSTRAINT "problemset_submission_problemset_id_85290e17_fk_problemset_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_submission" DROP CONSTRAINT "problemset_submission_submission_id_78e2b807_fk_submission_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "reaction" DROP CONSTRAINT "reaction_problem_id_a7f3b9f3_fk_problem_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_achievement" DROP CONSTRAINT "user_achievement_achievement_id_29db600d_fk_achievement_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_badge" DROP CONSTRAINT "user_badge_badge_id_92a983e9_fk_problemset_badge_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_profile" DROP CONSTRAINT "user_profile_user_id_8fdce8e2_fk_user_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_stat" DROP CONSTRAINT "user_stat_user_id_73337fc0_fk_user_id";
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "exercise" ADD CONSTRAINT "exercise_tutorial_id_6fd04055_fk_tutorial_id" FOREIGN KEY ("tutorial_id") REFERENCES "public"."tutorial"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "flowchart_submission" ADD CONSTRAINT "flowchart_submission_problem_id_8551edbf_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "message" ADD CONSTRAINT "message_submission_id_2fdf8a47_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "problem_tags" ADD CONSTRAINT "problem_tags_problem_id_866ecb8d_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "problem_tags" ADD CONSTRAINT "problem_tags_problemtag_id_72d20571_fk_problem_tag_id" FOREIGN KEY ("problemtag_id") REFERENCES "public"."problem_tag"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_badge" ADD CONSTRAINT "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id" FOREIGN KEY ("problemset_id") REFERENCES "public"."problemset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_problem" ADD CONSTRAINT "problemset_problem_problem_id_fff2d686_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_problem" ADD CONSTRAINT "problemset_problem_problemset_id_350d17fb_fk_problemset_id" FOREIGN KEY ("problemset_id") REFERENCES "public"."problemset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_progress" ADD CONSTRAINT "problemset_progress_problemset_id_20a9632e_fk_problemset_id" FOREIGN KEY ("problemset_id") REFERENCES "public"."problemset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_submission" ADD CONSTRAINT "problemset_submission_problem_id_5629b105_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_submission" ADD CONSTRAINT "problemset_submission_problemset_id_85290e17_fk_problemset_id" FOREIGN KEY ("problemset_id") REFERENCES "public"."problemset"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "problemset_submission" ADD CONSTRAINT "problemset_submission_submission_id_78e2b807_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reaction" ADD CONSTRAINT "reaction_problem_id_a7f3b9f3_fk_problem_id" FOREIGN KEY ("problem_id") REFERENCES "public"."problem"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_achievement" ADD CONSTRAINT "user_achievement_achievement_id_29db600d_fk_achievement_id" FOREIGN KEY ("achievement_id") REFERENCES "public"."achievement"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_badge" ADD CONSTRAINT "user_badge_badge_id_92a983e9_fk_problemset_badge_id" FOREIGN KEY ("badge_id") REFERENCES "public"."problemset_badge"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_profile" ADD CONSTRAINT "user_profile_user_id_8fdce8e2_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_stat" ADD CONSTRAINT "user_stat_user_id_73337fc0_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
CREATE INDEX "user_active_idx" ON "user" USING btree ("is_disabled","last_login" DESC NULLS FIRST);--> statement-breakpoint
|
||||||
|
CREATE INDEX "user_class_name_idx" ON "user" USING btree ("class_name");
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
DROP INDEX "acm_contest_rank_contest_id_21030ccd";--> statement-breakpoint
|
||||||
|
DROP INDEX "acm_contest_rank_user_id_40391ab2";--> statement-breakpoint
|
||||||
|
DROP INDEX "flowchart_submission_id_0dbfc4f9_like";--> statement-breakpoint
|
||||||
|
DROP INDEX "flowchart_submission_problem_id_8551edbf";--> statement-breakpoint
|
||||||
|
DROP INDEX "flowchart_submission_user_id_225c83e8";--> statement-breakpoint
|
||||||
|
DROP INDEX "message_recipient_id_2aa5dd76";--> statement-breakpoint
|
||||||
|
DROP INDEX "message_submission_id_2fdf8a47_like";--> statement-breakpoint
|
||||||
|
DROP INDEX "problem__id_919b1d80";--> statement-breakpoint
|
||||||
|
DROP INDEX "problem_contest_id_328e013a";--> statement-breakpoint
|
||||||
|
DROP INDEX "problem_tags_problem_id_866ecb8d";--> statement-breakpoint
|
||||||
|
DROP INDEX "problemset_problem_problemset_id_350d17fb";--> statement-breakpoint
|
||||||
|
DROP INDEX "problemset_progress_problemset_id_20a9632e";--> statement-breakpoint
|
||||||
|
DROP INDEX "problemset_submission_problemset_id_85290e17";--> statement-breakpoint
|
||||||
|
DROP INDEX "problemset_submission_submission_id_78e2b807_like";--> statement-breakpoint
|
||||||
|
DROP INDEX "problemset_submission_user_id_915fc9c6";--> statement-breakpoint
|
||||||
|
DROP INDEX "reaction_problem_id_a7f3b9f3";--> statement-breakpoint
|
||||||
|
DROP INDEX "submission_contest_id_775716d5";--> statement-breakpoint
|
||||||
|
DROP INDEX "submission_problem_id_76847b55";--> statement-breakpoint
|
||||||
|
DROP INDEX "submission_user_id_3779a8c1";--> statement-breakpoint
|
||||||
|
DROP INDEX "user_achievement_user_id_b8ec7d6a";--> statement-breakpoint
|
||||||
|
DROP INDEX "user_badge_user_id_a286d718";
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
CREATE INDEX "flowchart_create_time_idx" ON "flowchart_submission" USING btree ("create_time");--> statement-breakpoint
|
||||||
|
CREATE INDEX "submission_language_time_idx" ON "submission" USING btree ("language","create_time") WHERE "submission"."contest_id" is null;--> statement-breakpoint
|
||||||
|
CREATE INDEX "submission_result_time_idx" ON "submission" USING btree ("result","create_time") WHERE "submission"."contest_id" is null;--> statement-breakpoint
|
||||||
|
CREATE INDEX "submission_public_metrics_idx" ON "submission" USING btree ("user_id","problem_id","result","create_time") WHERE "submission"."contest_id" is null;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- 补删 django_migrations。它本该被 0002_drop_django_leftovers 删掉,但生产库里还留着。
|
||||||
|
--
|
||||||
|
-- 2026-09-10 核实(生产库 oj-postgres):
|
||||||
|
-- * 库里 29 张 public 表 = schema.ts 的 28 张 + 这张 django_migrations;
|
||||||
|
-- * 它 0 行,且 0002 里另外 6 张表(auth_group* / auth_permission /
|
||||||
|
-- django_content_type / django_dramatiq_task / django_session)确实都不在了;
|
||||||
|
-- * 全仓(二进制、路由、compose、脚本)零处读写它;
|
||||||
|
-- * 服务器上已无任何 Django 容器,只有 oj-api / oj-worker / oj-web。
|
||||||
|
--
|
||||||
|
-- 为什么 0002 没删干净,已无法从库里复原现场:0002 的记账行(created_at
|
||||||
|
-- 1787740469403)在,说明它当年是执行过的,而 DROP TABLE IF EXISTS 不会因为
|
||||||
|
-- 「表不存在」静默跳过之后的分号——这条迁移只有一个语句块。最可能是事后有人为了
|
||||||
|
-- 「给已有数据的库打基线」手工建了它(CLAUDE.md 里那段基线 SQL 建的是
|
||||||
|
-- drizzle.__drizzle_migrations,不是这张),或从旧 dump 单独恢复过它。
|
||||||
|
-- 来源不明不影响处置:空表 + 零引用,删掉没有任何数据损失。
|
||||||
|
--
|
||||||
|
-- 用 IF EXISTS 是为了**两种环境收敛到同一个结构**:空库自举时 0002 已经把它删了,
|
||||||
|
-- 生产库还留着。新环境跑到这一条是空转,生产库跑到这一条才真正动手,之后两边一致。
|
||||||
|
--
|
||||||
|
-- ⚠️ 这条会被部署的破坏性迁移闸拦下(migrate.ts 的 DESTRUCTIVE_PATTERNS)。
|
||||||
|
-- 那是**有意保留**的:DROP TABLE 该有人看一眼再放行,不值得为一张空表在闸门上开洞。
|
||||||
|
-- 放行前确认已备份,然后:
|
||||||
|
--
|
||||||
|
-- OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
|
||||||
|
DROP TABLE IF EXISTS django_migrations;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- 提交列表「题号」「用户名」两个筛选的索引,用法和实测数据见 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;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- 提交的编辑过程信号(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;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
-- 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");
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- 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;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- 语言值统一成 `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";
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- 把 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')
|
||||||
|
);
|
||||||
@@ -3,9 +3,34 @@ import postgres from "postgres"
|
|||||||
|
|
||||||
import * as schema from "./schema"
|
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)
|
const client = postgres(url)
|
||||||
|
|
||||||
export const db = drizzle(client, { schema })
|
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 }
|
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
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
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
@@ -22,6 +22,132 @@
|
|||||||
"when": 1787740469403,
|
"when": 1787740469403,
|
||||||
"tag": "0002_drop_django_leftovers",
|
"tag": "0002_drop_django_leftovers",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787850608174,
|
||||||
|
"tag": "0003_submission_public_create_time_id_idx",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788271411343,
|
||||||
|
"tag": "0004_add_tutorial_progress",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788272667927,
|
||||||
|
"tag": "0005_add_exercise_attempt",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788402925980,
|
||||||
|
"tag": "0006_drop_contest_announcement",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788408053304,
|
||||||
|
"tag": "0007_add_submission_problemset_id",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788409130862,
|
||||||
|
"tag": "0008_drop_ip_columns",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788409690565,
|
||||||
|
"tag": "0009_drop_django_dead_columns",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788409961010,
|
||||||
|
"tag": "0010_fk_cascade_on_delete",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 11,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788788493497,
|
||||||
|
"tag": "0011_user_lookup_indexes",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788869393805,
|
||||||
|
"tag": "0012_drop_redundant_indexes",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1788913334948,
|
||||||
|
"tag": "0013_add_filter_and_metrics_indexes",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,9 @@ export async function runMigrations() {
|
|||||||
process.exit(2)
|
process.exit(2)
|
||||||
}
|
}
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
console.error(`${migrationsDir} 下没找到任何迁移。镜像里的迁移目录是不是漏拷了?`)
|
console.error(
|
||||||
|
`${migrationsDir} 下没找到任何迁移。镜像里的迁移目录是不是漏拷了?`,
|
||||||
|
)
|
||||||
process.exit(2)
|
process.exit(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +138,16 @@ export async function runMigrations() {
|
|||||||
// 自举时不拦:空库上没有数据可丢,0002 那串 DROP ... IF EXISTS 全是空转。
|
// 自举时不拦:空库上没有数据可丢,0002 那串 DROP ... IF EXISTS 全是空转。
|
||||||
// 拦下来只会逼着每个新环境都带一次 OJ2_ALLOW_DESTRUCTIVE,把这道闸训练成习惯动作 ——
|
// 拦下来只会逼着每个新环境都带一次 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(
|
console.error(
|
||||||
"待执行的迁移里有破坏性语句,已停下:\n" +
|
"待执行的迁移里有破坏性语句,已停下:\n" +
|
||||||
blocked.map(({ tag, reasons }) => ` · ${tag}:${reasons.join(" / ")}`).join("\n") +
|
blocked
|
||||||
|
.map(({ tag, reasons }) => ` · ${tag}:${reasons.join(" / ")}`)
|
||||||
|
.join("\n") +
|
||||||
"\n\n这类改动不可逆,不该在一次日常部署里顺手执行。" +
|
"\n\n这类改动不可逆,不该在一次日常部署里顺手执行。" +
|
||||||
"\n确认已经做过备份之后,用这个显式放行:\n\n" +
|
"\n确认已经做过备份之后,用这个显式放行:\n\n" +
|
||||||
" OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh\n",
|
" OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh\n",
|
||||||
@@ -180,7 +188,9 @@ export async function runMigrations() {
|
|||||||
|
|
||||||
function destructiveReasons(sql: string) {
|
function destructiveReasons(sql: string) {
|
||||||
const bare = stripComments(sql)
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -192,7 +202,9 @@ function destructiveReasons(sql: string) {
|
|||||||
*/
|
*/
|
||||||
function readMigrationTags(): Map<number, string> {
|
function readMigrationTags(): Map<number, string> {
|
||||||
try {
|
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 }>
|
entries?: Array<{ when: number; tag: string }>
|
||||||
}
|
}
|
||||||
return new Map((journal.entries ?? []).map((e) => [e.when, e.tag]))
|
return new Map((journal.entries ?? []).map((e) => [e.when, e.tag]))
|
||||||
@@ -234,7 +246,9 @@ async function applyMigration(
|
|||||||
) {
|
) {
|
||||||
// 只留有可执行内容的段。`readMigrationFiles` 按 `--> statement-breakpoint` 切开后
|
// 只留有可执行内容的段。`readMigrationFiles` 按 `--> statement-breakpoint` 切开后
|
||||||
// 保留原文,所以纯注释段(比如 0002 开头那一大段说明)会自成一段。
|
// 保留原文,所以纯注释段(比如 0002 开头那一大段说明)会自成一段。
|
||||||
const statements = migration.sql.filter((stmt) => stripComments(stmt).trim() !== "")
|
const statements = migration.sql.filter(
|
||||||
|
(stmt) => stripComments(stmt).trim() !== "",
|
||||||
|
)
|
||||||
if (statements.length === 0) {
|
if (statements.length === 0) {
|
||||||
// 上游已经拦过一次(那条兜底检查),走到这里说明拦漏了,宁可响一声也别静默跳过
|
// 上游已经拦过一次(那条兜底检查),走到这里说明拦漏了,宁可响一声也别静默跳过
|
||||||
throw new Error(`${tag} 没有任何可执行语句`)
|
throw new Error(`${tag} 没有任何可执行语句`)
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
import { relations } from "drizzle-orm/relations";
|
|
||||||
import { user, aiAnalysis, announcement, contest, contestAnnouncement, problem, flowchartSubmission, message, submission, tutorial, exercise, problemset, problemsetProblem, problemsetProgress, problemsetSubmission, reaction, problemTags, problemTag, userStat, achievement, userAchievement, problemsetBadge, userBadge, userProfile, acmContestRank } from "./schema";
|
|
||||||
|
|
||||||
export const aiAnalysisRelations = relations(aiAnalysis, ({one}) => ({
|
|
||||||
user: one(user, {
|
|
||||||
fields: [aiAnalysis.userId],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const userRelations = relations(user, ({many}) => ({
|
|
||||||
aiAnalyses: many(aiAnalysis),
|
|
||||||
announcements: many(announcement),
|
|
||||||
contests: many(contest),
|
|
||||||
contestAnnouncements: many(contestAnnouncement),
|
|
||||||
flowchartSubmissions: many(flowchartSubmission),
|
|
||||||
messages_recipientId: many(message, {
|
|
||||||
relationName: "message_recipientId_user_id"
|
|
||||||
}),
|
|
||||||
messages_senderId: many(message, {
|
|
||||||
relationName: "message_senderId_user_id"
|
|
||||||
}),
|
|
||||||
problemsets: many(problemset),
|
|
||||||
problemsetProgresses: many(problemsetProgress),
|
|
||||||
problemsetSubmissions: many(problemsetSubmission),
|
|
||||||
reactions: many(reaction),
|
|
||||||
problems: many(problem),
|
|
||||||
tutorials: many(tutorial),
|
|
||||||
userStats: many(userStat),
|
|
||||||
userAchievements: many(userAchievement),
|
|
||||||
userBadges: many(userBadge),
|
|
||||||
userProfiles: many(userProfile),
|
|
||||||
acmContestRanks: many(acmContestRank),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const announcementRelations = relations(announcement, ({one}) => ({
|
|
||||||
user: one(user, {
|
|
||||||
fields: [announcement.createdById],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const contestRelations = relations(contest, ({one, many}) => ({
|
|
||||||
user: one(user, {
|
|
||||||
fields: [contest.createdById],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
contestAnnouncements: many(contestAnnouncement),
|
|
||||||
problems: many(problem),
|
|
||||||
submissions: many(submission),
|
|
||||||
acmContestRanks: many(acmContestRank),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const contestAnnouncementRelations = relations(contestAnnouncement, ({one}) => ({
|
|
||||||
contest: one(contest, {
|
|
||||||
fields: [contestAnnouncement.contestId],
|
|
||||||
references: [contest.id]
|
|
||||||
}),
|
|
||||||
user: one(user, {
|
|
||||||
fields: [contestAnnouncement.createdById],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const flowchartSubmissionRelations = relations(flowchartSubmission, ({one}) => ({
|
|
||||||
problem: one(problem, {
|
|
||||||
fields: [flowchartSubmission.problemId],
|
|
||||||
references: [problem.id]
|
|
||||||
}),
|
|
||||||
user: one(user, {
|
|
||||||
fields: [flowchartSubmission.userId],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const problemRelations = relations(problem, ({one, many}) => ({
|
|
||||||
flowchartSubmissions: many(flowchartSubmission),
|
|
||||||
problemsetProblems: many(problemsetProblem),
|
|
||||||
problemsetSubmissions: many(problemsetSubmission),
|
|
||||||
reactions: many(reaction),
|
|
||||||
contest: one(contest, {
|
|
||||||
fields: [problem.contestId],
|
|
||||||
references: [contest.id]
|
|
||||||
}),
|
|
||||||
user: one(user, {
|
|
||||||
fields: [problem.createdById],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
problemTags: many(problemTags),
|
|
||||||
submissions: many(submission),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const messageRelations = relations(message, ({one}) => ({
|
|
||||||
user_recipientId: one(user, {
|
|
||||||
fields: [message.recipientId],
|
|
||||||
references: [user.id],
|
|
||||||
relationName: "message_recipientId_user_id"
|
|
||||||
}),
|
|
||||||
user_senderId: one(user, {
|
|
||||||
fields: [message.senderId],
|
|
||||||
references: [user.id],
|
|
||||||
relationName: "message_senderId_user_id"
|
|
||||||
}),
|
|
||||||
submission: one(submission, {
|
|
||||||
fields: [message.submissionId],
|
|
||||||
references: [submission.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const submissionRelations = relations(submission, ({one, many}) => ({
|
|
||||||
messages: many(message),
|
|
||||||
problemsetSubmissions: many(problemsetSubmission),
|
|
||||||
contest: one(contest, {
|
|
||||||
fields: [submission.contestId],
|
|
||||||
references: [contest.id]
|
|
||||||
}),
|
|
||||||
problem: one(problem, {
|
|
||||||
fields: [submission.problemId],
|
|
||||||
references: [problem.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const exerciseRelations = relations(exercise, ({one}) => ({
|
|
||||||
tutorial: one(tutorial, {
|
|
||||||
fields: [exercise.tutorialId],
|
|
||||||
references: [tutorial.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const tutorialRelations = relations(tutorial, ({one, many}) => ({
|
|
||||||
exercises: many(exercise),
|
|
||||||
user: one(user, {
|
|
||||||
fields: [tutorial.createdById],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const problemsetRelations = relations(problemset, ({one, many}) => ({
|
|
||||||
user: one(user, {
|
|
||||||
fields: [problemset.createdById],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
problemsetProblems: many(problemsetProblem),
|
|
||||||
problemsetProgresses: many(problemsetProgress),
|
|
||||||
problemsetSubmissions: many(problemsetSubmission),
|
|
||||||
problemsetBadges: many(problemsetBadge),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const problemsetProblemRelations = relations(problemsetProblem, ({one}) => ({
|
|
||||||
problem: one(problem, {
|
|
||||||
fields: [problemsetProblem.problemId],
|
|
||||||
references: [problem.id]
|
|
||||||
}),
|
|
||||||
problemset: one(problemset, {
|
|
||||||
fields: [problemsetProblem.problemsetId],
|
|
||||||
references: [problemset.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const problemsetProgressRelations = relations(problemsetProgress, ({one}) => ({
|
|
||||||
problemset: one(problemset, {
|
|
||||||
fields: [problemsetProgress.problemsetId],
|
|
||||||
references: [problemset.id]
|
|
||||||
}),
|
|
||||||
user: one(user, {
|
|
||||||
fields: [problemsetProgress.userId],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const problemsetSubmissionRelations = relations(problemsetSubmission, ({one}) => ({
|
|
||||||
problem: one(problem, {
|
|
||||||
fields: [problemsetSubmission.problemId],
|
|
||||||
references: [problem.id]
|
|
||||||
}),
|
|
||||||
problemset: one(problemset, {
|
|
||||||
fields: [problemsetSubmission.problemsetId],
|
|
||||||
references: [problemset.id]
|
|
||||||
}),
|
|
||||||
submission: one(submission, {
|
|
||||||
fields: [problemsetSubmission.submissionId],
|
|
||||||
references: [submission.id]
|
|
||||||
}),
|
|
||||||
user: one(user, {
|
|
||||||
fields: [problemsetSubmission.userId],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const reactionRelations = relations(reaction, ({one}) => ({
|
|
||||||
problem: one(problem, {
|
|
||||||
fields: [reaction.problemId],
|
|
||||||
references: [problem.id]
|
|
||||||
}),
|
|
||||||
user: one(user, {
|
|
||||||
fields: [reaction.userId],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const problemTagsRelations = relations(problemTags, ({one}) => ({
|
|
||||||
problem: one(problem, {
|
|
||||||
fields: [problemTags.problemId],
|
|
||||||
references: [problem.id]
|
|
||||||
}),
|
|
||||||
problemTag: one(problemTag, {
|
|
||||||
fields: [problemTags.problemtagId],
|
|
||||||
references: [problemTag.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const problemTagRelations = relations(problemTag, ({many}) => ({
|
|
||||||
problemTags: many(problemTags),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const userStatRelations = relations(userStat, ({one}) => ({
|
|
||||||
user: one(user, {
|
|
||||||
fields: [userStat.userId],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const userAchievementRelations = relations(userAchievement, ({one}) => ({
|
|
||||||
achievement: one(achievement, {
|
|
||||||
fields: [userAchievement.achievementId],
|
|
||||||
references: [achievement.id]
|
|
||||||
}),
|
|
||||||
user: one(user, {
|
|
||||||
fields: [userAchievement.userId],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const achievementRelations = relations(achievement, ({many}) => ({
|
|
||||||
userAchievements: many(userAchievement),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const userBadgeRelations = relations(userBadge, ({one}) => ({
|
|
||||||
problemsetBadge: one(problemsetBadge, {
|
|
||||||
fields: [userBadge.badgeId],
|
|
||||||
references: [problemsetBadge.id]
|
|
||||||
}),
|
|
||||||
user: one(user, {
|
|
||||||
fields: [userBadge.userId],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const problemsetBadgeRelations = relations(problemsetBadge, ({one, many}) => ({
|
|
||||||
userBadges: many(userBadge),
|
|
||||||
problemset: one(problemset, {
|
|
||||||
fields: [problemsetBadge.problemsetId],
|
|
||||||
references: [problemset.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const userProfileRelations = relations(userProfile, ({one}) => ({
|
|
||||||
user: one(user, {
|
|
||||||
fields: [userProfile.userId],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const acmContestRankRelations = relations(acmContestRank, ({one}) => ({
|
|
||||||
contest: one(contest, {
|
|
||||||
fields: [acmContestRank.contestId],
|
|
||||||
references: [contest.id]
|
|
||||||
}),
|
|
||||||
user: one(user, {
|
|
||||||
fields: [acmContestRank.userId],
|
|
||||||
references: [user.id]
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
+1411
-627
File diff suppressed because it is too large
Load Diff
+31
-12
@@ -1,4 +1,4 @@
|
|||||||
import { flowchartUpdateSchema, type FlowchartUpdate } from "@oj2/contract"
|
import type { FlowchartUpdate } from "@oj2/contract"
|
||||||
|
|
||||||
import { redis } from "./redis"
|
import { redis } from "./redis"
|
||||||
|
|
||||||
@@ -13,7 +13,10 @@ export const configUpdateChannel = "config:updates"
|
|||||||
export const configTopic = "events:config"
|
export const configTopic = "events:config"
|
||||||
|
|
||||||
export async function publishConfigUpdate(key: string, value: unknown) {
|
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 }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,14 +42,19 @@ export async function publishSessionRevoked(
|
|||||||
target: { token: string } | { userId: number },
|
target: { token: string } | { userId: number },
|
||||||
reason: SessionRevokedReason,
|
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 {
|
export function parseSessionRevoked(raw: string): SessionRevoked | null {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(raw) as SessionRevoked
|
const value = JSON.parse(raw) as SessionRevoked
|
||||||
if (typeof value.token !== "string" && !Number.isInteger(value.userId)) return null
|
if (typeof value.token !== "string" && !Number.isInteger(value.userId))
|
||||||
if (value.reason !== "session-ended" && value.reason !== "account-disabled") return null
|
return null
|
||||||
|
if (value.reason !== "session-ended" && value.reason !== "account-disabled")
|
||||||
|
return null
|
||||||
return value
|
return value
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -71,8 +79,11 @@ export function userEventTopic(userId: number) {
|
|||||||
return `events:user:${userId}`
|
return `events:user:${userId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishFlowchartUpdate(userId: number, data: FlowchartUpdate) {
|
export async function publishFlowchartUpdate(
|
||||||
await redis.publish(userEventChannel, JSON.stringify({ userId, data: flowchartUpdateSchema.parse(data) }))
|
userId: number,
|
||||||
|
data: FlowchartUpdate,
|
||||||
|
) {
|
||||||
|
await redis.publish(userEventChannel, JSON.stringify({ userId, data }))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function publishAchievementNotification(
|
export async function publishAchievementNotification(
|
||||||
@@ -80,16 +91,24 @@ export async function publishAchievementNotification(
|
|||||||
achievements: AchievementNotification[],
|
achievements: AchievementNotification[],
|
||||||
) {
|
) {
|
||||||
if (!achievements.length) return
|
if (!achievements.length) return
|
||||||
await redis.publish(userEventChannel, JSON.stringify({
|
await redis.publish(
|
||||||
userId,
|
userEventChannel,
|
||||||
data: { type: "achievement_unlocked", achievements },
|
JSON.stringify({
|
||||||
}))
|
userId,
|
||||||
|
data: { type: "achievement_unlocked", achievements },
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseUserEvent(raw: string): UserEvent | null {
|
export function parseUserEvent(raw: string): UserEvent | null {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(raw) as UserEvent
|
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
|
return value
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { flowchartUpdateSchema } from "@oj2/contract"
|
import type { FlowchartUpdate } from "@oj2/contract"
|
||||||
import { eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
|
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
@@ -32,14 +32,18 @@ function parseEvaluation(value: string) {
|
|||||||
const json = block ?? value.match(/\{[\s\S]*\}/)?.[0]
|
const json = block ?? value.match(/\{[\s\S]*\}/)?.[0]
|
||||||
if (!json) throw new Error("AI response did not contain JSON")
|
if (!json) throw new Error("AI response did not contain JSON")
|
||||||
const data = JSON.parse(json) as Record<string, unknown>
|
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))
|
const score = Math.max(0, Math.min(100, data.score))
|
||||||
return {
|
return {
|
||||||
score,
|
score,
|
||||||
grade: gradeForScore(score),
|
grade: gradeForScore(score),
|
||||||
feedback: typeof data.feedback === "string" ? data.feedback : "",
|
feedback: typeof data.feedback === "string" ? data.feedback : "",
|
||||||
suggestions: typeof data.suggestions === "string" ? data.suggestions : "",
|
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
|
||||||
|
: {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,31 +51,47 @@ export async function evaluateFlowchart(
|
|||||||
job: FlowchartJobData,
|
job: FlowchartJobData,
|
||||||
{ isFinalAttempt = true }: { isFinalAttempt?: boolean } = {},
|
{ isFinalAttempt = true }: { isFinalAttempt?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
|
const [row] = await db
|
||||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
.select({ flowchart: schema.flowchartSubmission, problem: schema.problem })
|
||||||
.where(eq(schema.flowchartSubmission.id, job.submissionId)).limit(1)
|
.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
|
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()
|
const started = performance.now()
|
||||||
try {
|
try {
|
||||||
const reference = row.problem.mermaidCode ? `\n标准答案参考:\n${row.problem.mermaidCode}` : "\n此题没有标准流程图。"
|
const reference = row.problem.mermaidCode
|
||||||
const result = parseEvaluation(await completeChat(
|
? `\n标准答案参考:\n${row.problem.mermaidCode}`
|
||||||
evaluationPrompt(row.problem),
|
: "\n此题没有标准流程图。"
|
||||||
`学生流程图:\n${row.flowchart.mermaidCode}${reference}\n设计提示:${row.problem.flowchartHint ?? "无"}`,
|
const result = parseEvaluation(
|
||||||
))
|
await completeChat(
|
||||||
await db.update(schema.flowchartSubmission).set({
|
evaluationPrompt(row.problem),
|
||||||
status: 2,
|
`学生流程图:\n${row.flowchart.mermaidCode}${reference}\n设计提示:${row.problem.flowchartHint ?? "无"}`,
|
||||||
aiScore: result.score,
|
),
|
||||||
aiGrade: result.grade,
|
)
|
||||||
aiFeedback: result.feedback,
|
await db
|
||||||
aiSuggestions: result.suggestions,
|
.update(schema.flowchartSubmission)
|
||||||
aiCriteriaDetails: result.criteria,
|
.set({
|
||||||
aiProvider: "deepseek",
|
status: 2,
|
||||||
aiModel: process.env.AI_MODEL ?? "deepseek-v4-flash",
|
aiScore: result.score,
|
||||||
processingTime: (performance.now() - started) / 1000,
|
aiGrade: result.grade,
|
||||||
evaluationTime: new Date().toISOString(),
|
aiFeedback: result.feedback,
|
||||||
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
aiSuggestions: result.suggestions,
|
||||||
await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({
|
aiCriteriaDetails: result.criteria,
|
||||||
|
aiProvider: "deepseek",
|
||||||
|
aiModel: process.env.AI_MODEL ?? "deepseek-flash",
|
||||||
|
processingTime: (performance.now() - started) / 1000,
|
||||||
|
evaluationTime: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||||
|
await publishFlowchartUpdate(row.flowchart.userId, {
|
||||||
type: "flowchart_evaluation_completed",
|
type: "flowchart_evaluation_completed",
|
||||||
submissionId: row.flowchart.id,
|
submissionId: row.flowchart.id,
|
||||||
score: result.score,
|
score: result.score,
|
||||||
@@ -79,7 +99,7 @@ export async function evaluateFlowchart(
|
|||||||
feedback: result.feedback,
|
feedback: result.feedback,
|
||||||
suggestions: result.suggestions,
|
suggestions: result.suggestions,
|
||||||
criteriaDetails: result.criteria,
|
criteriaDetails: result.criteria,
|
||||||
}))
|
} satisfies FlowchartUpdate)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 原来这里把 error.message 原样推给学生、前端还直接 message.error 弹出来 ——
|
// 原来这里把 error.message 原样推给学生、前端还直接 message.error 弹出来 ——
|
||||||
// AI provider 的地址、内部报错就这么进了浏览器。真实原因留在服务端日志里,
|
// AI provider 的地址、内部报错就这么进了浏览器。真实原因留在服务端日志里,
|
||||||
@@ -90,11 +110,14 @@ export async function evaluateFlowchart(
|
|||||||
// 一旦提前写成 3,队列配的 attempts: 3 就成了摆设 —— 后两次尝试进来什么都不做
|
// 一旦提前写成 3,队列配的 attempts: 3 就成了摆设 —— 后两次尝试进来什么都不做
|
||||||
// 就算成功,AI 侧的偶发失败(限流、超时、网络抖动)永远等不到重试。
|
// 就算成功,AI 侧的偶发失败(限流、超时、网络抖动)永远等不到重试。
|
||||||
if (!isFinalAttempt) throw error
|
if (!isFinalAttempt) throw error
|
||||||
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
await db
|
||||||
await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({
|
.update(schema.flowchartSubmission)
|
||||||
|
.set({ status: 3 })
|
||||||
|
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||||
|
await publishFlowchartUpdate(row.flowchart.userId, {
|
||||||
type: "flowchart_evaluation_failed",
|
type: "flowchart_evaluation_failed",
|
||||||
submissionId: row.flowchart.id,
|
submissionId: row.flowchart.id,
|
||||||
}))
|
} satisfies FlowchartUpdate)
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-62
@@ -44,16 +44,16 @@ app.route("/api", judgeServerRoutes)
|
|||||||
app.route("/api/admin", adminRoutes)
|
app.route("/api/admin", adminRoutes)
|
||||||
|
|
||||||
app.onError((error, c) => {
|
app.onError((error, c) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
return c.json(
|
return c.json(
|
||||||
{ error: { code: "internal-error", message: "Internal server error" } },
|
{ error: { code: "internal-error", message: "Internal server error" } },
|
||||||
500,
|
500,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 头像取不到时的占位图,避免每个没设头像的学生都打一次 404 */
|
/** 头像取不到时的占位图,避免每个没设头像的学生都打一次 404 */
|
||||||
const DEFAULT_AVATAR_SVG =
|
const DEFAULT_AVATAR_SVG =
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="64" fill="#e2e8f0"/><circle cx="64" cy="48" r="24" fill="#94a3b8"/><path d="M20 120c4-28 22-42 44-42s40 14 44 42" fill="#94a3b8"/></svg>'
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="64" fill="#e2e8f0"/><circle cx="64" cy="48" r="24" fill="#94a3b8"/><path d="M20 120c4-28 22-42 44-42s40 14 44 42" fill="#94a3b8"/></svg>'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 伺服 /public 下的用户上传文件。
|
* 伺服 /public 下的用户上传文件。
|
||||||
@@ -64,65 +64,92 @@ const DEFAULT_AVATAR_SVG =
|
|||||||
* 生产环境这些请求也走后端(Caddy 把 /public/* 整段反代过来),不让 Caddy 直接读盘:
|
* 生产环境这些请求也走后端(Caddy 把 /public/* 整段反代过来),不让 Caddy 直接读盘:
|
||||||
* 这样开发(Vite 代理)和生产是同一条代码路径,少一处只在服务器上才出错的差异。
|
* 这样开发(Vite 代理)和生产是同一条代码路径,少一处只在服务器上才出错的差异。
|
||||||
*/
|
*/
|
||||||
async function serveUpload(pathname: string, prefix: string, directory: string) {
|
async function serveUpload(
|
||||||
const decoded = decodeURIComponent(pathname)
|
pathname: string,
|
||||||
const filename = basename(decoded)
|
prefix: string,
|
||||||
if (!filename || filename !== decoded.slice(prefix.length + 1)) {
|
directory: string,
|
||||||
return new Response("Not found", { status: 404 })
|
) {
|
||||||
}
|
const decoded = decodeURIComponent(pathname)
|
||||||
const file = Bun.file(resolve(directory, filename))
|
const filename = basename(decoded)
|
||||||
if (await file.exists()) {
|
if (!filename || filename !== decoded.slice(prefix.length + 1)) {
|
||||||
// 文件名由后端生成且内容不变,可以放心长缓存
|
return new Response("Not found", { status: 404 })
|
||||||
return new Response(file, { headers: { "cache-control": "public, max-age=86400" } })
|
}
|
||||||
}
|
const file = Bun.file(resolve(directory, filename))
|
||||||
return null
|
if (await file.exists()) {
|
||||||
|
// 文件名由后端生成且内容不变,可以放心长缓存
|
||||||
|
return new Response(file, {
|
||||||
|
headers: { "cache-control": "public, max-age=86400" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = Bun.serve<SubmissionSocketData>({
|
const server = Bun.serve<SubmissionSocketData>({
|
||||||
port: config.port,
|
port: config.port,
|
||||||
async fetch(request, bunServer) {
|
async fetch(request, bunServer) {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) {
|
if (url.pathname.startsWith(`${config.avatarUriPrefix}/`)) {
|
||||||
const hit = await serveUpload(url.pathname, config.avatarUriPrefix, config.avatarDirectory)
|
const hit = await serveUpload(
|
||||||
if (hit) return hit
|
url.pathname,
|
||||||
if (basename(decodeURIComponent(url.pathname)) === "default.png") {
|
config.avatarUriPrefix,
|
||||||
return new Response(DEFAULT_AVATAR_SVG, {
|
config.avatarDirectory,
|
||||||
headers: { "content-type": "image/svg+xml", "cache-control": "public, max-age=3600" },
|
)
|
||||||
})
|
if (hit) return hit
|
||||||
}
|
if (basename(decodeURIComponent(url.pathname)) === "default.png") {
|
||||||
return new Response("Not found", { status: 404 })
|
return new Response(DEFAULT_AVATAR_SVG, {
|
||||||
}
|
headers: {
|
||||||
// 题面里插的图片。原来没有这一段 —— 后台上传成功、返回 /public/upload/xxx,
|
"content-type": "image/svg+xml",
|
||||||
// 但没有任何路由伺服它,题面图片一律 404。
|
"cache-control": "public, max-age=3600",
|
||||||
if (url.pathname.startsWith(`${config.uploadUriPrefix}/`)) {
|
},
|
||||||
return (
|
})
|
||||||
(await serveUpload(url.pathname, config.uploadUriPrefix, config.uploadDirectory)) ??
|
}
|
||||||
new Response("Not found", { status: 404 })
|
return new Response("Not found", { status: 404 })
|
||||||
)
|
}
|
||||||
}
|
// 题面里插的图片。原来没有这一段 —— 后台上传成功、返回 /public/upload/xxx,
|
||||||
if (url.pathname === "/ws/submissions" || url.pathname === "/ws/config") {
|
// 但没有任何路由伺服它,题面图片一律 404。
|
||||||
if (!isAllowedWebSocketOrigin(request.headers.get("origin"), url)) {
|
if (url.pathname.startsWith(`${config.uploadUriPrefix}/`)) {
|
||||||
return new Response("Forbidden", { status: 403 })
|
return (
|
||||||
}
|
(await serveUpload(
|
||||||
const user = await getRequestSessionUser(request)
|
url.pathname,
|
||||||
if (!user) return new Response("Unauthorized", { status: 401 })
|
config.uploadUriPrefix,
|
||||||
const kind = url.pathname === "/ws/config" ? "config" : "submissions"
|
config.uploadDirectory,
|
||||||
if (
|
)) ?? new Response("Not found", { status: 404 })
|
||||||
bunServer.upgrade(request, {
|
)
|
||||||
data: {
|
}
|
||||||
userId: user.id,
|
if (
|
||||||
kind,
|
url.pathname === "/ws/submissions" ||
|
||||||
token: readRequestSessionToken(request),
|
url.pathname === "/ws/config" ||
|
||||||
},
|
url.pathname === "/ws/collab"
|
||||||
})
|
) {
|
||||||
) {
|
if (!isAllowedWebSocketOrigin(request.headers.get("origin"), url)) {
|
||||||
return undefined
|
return new Response("Forbidden", { status: 403 })
|
||||||
}
|
}
|
||||||
return new Response("WebSocket upgrade failed", { status: 400 })
|
const user = await getRequestSessionUser(request)
|
||||||
}
|
if (!user) return new Response("Unauthorized", { status: 401 })
|
||||||
return app.fetch(request)
|
const kind =
|
||||||
},
|
url.pathname === "/ws/config"
|
||||||
websocket: submissionWebSocketHandler(),
|
? "config"
|
||||||
|
: url.pathname === "/ws/collab"
|
||||||
|
? "collab"
|
||||||
|
: "submissions"
|
||||||
|
if (
|
||||||
|
bunServer.upgrade(request, {
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
kind,
|
||||||
|
token: readRequestSessionToken(request),
|
||||||
|
username: user.username,
|
||||||
|
adminType: user.adminType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return new Response("WebSocket upgrade failed", { status: 400 })
|
||||||
|
}
|
||||||
|
return app.fetch(request)
|
||||||
|
},
|
||||||
|
websocket: submissionWebSocketHandler(),
|
||||||
})
|
})
|
||||||
|
|
||||||
await bridgeSubmissionEvents(server)
|
await bridgeSubmissionEvents(server)
|
||||||
|
|||||||
+154
-81
@@ -1,14 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
|
AST_NODE_TARGETS_BY_LANGUAGE,
|
||||||
|
AST_OPERATOR_TARGETS_BY_LANGUAGE,
|
||||||
|
astNodeLabel,
|
||||||
|
astOperatorLabel,
|
||||||
|
astRuleIsMeaningful,
|
||||||
|
astTargetNodeType,
|
||||||
astRuleSchema,
|
astRuleSchema,
|
||||||
AST_NODE_TARGET_LABELS,
|
AST_SUPPORTED_LANGUAGES,
|
||||||
type AstRequirement,
|
type AstRequirement,
|
||||||
type AstRequirements,
|
type AstRequirements,
|
||||||
type AstRule,
|
type AstRule,
|
||||||
|
type AstRules,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { Language, Parser, type Node } from "web-tree-sitter"
|
import { Language, Parser, type Node } from "web-tree-sitter"
|
||||||
// 语法 wasm 内嵌成资源。原来是 `Bun.resolveSync(pkg + "/" + name, import.meta.dir)`,
|
// 语法 wasm 内嵌成资源。原来是 `Bun.resolveSync(pkg + "/" + name, import.meta.dir)`,
|
||||||
// 编译成单二进制后 import.meta.dir 是 /$bunfs/root,解析不到 node_modules。见 vendor/jieba.ts
|
// 编译成单二进制后 import.meta.dir 是 /$bunfs/root,解析不到 node_modules。见 vendor/jieba.ts
|
||||||
import cWasmPath from "tree-sitter-c/tree-sitter-c.wasm" with { type: "file" }
|
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 pythonWasmPath from "tree-sitter-python/tree-sitter-python.wasm" with { type: "file" }
|
||||||
// web-tree-sitter 自己的运行时 wasm,Parser.init() 要用
|
// web-tree-sitter 自己的运行时 wasm,Parser.init() 要用
|
||||||
import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { type: "file" }
|
import treeSitterWasmPath from "web-tree-sitter/web-tree-sitter.wasm" with { type: "file" }
|
||||||
@@ -20,66 +28,31 @@ export type { AstRule } from "@oj2/contract"
|
|||||||
export interface AstResult {
|
export interface AstResult {
|
||||||
description: string
|
description: string
|
||||||
passed: boolean
|
passed: boolean
|
||||||
}
|
/** count_* 引擎实际数到的次数。失败时前端拿它补一句「当前 N 次」 */
|
||||||
|
actual?: number
|
||||||
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: "!",
|
|
||||||
},
|
|
||||||
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
|
let initPromise: Promise<void> | undefined
|
||||||
const languages = new Map<string, Language>()
|
const languages = new Map<string, Language>()
|
||||||
|
|
||||||
async function loadLanguage(language: string) {
|
async function loadLanguage(language: string) {
|
||||||
if (!(language in mappings)) return null
|
if (!AST_SUPPORTED_LANGUAGES.includes(language)) return null
|
||||||
// locateFile 指到内嵌的 tree-sitter.wasm:emscripten 默认按脚本所在目录找,
|
// locateFile 指到内嵌的 tree-sitter.wasm:emscripten 默认按脚本所在目录找,
|
||||||
// 单二进制里那个目录是 /$bunfs/root,它自己找不着
|
// 单二进制里那个目录是 /$bunfs/root,它自己找不着
|
||||||
if (!initPromise) initPromise = Parser.init({ locateFile: () => treeSitterWasmPath })
|
if (!initPromise)
|
||||||
|
initPromise = Parser.init({ locateFile: () => treeSitterWasmPath })
|
||||||
await initPromise
|
await initPromise
|
||||||
|
|
||||||
const cached = languages.get(language)
|
const cached = languages.get(language)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const loaded = await Language.load(language === "C" ? cWasmPath : pythonWasmPath)
|
const wasmPath =
|
||||||
|
language === "C"
|
||||||
|
? cWasmPath
|
||||||
|
: language === "C++"
|
||||||
|
? cppWasmPath
|
||||||
|
: pythonWasmPath
|
||||||
|
const loaded = await Language.load(wasmPath)
|
||||||
languages.set(language, loaded)
|
languages.set(language, loaded)
|
||||||
return loaded
|
return loaded
|
||||||
}
|
}
|
||||||
@@ -95,9 +68,9 @@ function hasNode(root: Node, type: string): boolean {
|
|||||||
return root.children.some((child) => hasNode(child, type))
|
return root.children.some((child) => hasNode(child, type))
|
||||||
}
|
}
|
||||||
|
|
||||||
function targetName(rule: AstRule) {
|
function targetName(rule: AstRule, language?: string) {
|
||||||
const target = rule.target ?? ""
|
const target = rule.target ?? ""
|
||||||
return rule.label || AST_NODE_TARGET_LABELS[target] || target || "指定语法"
|
return rule.label || astNodeLabel(target, language) || "指定语法"
|
||||||
}
|
}
|
||||||
|
|
||||||
function countPhrase(verb: string, rule: AstRule) {
|
function countPhrase(verb: string, rule: AstRule) {
|
||||||
@@ -114,9 +87,9 @@ function countPhrase(verb: string, rule: AstRule) {
|
|||||||
* 用的是同一份 —— 原来前端 ProblemContent.vue 里另有一份几乎一样的实现,
|
* 用的是同一份 —— 原来前端 ProblemContent.vue 里另有一份几乎一样的实现,
|
||||||
* 只有 min/max 同时给出时的措辞不一样(生产库里没有这种规则)。
|
* 只有 min/max 同时给出时的措辞不一样(生产库里没有这种规则)。
|
||||||
*/
|
*/
|
||||||
export function describeAstRule(rule: AstRule): string {
|
export function describeAstRule(rule: AstRule, language?: string): string {
|
||||||
if (rule.message) return rule.message
|
if (rule.message) return rule.message
|
||||||
const name = targetName(rule)
|
const name = targetName(rule, language)
|
||||||
const target = rule.target ?? ""
|
const target = rule.target ?? ""
|
||||||
switch (rule.engine) {
|
switch (rule.engine) {
|
||||||
case "must_exist_node":
|
case "must_exist_node":
|
||||||
@@ -136,10 +109,12 @@ export function describeAstRule(rule: AstRule): string {
|
|||||||
case "must_not_call_method":
|
case "must_not_call_method":
|
||||||
return `不能调用 .${target}()`
|
return `不能调用 .${target}()`
|
||||||
case "must_use_operator":
|
case "must_use_operator":
|
||||||
return `必须使用 ${target} 运算符`
|
return `必须使用 ${astOperatorLabel(target, language)} 运算符`
|
||||||
case "must_have_nesting": {
|
case "must_have_nesting": {
|
||||||
const outer = rule.outer ?? ""
|
// 这两个走 astNodeLabel 而不是裸值 —— 少了这一步文案就是
|
||||||
const inner = rule.inner ?? ""
|
// 「必须使用 for_loop 嵌套」,旧栈 ast_checker/engines/nesting.py 是翻的
|
||||||
|
const outer = astNodeLabel(rule.outer ?? "", language)
|
||||||
|
const inner = astNodeLabel(rule.inner ?? "", language)
|
||||||
return outer === inner
|
return outer === inner
|
||||||
? `必须使用 ${outer} 嵌套`
|
? `必须使用 ${outer} 嵌套`
|
||||||
: `必须在 ${outer} 中嵌套使用 ${inner}`
|
: `必须在 ${outer} 中嵌套使用 ${inner}`
|
||||||
@@ -157,28 +132,100 @@ function requirementKind(engine: AstRule["engine"]): AstRequirement["kind"] {
|
|||||||
/**
|
/**
|
||||||
* 把规则原文投影成下发给学生的「代码要求」。规则里的 engine / target 不出现在
|
* 把规则原文投影成下发给学生的「代码要求」。规则里的 engine / target 不出现在
|
||||||
* 响应里 —— 阶段 3 泄露评审收掉 ast_rules 时要的就是这个,见契约的注释。
|
* 响应里 —— 阶段 3 泄露评审收掉 ast_rules 时要的就是这个,见契约的注释。
|
||||||
|
*
|
||||||
|
* 只投影判题机真检查得了的语言。原来这里不看语言,给 C++ 题配的规则照样渲染成
|
||||||
|
* 「必须使用 for 循环」挂在题目页上,而 loadLanguage 对 C++ 返回 null、
|
||||||
|
* checkAst 直接放行 —— 学生看得见要求,判题从不检查。
|
||||||
*/
|
*/
|
||||||
export function astRequirements(value: unknown): AstRequirements | null {
|
export function astRequirements(value: unknown): AstRequirements | null {
|
||||||
const grouped = value && typeof value === "object" && !Array.isArray(value)
|
const grouped =
|
||||||
? (value as Record<string, unknown>)
|
value && typeof value === "object" && !Array.isArray(value)
|
||||||
: null
|
? (value as Record<string, unknown>)
|
||||||
|
: null
|
||||||
if (!grouped) return null
|
if (!grouped) return null
|
||||||
const out: AstRequirements = {}
|
const out: AstRequirements = {}
|
||||||
for (const [language, rules] of Object.entries(grouped)) {
|
for (const [language, rules] of Object.entries(grouped)) {
|
||||||
if (!Array.isArray(rules)) continue
|
if (!Array.isArray(rules)) continue
|
||||||
|
if (!AST_SUPPORTED_LANGUAGES.includes(language)) continue
|
||||||
const items = rules.flatMap((rule) => {
|
const items = rules.flatMap((rule) => {
|
||||||
const parsed = astRuleSchema.safeParse(rule)
|
const parsed = astRuleSchema.safeParse(rule)
|
||||||
if (!parsed.success) return []
|
if (!parsed.success) return []
|
||||||
return [{
|
if (!astRuleIsMeaningful(parsed.data)) return []
|
||||||
description: describeAstRule(parsed.data),
|
return [
|
||||||
kind: requirementKind(parsed.data.engine),
|
{
|
||||||
}]
|
description: describeAstRule(parsed.data, language),
|
||||||
|
kind: requirementKind(parsed.data.engine),
|
||||||
|
},
|
||||||
|
]
|
||||||
})
|
})
|
||||||
if (items.length > 0) out[language] = items
|
if (items.length > 0) out[language] = items
|
||||||
}
|
}
|
||||||
return Object.keys(out).length > 0 ? out : null
|
return Object.keys(out).length > 0 ? out : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AST 规则的语义校验。zod 只管形状(engine 在枚举里、min 是整数),管不了
|
||||||
|
* 「给 C 题选了只有 Python 才有的 list_comprehension」这类组合 —— 那种规则存得进去,
|
||||||
|
* 判题时 astTargetNodeType() 找不到就回落成裸名去比节点类型,永远失败或永远通过,
|
||||||
|
* 两头都不报错,只有学生受着。
|
||||||
|
*
|
||||||
|
* 放这儿而不是 astRulesSchema 的 refine 上:那个 schema 同时用于**读**后台题目详情,
|
||||||
|
* 在读路径上抛错会让历史脏数据直接把题目详情打不开。
|
||||||
|
*/
|
||||||
|
export function astRulesError(astRules: AstRules | null): string | null {
|
||||||
|
if (!astRules) return null
|
||||||
|
for (const [language, rules] of Object.entries(astRules)) {
|
||||||
|
if (rules.length === 0) continue
|
||||||
|
if (!AST_SUPPORTED_LANGUAGES.includes(language)) {
|
||||||
|
return `代码规则暂不支持 ${language},判题机只检查 ${AST_SUPPORTED_LANGUAGES.join(" / ")}`
|
||||||
|
}
|
||||||
|
const nodes = AST_NODE_TARGETS_BY_LANGUAGE[language] ?? {}
|
||||||
|
const operators = AST_OPERATOR_TARGETS_BY_LANGUAGE[language] ?? {}
|
||||||
|
for (const [index, rule] of rules.entries()) {
|
||||||
|
const at = `代码规则 ${language} 第 ${index + 1} 条`
|
||||||
|
const target = rule.target ?? ""
|
||||||
|
if (rule.engine.endsWith("_node")) {
|
||||||
|
if (!(target in nodes))
|
||||||
|
return `${at}:${language} 没有「${target}」这种语法`
|
||||||
|
} else if (rule.engine === "must_use_operator") {
|
||||||
|
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}」这种语法`
|
||||||
|
}
|
||||||
|
} else if (!target.trim()) {
|
||||||
|
return `${at}:要检查的函数名/方法名不能为空`
|
||||||
|
}
|
||||||
|
if (!astRuleIsMeaningful(rule)) return `${at}:次数检查至少要填一个数字`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存前清掉够不着的规则分组:不在题目 languages 里的(老师改过语言列表),
|
||||||
|
* 以及判题机检查不了的(C++ / Java / …)。两者编辑器都不给开 tab,留着就是死数据。
|
||||||
|
*
|
||||||
|
* 必须先剔除再校验,否则历史脏数据会把老师锁死:一道 languages 含 C++ 的题,
|
||||||
|
* 早年配过 C++ 规则,如今 tab 里看不到那组规则,保存却被「暂不支持 C++」拦下,
|
||||||
|
* 老师在界面上无从修改。
|
||||||
|
*/
|
||||||
|
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)) {
|
||||||
|
if (!languages.includes(language)) continue
|
||||||
|
if (!AST_SUPPORTED_LANGUAGES.includes(language)) continue
|
||||||
|
if (rules.length > 0) out[language] = rules
|
||||||
|
}
|
||||||
|
return Object.keys(out).length > 0 ? out : null
|
||||||
|
}
|
||||||
|
|
||||||
function rangePassed(count: number, rule: AstRule) {
|
function rangePassed(count: number, rule: AstRule) {
|
||||||
if (rule.exact !== undefined && count !== rule.exact) return false
|
if (rule.exact !== undefined && count !== rule.exact) return false
|
||||||
if (rule.min !== undefined && count < rule.min) return false
|
if (rule.min !== undefined && count < rule.min) return false
|
||||||
@@ -186,16 +233,41 @@ function rangePassed(count: number, rule: AstRule) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CALL_NODE_TYPES: Record<string, string> = {
|
||||||
|
C: "call_expression",
|
||||||
|
"C++": "call_expression",
|
||||||
|
Python: "call",
|
||||||
|
}
|
||||||
|
|
||||||
function functionCalls(root: Node, target: string, language: string) {
|
function functionCalls(root: Node, target: string, language: string) {
|
||||||
const callType = language === "C" ? "call_expression" : "call"
|
const callType = CALL_NODE_TYPES[language] ?? "call"
|
||||||
return collectNodes(root, callType).filter((call) => {
|
return collectNodes(root, callType).filter((call) => {
|
||||||
const fn = call.childForFieldName("function")
|
const fn = call.childForFieldName("function")
|
||||||
return fn?.type === "identifier" && fn.text === target
|
if (!fn) return false
|
||||||
|
if (fn.type === "identifier") return fn.text === target
|
||||||
|
// `std::sort(...)` 是 qualified_identifier。学生写 sort 还是 std::sort 取决于
|
||||||
|
// 有没有 using namespace std,两种都得认,所以末段也比一次
|
||||||
|
if (language === "C++" && fn.type === "qualified_identifier") {
|
||||||
|
return fn.text === target || fn.text.split("::").pop() === target
|
||||||
|
}
|
||||||
|
return false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function methodCalls(root: Node, target: string, language: string) {
|
function methodCalls(root: Node, target: string, language: string) {
|
||||||
if (language === "C") return []
|
// C++ 的 `a.push_back()` / `p->push_back()` 都是 call_expression + field_expression,
|
||||||
|
// 和 Python 的 attribute 不是一回事 —— 少了这个分支,C++ 的「必须调用 .push_back()」
|
||||||
|
// 会静默地永远失败
|
||||||
|
if (language === "C++") {
|
||||||
|
return collectNodes(root, "call_expression").filter((call) => {
|
||||||
|
const fn = call.childForFieldName("function")
|
||||||
|
return (
|
||||||
|
fn?.type === "field_expression" &&
|
||||||
|
fn.childForFieldName("field")?.text === target
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (language !== "Python") return []
|
||||||
return collectNodes(root, "call").filter((call) => {
|
return collectNodes(root, "call").filter((call) => {
|
||||||
const fn = call.childForFieldName("function")
|
const fn = call.childForFieldName("function")
|
||||||
return (
|
return (
|
||||||
@@ -209,70 +281,71 @@ function evaluateRule(
|
|||||||
root: Node,
|
root: Node,
|
||||||
rule: AstRule,
|
rule: AstRule,
|
||||||
language: string,
|
language: string,
|
||||||
mapping: Record<string, string>,
|
|
||||||
): AstResult | null {
|
): AstResult | null {
|
||||||
const target = rule.target ?? ""
|
const target = rule.target ?? ""
|
||||||
const nodeType = mapping[target] ?? target
|
const nodeType = astTargetNodeType(target, language)
|
||||||
|
|
||||||
switch (rule.engine) {
|
switch (rule.engine) {
|
||||||
case "must_exist_node":
|
case "must_exist_node":
|
||||||
return {
|
return {
|
||||||
description: describeAstRule(rule),
|
description: describeAstRule(rule, language),
|
||||||
passed: hasNode(root, nodeType),
|
passed: hasNode(root, nodeType),
|
||||||
}
|
}
|
||||||
case "must_not_exist_node":
|
case "must_not_exist_node":
|
||||||
return {
|
return {
|
||||||
description: describeAstRule(rule),
|
description: describeAstRule(rule, language),
|
||||||
passed: !hasNode(root, nodeType),
|
passed: !hasNode(root, nodeType),
|
||||||
}
|
}
|
||||||
case "count_node": {
|
case "count_node": {
|
||||||
const count = collectNodes(root, nodeType).length
|
const count = collectNodes(root, nodeType).length
|
||||||
return {
|
return {
|
||||||
description: describeAstRule(rule),
|
description: describeAstRule(rule, language),
|
||||||
passed: rangePassed(count, rule),
|
passed: rangePassed(count, rule),
|
||||||
|
actual: count,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "must_call_function":
|
case "must_call_function":
|
||||||
return {
|
return {
|
||||||
description: describeAstRule(rule),
|
description: describeAstRule(rule, language),
|
||||||
passed: functionCalls(root, target, language).length > 0,
|
passed: functionCalls(root, target, language).length > 0,
|
||||||
}
|
}
|
||||||
case "must_not_call_function":
|
case "must_not_call_function":
|
||||||
return {
|
return {
|
||||||
description: describeAstRule(rule),
|
description: describeAstRule(rule, language),
|
||||||
passed: functionCalls(root, target, language).length === 0,
|
passed: functionCalls(root, target, language).length === 0,
|
||||||
}
|
}
|
||||||
case "count_function_call": {
|
case "count_function_call": {
|
||||||
const count = functionCalls(root, target, language).length
|
const count = functionCalls(root, target, language).length
|
||||||
return {
|
return {
|
||||||
description: describeAstRule(rule),
|
description: describeAstRule(rule, language),
|
||||||
passed: rangePassed(count, rule),
|
passed: rangePassed(count, rule),
|
||||||
|
actual: count,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "must_call_method":
|
case "must_call_method":
|
||||||
return {
|
return {
|
||||||
description: describeAstRule(rule),
|
description: describeAstRule(rule, language),
|
||||||
passed: methodCalls(root, target, language).length > 0,
|
passed: methodCalls(root, target, language).length > 0,
|
||||||
}
|
}
|
||||||
case "must_not_call_method":
|
case "must_not_call_method":
|
||||||
return {
|
return {
|
||||||
description: describeAstRule(rule),
|
description: describeAstRule(rule, language),
|
||||||
passed: methodCalls(root, target, language).length === 0,
|
passed: methodCalls(root, target, language).length === 0,
|
||||||
}
|
}
|
||||||
case "must_use_operator":
|
case "must_use_operator":
|
||||||
return {
|
return {
|
||||||
description: describeAstRule(rule),
|
description: describeAstRule(rule, language),
|
||||||
passed: hasNode(root, nodeType),
|
passed: hasNode(root, nodeType),
|
||||||
}
|
}
|
||||||
case "must_have_nesting": {
|
case "must_have_nesting": {
|
||||||
const outer = rule.outer ?? ""
|
const outer = rule.outer ?? ""
|
||||||
const inner = rule.inner ?? ""
|
const inner = rule.inner ?? ""
|
||||||
const outerType = mapping[outer] ?? outer
|
const outerType = astTargetNodeType(outer, language)
|
||||||
const innerType = mapping[inner] ?? inner
|
const innerType = astTargetNodeType(inner, language)
|
||||||
const passed = collectNodes(root, outerType).some((node) =>
|
const passed = collectNodes(root, outerType).some((node) =>
|
||||||
node.children.some((child) => hasNode(child, innerType)),
|
node.children.some((child) => hasNode(child, innerType)),
|
||||||
)
|
)
|
||||||
return { description: describeAstRule(rule), passed }
|
return { description: describeAstRule(rule, language), passed }
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
@@ -295,9 +368,9 @@ export async function checkAst(
|
|||||||
const tree = parser.parse(code)
|
const tree = parser.parse(code)
|
||||||
if (!tree) return { passed: true, results: [] }
|
if (!tree) return { passed: true, results: [] }
|
||||||
try {
|
try {
|
||||||
const mapping = mappings[language] ?? {}
|
|
||||||
const results = rules
|
const results = rules
|
||||||
.map((rule) => evaluateRule(tree.rootNode, rule, language, mapping))
|
.filter(astRuleIsMeaningful)
|
||||||
|
.map((rule) => evaluateRule(tree.rootNode, rule, language))
|
||||||
.filter((result): result is AstResult => result !== null)
|
.filter((result): result is AstResult => result !== null)
|
||||||
return { passed: results.every((result) => result.passed), results }
|
return { passed: results.every((result) => result.passed), results }
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { submissionUpdateSchema, type SubmissionUpdate } from "@oj2/contract"
|
||||||
submissionUpdateSchema,
|
|
||||||
type SubmissionUpdate,
|
|
||||||
} from "@oj2/contract"
|
|
||||||
|
|
||||||
import { redis } from "../redis"
|
import { redis } from "../redis"
|
||||||
|
|
||||||
@@ -20,10 +17,7 @@ export async function publishSubmissionUpdate(
|
|||||||
userId: number,
|
userId: number,
|
||||||
data: SubmissionUpdate,
|
data: SubmissionUpdate,
|
||||||
) {
|
) {
|
||||||
const event: SubmissionEvent = {
|
const event: SubmissionEvent = { userId, data }
|
||||||
userId,
|
|
||||||
data: submissionUpdateSchema.parse(data),
|
|
||||||
}
|
|
||||||
await redis.publish(submissionUpdateChannel, JSON.stringify(event))
|
await redis.publish(submissionUpdateChannel, JSON.stringify(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,39 @@
|
|||||||
const defaultEnv = ["LANG=en_US.UTF-8", "LANGUAGE=en_US:en", "LC_ALL=en_US.UTF-8"]
|
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"
|
||||||
|
|
||||||
export const languageConfigs: Record<string, Record<string, unknown>> = {
|
export const languageConfigs: Record<string, Record<string, unknown>> = {
|
||||||
C: {
|
C: {
|
||||||
@@ -9,8 +44,7 @@ export const languageConfigs: Record<string, Record<string, unknown>> = {
|
|||||||
max_cpu_time: 3000,
|
max_cpu_time: 3000,
|
||||||
max_real_time: 10000,
|
max_real_time: 10000,
|
||||||
max_memory: 256 * 1024 * 1024,
|
max_memory: 256 * 1024 * 1024,
|
||||||
compile_command:
|
compile_command: `/usr/bin/gcc -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c17 ${cLooseErrors} {src_path} -lm -o {exe_path}`,
|
||||||
"/usr/bin/gcc -DONLINE_JUDGE -O2 -w -fmax-errors=3 -std=c17 {src_path} -lm -o {exe_path}",
|
|
||||||
},
|
},
|
||||||
run: {
|
run: {
|
||||||
command: "{exe_path}",
|
command: "{exe_path}",
|
||||||
@@ -35,24 +69,7 @@ export const languageConfigs: Record<string, Record<string, unknown>> = {
|
|||||||
env: defaultEnv,
|
env: defaultEnv,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Java: {
|
Python: {
|
||||||
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: "",
|
template: "",
|
||||||
compile: {
|
compile: {
|
||||||
src_name: "solution.py",
|
src_name: "solution.py",
|
||||||
@@ -68,40 +85,16 @@ export const languageConfigs: Record<string, Record<string, unknown>> = {
|
|||||||
env: defaultEnv,
|
env: defaultEnv,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Golang: {
|
}
|
||||||
template: "",
|
|
||||||
compile: {
|
/**
|
||||||
src_name: "main.go",
|
* 按语言取判题配置。**判题侧一律走这个函数**,不要直接 `languageConfigs[x]`:
|
||||||
exe_name: "main",
|
* 它先过 `normalizeLanguage()`,所以 `Python3` / `Python2` 这类旧值也能命中。
|
||||||
max_cpu_time: 3000,
|
*/
|
||||||
max_real_time: 5000,
|
export function judgeConfigFor(language: string) {
|
||||||
max_memory: 1024 * 1024 * 1024,
|
return (
|
||||||
compile_command: "/usr/bin/go build -o {exe_path} {src_path}",
|
languageConfigs[language] ??
|
||||||
env: ["GOCACHE=/tmp", "GOPATH=/tmp", "GOMAXPROCS=1", ...defaultEnv],
|
languageConfigs[normalizeLanguage(language) ?? ""] ??
|
||||||
},
|
null
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
+172
-54
@@ -1,21 +1,21 @@
|
|||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
import { astRuleSchema } from "@oj2/contract"
|
import { astRuleSchema, type ContestSubmissionInfo } from "@oj2/contract"
|
||||||
import { and, eq, inArray } from "drizzle-orm"
|
import { and, eq, inArray } from "drizzle-orm"
|
||||||
|
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { publishAchievementNotification } from "../events"
|
import { publishAchievementNotification } from "../events"
|
||||||
import { updateAchievementsForSubmission } from "../services/achievements"
|
import {
|
||||||
|
updateAchievementsForProblemSet,
|
||||||
|
updateAchievementsForSubmission,
|
||||||
|
} from "../services/achievements"
|
||||||
|
import { recordSolvedProblem } from "../services/problemset"
|
||||||
import { checkAst, type AstRule } from "./ast"
|
import { checkAst, type AstRule } from "./ast"
|
||||||
import { publishSubmissionUpdate } from "./events"
|
import { publishSubmissionUpdate } from "./events"
|
||||||
import type { JudgeJobData } from "./job"
|
import type { JudgeJobData } from "./job"
|
||||||
import { languageConfigs } from "./languages"
|
import { judgeConfigFor } from "./languages"
|
||||||
import {
|
import { isAccepted, JudgeStatus, type JudgeStatusValue } from "./status"
|
||||||
isAccepted,
|
|
||||||
JudgeStatus,
|
|
||||||
type JudgeStatusValue,
|
|
||||||
} from "./status"
|
|
||||||
import { parseProblemTemplate } from "./template"
|
import { parseProblemTemplate } from "./template"
|
||||||
import { runSqlCase } from "./sql"
|
import { runSqlCase } from "./sql"
|
||||||
import { readInfo } from "../services/test-case"
|
import { readInfo } from "../services/test-case"
|
||||||
@@ -77,8 +77,9 @@ async function requestJudge(
|
|||||||
memoryLimit: number,
|
memoryLimit: number,
|
||||||
testCaseId: string,
|
testCaseId: string,
|
||||||
) {
|
) {
|
||||||
const languageConfig = languageConfigs[language]
|
const languageConfig = judgeConfigFor(language)
|
||||||
if (!languageConfig) throw new Error(`Unsupported judge language: ${language}`)
|
if (!languageConfig)
|
||||||
|
throw new Error(`Unsupported judge language: ${language}`)
|
||||||
|
|
||||||
const token = createHash("sha256")
|
const token = createHash("sha256")
|
||||||
.update(config.judgeServerToken)
|
.update(config.judgeServerToken)
|
||||||
@@ -172,8 +173,7 @@ async function persistResult(
|
|||||||
.update(schema.problem)
|
.update(schema.problem)
|
||||||
.set({
|
.set({
|
||||||
submissionNumber: problem.submissionNumber + 1,
|
submissionNumber: problem.submissionNumber + 1,
|
||||||
acceptedNumber:
|
acceptedNumber: problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
|
||||||
problem.acceptedNumber + (isAccepted(result) ? 1 : 0),
|
|
||||||
statisticInfo: problemStatistics,
|
statisticInfo: problemStatistics,
|
||||||
})
|
})
|
||||||
.where(eq(schema.problem.id, problemId))
|
.where(eq(schema.problem.id, problemId))
|
||||||
@@ -232,7 +232,10 @@ async function persistResult(
|
|||||||
submissionInfo: {},
|
submissionInfo: {},
|
||||||
})
|
})
|
||||||
.onConflictDoNothing({
|
.onConflictDoNothing({
|
||||||
target: [schema.acmContestRank.contestId, schema.acmContestRank.userId],
|
target: [
|
||||||
|
schema.acmContestRank.contestId,
|
||||||
|
schema.acmContestRank.userId,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const [rank] = await tx
|
const [rank] = await tx
|
||||||
@@ -247,15 +250,12 @@ async function persistResult(
|
|||||||
.for("update")
|
.for("update")
|
||||||
if (!rank) throw new Error("Contest rank could not be created")
|
if (!rank) throw new Error("Contest rank could not be created")
|
||||||
|
|
||||||
const rankInfo = objectValue(rank.submissionInfo)
|
const rankInfo = rank.submissionInfo
|
||||||
const previousInfo = objectValue(rankInfo[String(problemId)])
|
const previousInfo = rankInfo[String(problemId)]
|
||||||
const alreadyAccepted = previousInfo.is_ac === true
|
const alreadyAccepted = previousInfo?.is_ac === true
|
||||||
if (!alreadyAccepted) {
|
if (!alreadyAccepted) {
|
||||||
const errorNumber =
|
const errorNumber = previousInfo?.error_number ?? 0
|
||||||
typeof previousInfo.error_number === "number"
|
const nextInfo: ContestSubmissionInfo = {
|
||||||
? previousInfo.error_number
|
|
||||||
: 0
|
|
||||||
const nextInfo: Record<string, unknown> = {
|
|
||||||
is_ac: acceptedNow,
|
is_ac: acceptedNow,
|
||||||
ac_time: 0,
|
ac_time: 0,
|
||||||
error_number:
|
error_number:
|
||||||
@@ -269,7 +269,8 @@ async function persistResult(
|
|||||||
const acTime = Math.max(
|
const acTime = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.floor(
|
Math.floor(
|
||||||
(Date.parse(submissionCreateTime) - Date.parse(contest.startTime)) /
|
(Date.parse(submissionCreateTime) -
|
||||||
|
Date.parse(contest.startTime)) /
|
||||||
1000,
|
1000,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -295,7 +296,11 @@ 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 message = error instanceof Error ? error.message : String(error)
|
||||||
const updated = await db
|
const updated = await db
|
||||||
.update(schema.submission)
|
.update(schema.submission)
|
||||||
@@ -324,6 +329,34 @@ async function markSystemError(submissionId: string, userId: number, error: unkn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判题任务在 `judgeSubmission` 之外失败时的兜底。
|
||||||
|
*
|
||||||
|
* 正常路径上的异常都被 judgeSubmission 自己的 try/catch 接住、落成 SYSTEM_ERROR
|
||||||
|
* 并且推给前端,所以能走到队列 `failed` 事件的只剩两种:取提交那一步就炸了,
|
||||||
|
* 以及**worker 进程中途死掉** —— 机房断电、容器 OOM 被杀、部署时重启。后一种
|
||||||
|
* BullMQ 会先按 stalled 重入队一次,再没人接就彻底放手;判题队列又没配 attempts,
|
||||||
|
* 失败即终局。没有这个兜底,那条提交就永远停在「等待评分」,学生看着转圈,
|
||||||
|
* 教师统计里它还占着一个「判题中」的名额。生产库里 3 条卡死的 PENDING
|
||||||
|
* (2022-11 / 2026-03 / 2026-04,都是旧栈时代留下的)就是这么来的。
|
||||||
|
*
|
||||||
|
* `markSystemError` 只动 PENDING / JUDGING 两个状态,所以判完了的、被重判改过的
|
||||||
|
* 都不会被它覆盖。唯一能撞上的是「重判刚把状态置回 PENDING,同一刻上一个被遗弃的
|
||||||
|
* 任务才失败」——结果是这次重判被吃掉、显示成系统错误,比静默卡死看得见。
|
||||||
|
*/
|
||||||
|
export async function failAbandonedSubmission(
|
||||||
|
submissionId: string,
|
||||||
|
error: unknown,
|
||||||
|
) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ userId: schema.submission.userId })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(eq(schema.submission.id, submissionId))
|
||||||
|
.limit(1)
|
||||||
|
if (!row) return
|
||||||
|
await markSystemError(submissionId, row.userId, error)
|
||||||
|
}
|
||||||
|
|
||||||
export async function judgeSubmission(job: JudgeJobData) {
|
export async function judgeSubmission(job: JudgeJobData) {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -331,7 +364,10 @@ export async function judgeSubmission(job: JudgeJobData) {
|
|||||||
problem: schema.problem,
|
problem: schema.problem,
|
||||||
})
|
})
|
||||||
.from(schema.submission)
|
.from(schema.submission)
|
||||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
.innerJoin(
|
||||||
|
schema.problem,
|
||||||
|
eq(schema.submission.problemId, schema.problem.id),
|
||||||
|
)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.submission.id, job.submissionId),
|
eq(schema.submission.id, job.submissionId),
|
||||||
@@ -341,7 +377,11 @@ export async function judgeSubmission(job: JudgeJobData) {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
|
|
||||||
if (!row) throw new Error(`Submission ${job.submissionId} does not exist`)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,15 +408,16 @@ export async function judgeSubmission(job: JudgeJobData) {
|
|||||||
|
|
||||||
// SQL 题不经判题沙箱:沙箱是给编译型/脚本型语言用的,SQL 判的是结果集,
|
// SQL 题不经判题沙箱:沙箱是给编译型/脚本型语言用的,SQL 判的是结果集,
|
||||||
// 走 judge/sql 的 WASM 引擎(在独立子进程里跑,见那边的说明)。
|
// 走 judge/sql 的 WASM 引擎(在独立子进程里跑,见那边的说明)。
|
||||||
const response = row.submission.language === "SQL"
|
const response =
|
||||||
? await judgeSqlSubmission(row.problem, row.submission.code)
|
row.submission.language === "SQL"
|
||||||
: await requestJudge(
|
? await judgeSqlSubmission(row.problem, row.submission.code)
|
||||||
row.submission.language,
|
: await requestJudge(
|
||||||
source,
|
row.submission.language,
|
||||||
row.problem.timeLimit,
|
source,
|
||||||
row.problem.memoryLimit,
|
row.problem.timeLimit,
|
||||||
row.problem.testCaseId,
|
row.problem.memoryLimit,
|
||||||
)
|
row.problem.testCaseId,
|
||||||
|
)
|
||||||
|
|
||||||
let result: JudgeStatusValue
|
let result: JudgeStatusValue
|
||||||
let info: unknown = {}
|
let info: unknown = {}
|
||||||
@@ -399,11 +440,19 @@ export async function judgeSubmission(job: JudgeJobData) {
|
|||||||
(left, right) => Number(left.test_case) - Number(right.test_case),
|
(left, right) => Number(left.test_case) - Number(right.test_case),
|
||||||
)
|
)
|
||||||
info = { err: null, data: cases }
|
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)
|
result = statusValue(firstFailure?.result ?? JudgeStatus.ACCEPTED)
|
||||||
statisticInfo = {
|
statisticInfo = {
|
||||||
time_cost: Math.max(0, ...cases.map((item) => Number(item.cpu_time) || 0)),
|
time_cost: Math.max(
|
||||||
memory_cost: Math.max(0, ...cases.map((item) => Number(item.memory) || 0)),
|
0,
|
||||||
|
...cases.map((item) => Number(item.cpu_time) || 0),
|
||||||
|
),
|
||||||
|
memory_cost: Math.max(
|
||||||
|
0,
|
||||||
|
...cases.map((item) => Number(item.memory) || 0),
|
||||||
|
),
|
||||||
score: 0,
|
score: 0,
|
||||||
}
|
}
|
||||||
// SQL 判题给出的中文提示(只读拒绝/超时/内存/无结果集)只存在测试点的
|
// SQL 判题给出的中文提示(只读拒绝/超时/内存/无结果集)只存在测试点的
|
||||||
@@ -412,7 +461,8 @@ export async function judgeSubmission(job: JudgeJobData) {
|
|||||||
const failedMessage = cases.find(
|
const failedMessage = cases.find(
|
||||||
(item) => item.result !== JudgeStatus.ACCEPTED && item.error_message,
|
(item) => item.result !== JudgeStatus.ACCEPTED && item.error_message,
|
||||||
)?.error_message
|
)?.error_message
|
||||||
if (typeof failedMessage === "string") statisticInfo.err_info = failedMessage
|
if (typeof failedMessage === "string")
|
||||||
|
statisticInfo.err_info = failedMessage
|
||||||
|
|
||||||
if (result === JudgeStatus.ACCEPTED) {
|
if (result === JudgeStatus.ACCEPTED) {
|
||||||
const rules = astRulesForLanguage(
|
const rules = astRulesForLanguage(
|
||||||
@@ -446,18 +496,74 @@ export async function judgeSubmission(job: JudgeJobData) {
|
|||||||
)
|
)
|
||||||
if (!saved) return
|
if (!saved) return
|
||||||
|
|
||||||
|
// 题单记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress,
|
||||||
|
// 只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。
|
||||||
|
// 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时
|
||||||
|
// 进度已经落库,跳回题单页看到的就是新数据。
|
||||||
|
// 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。
|
||||||
|
if (row.submission.contestId === null && isAccepted(result)) {
|
||||||
|
try {
|
||||||
|
const { updated, earned } = await recordSolvedProblem(
|
||||||
|
row.submission.userId,
|
||||||
|
row.problem.id,
|
||||||
|
row.submission.id,
|
||||||
|
row.submission.createTime,
|
||||||
|
)
|
||||||
|
if (earned.length > 0) {
|
||||||
|
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) => ({
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const unlocked = await updateAchievementsForSubmission(row.submission.id)
|
const unlocked = await updateAchievementsForSubmission(row.submission.id)
|
||||||
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({
|
await publishAchievementNotification(
|
||||||
id: achievement.id,
|
row.submission.userId,
|
||||||
name: achievement.name,
|
unlocked.map((achievement) => ({
|
||||||
description: achievement.description,
|
id: achievement.id,
|
||||||
icon: achievement.icon,
|
name: achievement.name,
|
||||||
rarity: achievement.rarity,
|
description: achievement.description,
|
||||||
kind: "achievement",
|
icon: achievement.icon,
|
||||||
})))
|
rarity: achievement.rarity,
|
||||||
|
kind: "achievement",
|
||||||
|
})),
|
||||||
|
)
|
||||||
} catch (error) {
|
} 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, {
|
await publishSubmissionUpdate(row.submission.userId, {
|
||||||
@@ -466,7 +572,9 @@ export async function judgeSubmission(job: JudgeJobData) {
|
|||||||
result,
|
result,
|
||||||
status: "finished",
|
status: "finished",
|
||||||
score:
|
score:
|
||||||
typeof statisticInfo.score === "number" ? statisticInfo.score : undefined,
|
typeof statisticInfo.score === "number"
|
||||||
|
? statisticInfo.score
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to judge submission ${row.submission.id}`, error)
|
console.error(`Failed to judge submission ${row.submission.id}`, error)
|
||||||
@@ -474,7 +582,6 @@ export async function judgeSubmission(job: JudgeJobData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SQL 题判题:逐个测试点用各自的初始化脚本跑一遍,产出与沙箱同构的结果结构,
|
* SQL 题判题:逐个测试点用各自的初始化脚本跑一遍,产出与沙箱同构的结果结构,
|
||||||
* 好让上面的状态聚合、统计、排名、WebSocket 推送逻辑完全复用。
|
* 好让上面的状态聚合、统计、排名、WebSocket 推送逻辑完全复用。
|
||||||
@@ -492,15 +599,23 @@ async function judgeSqlSubmission(
|
|||||||
const answers = Array.isArray(problem.answers) ? problem.answers : []
|
const answers = Array.isArray(problem.answers) ? problem.answers : []
|
||||||
const refSql = answers
|
const refSql = answers
|
||||||
.map((item) => objectValue(item))
|
.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 标准答案")
|
if (typeof refSql !== "string") throw new Error("题目缺少 SQL 标准答案")
|
||||||
|
|
||||||
const info = await readInfo(problem.testCaseId)
|
const info = await readInfo(problem.testCaseId)
|
||||||
if (!info) throw new Error("测试点信息读取失败")
|
if (!info) throw new Error("测试点信息读取失败")
|
||||||
if (!info.sql) throw new Error("测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包")
|
if (!info.sql)
|
||||||
|
throw new Error("测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包")
|
||||||
|
|
||||||
// 按 "1","2",… 的数字序遍历,保证测试点顺序稳定
|
// 按 "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("题目没有任何测试点")
|
if (keys.length === 0) throw new Error("题目没有任何测试点")
|
||||||
|
|
||||||
const cases: JudgeCase[] = []
|
const cases: JudgeCase[] = []
|
||||||
@@ -509,7 +624,9 @@ async function judgeSqlSubmission(
|
|||||||
const initSql = await readFile(
|
const initSql = await readFile(
|
||||||
resolvePath(config.testCaseDirectory, problem.testCaseId, inputName),
|
resolvePath(config.testCaseDirectory, problem.testCaseId, inputName),
|
||||||
"utf8",
|
"utf8",
|
||||||
).catch(() => { throw new Error(`测试点脚本 ${inputName} 读取失败`) })
|
).catch(() => {
|
||||||
|
throw new Error(`测试点脚本 ${inputName} 读取失败`)
|
||||||
|
})
|
||||||
|
|
||||||
const outcome = await runSqlCase({
|
const outcome = await runSqlCase({
|
||||||
kind: "judge",
|
kind: "judge",
|
||||||
@@ -523,7 +640,8 @@ async function judgeSqlSubmission(
|
|||||||
})
|
})
|
||||||
if (!outcome.ok) {
|
if (!outcome.ok) {
|
||||||
// 初始化/标准答案执行失败属出题配置问题,整题 SYSTEM_ERROR
|
// 初始化/标准答案执行失败属出题配置问题,整题 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({
|
cases.push({
|
||||||
test_case: String(index + 1),
|
test_case: String(index + 1),
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ export type SqlJob =
|
|||||||
timeLimitMs: number
|
timeLimitMs: number
|
||||||
memoryLimitMb: number
|
memoryLimitMb: number
|
||||||
}
|
}
|
||||||
| { kind: "display"; initSql: string; refSql: string; mode: "query" | "modify" }
|
| {
|
||||||
|
kind: "display"
|
||||||
|
initSql: string
|
||||||
|
refSql: string
|
||||||
|
mode: "query" | "modify"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 写阶段标记。必须用 writeSync:父进程正是靠这个标记决定「多久之后 SIGKILL」
|
* 写阶段标记。必须用 writeSync:父进程正是靠这个标记决定「多久之后 SIGKILL」
|
||||||
@@ -90,10 +95,13 @@ export async function runSqlChild() {
|
|||||||
// WASM 堆触顶时 emscripten 抛的是普通 Error("Aborted"/"out of memory"),
|
// WASM 堆触顶时 emscripten 抛的是普通 Error("Aborted"/"out of memory"),
|
||||||
// 到这里说明连引擎自身都没撑住,按内存超限报,不当成出题人的错
|
// 到这里说明连引擎自身都没撑住,按内存超限报,不当成出题人的错
|
||||||
const message = String((error as Error)?.message ?? error)
|
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({
|
finish({
|
||||||
ok: false,
|
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),
|
message: memoryish ? "内存超出限制" : message.slice(0, 200),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,17 @@ const DISPLAY_ROW_LIMIT = 20
|
|||||||
const ERROR_MESSAGE_MAX_LEN = 200
|
const ERROR_MESSAGE_MAX_LEN = 200
|
||||||
|
|
||||||
/** prepare 阶段的语法类错误,映射为 COMPILE_ERROR */
|
/** 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 {
|
export class SqlCaseError extends Error {
|
||||||
constructor(readonly result: JudgeStatusValue, readonly detail: string) {
|
constructor(
|
||||||
|
readonly result: JudgeStatusValue,
|
||||||
|
readonly detail: string,
|
||||||
|
) {
|
||||||
super(detail)
|
super(detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,9 +89,11 @@ type Canonical = string
|
|||||||
*/
|
*/
|
||||||
function canonicalValue(value: unknown): Canonical {
|
function canonicalValue(value: unknown): Canonical {
|
||||||
if (value === null || value === undefined) return "null"
|
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 (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")
|
// Python 的 format(v, ".6g")
|
||||||
return `num:${formatG6(value)}`
|
return `num:${formatG6(value)}`
|
||||||
}
|
}
|
||||||
@@ -96,7 +105,10 @@ function canonicalValue(value: unknown): Canonical {
|
|||||||
function formatG6(value: number) {
|
function formatG6(value: number) {
|
||||||
const exponent = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)))
|
const exponent = value === 0 ? 0 : Math.floor(Math.log10(Math.abs(value)))
|
||||||
if (exponent < -4 || exponent >= 6) {
|
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)
|
const text = value.toPrecision(6)
|
||||||
return text.includes(".") ? text.replace(/\.?0+$/, "") : text
|
return text.includes(".") ? text.replace(/\.?0+$/, "") : text
|
||||||
@@ -138,9 +150,11 @@ interface PreparedStatement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function iterate(db: Database, script: string): Iterable<PreparedStatement> {
|
function iterate(db: Database, script: string): Iterable<PreparedStatement> {
|
||||||
return (db as unknown as {
|
return (
|
||||||
iterateStatements(sql: string): Iterable<PreparedStatement>
|
db as unknown as {
|
||||||
}).iterateStatements(script)
|
iterateStatements(sql: string): Iterable<PreparedStatement>
|
||||||
|
}
|
||||||
|
).iterateStatements(script)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,9 +171,17 @@ function leadingKeyword(statement: PreparedStatement) {
|
|||||||
}
|
}
|
||||||
// 万一这个 build 没开 SQLITE_ENABLE_NORMALIZE,退回到原文剥注释
|
// 万一这个 build 没开 SQLITE_ENABLE_NORMALIZE,退回到原文剥注释
|
||||||
if (!text) {
|
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() ?? ""
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,11 +209,17 @@ class ByteBudget {
|
|||||||
? Buffer.byteLength(value)
|
? Buffer.byteLength(value)
|
||||||
: 8 // 数字和 NULL 按定长算,撑不出内存
|
: 8 // 数字和 NULL 按定长算,撑不出内存
|
||||||
if (bytes > this.maxBytes) {
|
if (bytes > this.maxBytes) {
|
||||||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制")
|
throw new SqlCaseError(
|
||||||
|
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
|
||||||
|
"单个数据值超出内存限制",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
this.used += bytes
|
this.used += bytes
|
||||||
if (this.used > this.maxBytes) {
|
if (this.used > this.maxBytes) {
|
||||||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "查询结果超出内存限制")
|
throw new SqlCaseError(
|
||||||
|
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
|
||||||
|
"查询结果超出内存限制",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,12 +254,17 @@ function executeStatements(
|
|||||||
budget?.charge(row)
|
budget?.charge(row)
|
||||||
rows.push(canonicalRow(row))
|
rows.push(canonicalRow(row))
|
||||||
if (rows.length > ROW_LIMIT) {
|
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 }
|
last = { columns: names.length, rows }
|
||||||
} else {
|
} else {
|
||||||
while (statement.step()) { /* 无结果集语句,推进到结束 */ }
|
while (statement.step()) {
|
||||||
|
/* 无结果集语句,推进到结束 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
statement.free()
|
statement.free()
|
||||||
@@ -242,7 +275,10 @@ function executeStatements(
|
|||||||
|
|
||||||
/** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */
|
/** dump 所有用户表:{表名: 列数 + 已排序的行},表状态天然无序 */
|
||||||
function dumpTables(db: Database, budget?: ByteBudget) {
|
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[] }> = {}
|
const state: Record<string, { columns: number; rows: string[] }> = {}
|
||||||
for (const table of names) {
|
for (const table of names) {
|
||||||
const quoted = String(table).replaceAll('"', '""')
|
const quoted = String(table).replaceAll('"', '""')
|
||||||
@@ -253,7 +289,10 @@ function dumpTables(db: Database, budget?: ByteBudget) {
|
|||||||
return canonicalRow(row as unknown[])
|
return canonicalRow(row as unknown[])
|
||||||
})
|
})
|
||||||
if (rows.length > ROW_LIMIT) {
|
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)] = {
|
state[String(table)] = {
|
||||||
// 空表 exec 不返回结果,列数用 table_info 兜底
|
// 空表 exec 不返回结果,列数用 table_info 兜底
|
||||||
@@ -278,14 +317,25 @@ function trustedErrorText(message: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 执行受信脚本(初始化/标准答案),任何失败都是出题问题 → SYSTEM_ERROR */
|
/** 执行受信脚本(初始化/标准答案),任何失败都是出题问题 → SYSTEM_ERROR */
|
||||||
function executeTrusted(db: Database, script: string, deadline: number, prefix: string) {
|
function executeTrusted(
|
||||||
|
db: Database,
|
||||||
|
script: string,
|
||||||
|
deadline: number,
|
||||||
|
prefix: string,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
return executeStatements(db, script, deadline)
|
return executeStatements(db, script, deadline)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SqlCaseError) {
|
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))}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,36 +350,63 @@ function runStudent(
|
|||||||
// 查询题只读:PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单
|
// 查询题只读:PRAGMA query_only 是 SQLite 原生开关,替代旧实现的 authorizer 白名单
|
||||||
if (mode === "query") db.run("PRAGMA query_only=1")
|
if (mode === "query") db.run("PRAGMA query_only=1")
|
||||||
// 把题目的 memoryLimit 变成学生看得见的约束,替代旧实现的 setlimit(LIMIT_LENGTH)
|
// 把题目的 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 {
|
try {
|
||||||
const last = executeStatements(db, script, deadline, (statement) => {
|
const last = executeStatements(
|
||||||
// query_only 自己就是个 PRAGMA,不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
|
db,
|
||||||
// 就把只读关掉了。旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒掉,这里对齐它。
|
script,
|
||||||
// 教学场景下学生也没有用 PRAGMA 的正当需求,两种题型一律拒。
|
deadline,
|
||||||
if (leadingKeyword(statement) === "PRAGMA") {
|
(statement) => {
|
||||||
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, "禁止使用 PRAGMA 语句")
|
// query_only 自己就是个 PRAGMA,不拦 PRAGMA 的话学生一句 `PRAGMA query_only=0`
|
||||||
}
|
// 就把只读关掉了。旧实现的 authorizer 把 SQLITE_PRAGMA 一律拒掉,这里对齐它。
|
||||||
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
|
// 教学场景下学生也没有用 PRAGMA 的正当需求,两种题型一律拒。
|
||||||
applyLimits(db, memoryLimitMb)
|
if (leadingKeyword(statement) === "PRAGMA") {
|
||||||
if (mode === "query") db.run("PRAGMA query_only=1")
|
throw new SqlCaseError(
|
||||||
}, budget)
|
JudgeStatus.RUNTIME_ERROR,
|
||||||
|
"禁止使用 PRAGMA 语句",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 兜底:万一漏掉某种改设置的写法,限制在每条语句前都重放一遍
|
||||||
|
applyLimits(db, memoryLimitMb)
|
||||||
|
if (mode === "query") db.run("PRAGMA query_only=1")
|
||||||
|
},
|
||||||
|
budget,
|
||||||
|
)
|
||||||
if (mode === "query") return last
|
if (mode === "query") return last
|
||||||
return dumpTables(db, budget)
|
return dumpTables(db, budget)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SqlCaseError) throw error
|
if (error instanceof SqlCaseError) throw error
|
||||||
const message = String((error as Error).message)
|
const message = String((error as Error).message)
|
||||||
if (message.includes("interrupted")) {
|
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")) {
|
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 自身的长度上限
|
// WASM 堆触顶(zeroblob/group_concat 构造出的超大单值)或 SQLite 自身的长度上限
|
||||||
if (message.includes("too big") || message.includes("out of memory") || message.includes("Aborted")) {
|
if (
|
||||||
throw new SqlCaseError(JudgeStatus.MEMORY_LIMIT_EXCEEDED, "单个数据值超出内存限制")
|
message.includes("too big") ||
|
||||||
|
message.includes("out of memory") ||
|
||||||
|
message.includes("Aborted")
|
||||||
|
) {
|
||||||
|
throw new SqlCaseError(
|
||||||
|
JudgeStatus.MEMORY_LIMIT_EXCEEDED,
|
||||||
|
"单个数据值超出内存限制",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (message.includes("readonly database")) {
|
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))) {
|
if (SYNTAX_ERROR_MARKERS.some((marker) => message.includes(marker))) {
|
||||||
throw new SqlCaseError(JudgeStatus.COMPILE_ERROR, truncate(message))
|
throw new SqlCaseError(JudgeStatus.COMPILE_ERROR, truncate(message))
|
||||||
@@ -337,7 +414,11 @@ function runStudent(
|
|||||||
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, truncate(message))
|
throw new SqlCaseError(JudgeStatus.RUNTIME_ERROR, truncate(message))
|
||||||
} finally {
|
} finally {
|
||||||
if (mode === "query") {
|
if (mode === "query") {
|
||||||
try { db.run("PRAGMA query_only=0") } catch { /* 连接可能已不可用 */ }
|
try {
|
||||||
|
db.run("PRAGMA query_only=0")
|
||||||
|
} catch {
|
||||||
|
/* 连接可能已不可用 */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -412,14 +493,22 @@ export async function runCase(
|
|||||||
const refDb = newDatabase(SQL, options.memoryLimitMb)
|
const refDb = newDatabase(SQL, options.memoryLimitMb)
|
||||||
try {
|
try {
|
||||||
executeTrusted(refDb, initSql, trustedDeadline, "初始化脚本执行失败")
|
executeTrusted(refDb, initSql, trustedDeadline, "初始化脚本执行失败")
|
||||||
const last = executeTrusted(refDb, refSql, trustedDeadline, "标准答案执行失败")
|
const last = executeTrusted(
|
||||||
|
refDb,
|
||||||
|
refSql,
|
||||||
|
trustedDeadline,
|
||||||
|
"标准答案执行失败",
|
||||||
|
)
|
||||||
if (options.mode === "query") {
|
if (options.mode === "query") {
|
||||||
expected = last
|
expected = last
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
expected = dumpTables(refDb)
|
expected = dumpTables(refDb)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超出限制: ${(error as SqlCaseError).detail}`)
|
throw new SqlCaseError(
|
||||||
|
JudgeStatus.SYSTEM_ERROR,
|
||||||
|
`标准答案结果超出限制: ${(error as SqlCaseError).detail}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -460,7 +549,13 @@ export async function runCase(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
elapsed = Date.now() - start
|
elapsed = Date.now() - start
|
||||||
const failure = error as SqlCaseError
|
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
|
elapsed = Date.now() - start
|
||||||
} finally {
|
} finally {
|
||||||
@@ -496,20 +591,35 @@ interface DisplayTable {
|
|||||||
|
|
||||||
/** 按建表顺序 dump 用户表的原始行用于展示(区别于 dumpTables 的归一化判题态) */
|
/** 按建表顺序 dump 用户表的原始行用于展示(区别于 dumpTables 的归一化判题态) */
|
||||||
function dumpDisplayTables(db: Database, only?: Set<string>): DisplayTable[] {
|
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[] = []
|
const tables: DisplayTable[] = []
|
||||||
for (const raw of names) {
|
for (const raw of names) {
|
||||||
const name = String(raw)
|
const name = String(raw)
|
||||||
if (only && !only.has(name)) continue
|
if (only && !only.has(name)) continue
|
||||||
const quoted = name.replaceAll('"', '""')
|
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]),
|
name: String(row[1]),
|
||||||
type: String(row[2] ?? ""),
|
type: String(row[2] ?? ""),
|
||||||
}))
|
}))
|
||||||
const total = Number(db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0)
|
const total = Number(
|
||||||
const rows = (db.exec(`SELECT * FROM "${quoted}" LIMIT ${DISPLAY_ROW_LIMIT}`)[0]?.values ?? [])
|
db.exec(`SELECT COUNT(*) FROM "${quoted}"`)[0]?.values[0]?.[0] ?? 0,
|
||||||
.map((row) => (row as unknown[]).map(displayValue))
|
)
|
||||||
tables.push({ name, columns, rows, total_rows: total, truncated: total > DISPLAY_ROW_LIMIT })
|
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
|
return tables
|
||||||
}
|
}
|
||||||
@@ -546,17 +656,27 @@ export async function buildDisplay(
|
|||||||
for (const statement of iterate(db, refSql)) {
|
for (const statement of iterate(db, refSql)) {
|
||||||
try {
|
try {
|
||||||
const names = statement.getColumnNames()
|
const names = statement.getColumnNames()
|
||||||
if (names.length === 0) { while (statement.step()) { /* 无结果集 */ } ; continue }
|
if (names.length === 0) {
|
||||||
|
while (statement.step()) {
|
||||||
|
/* 无结果集 */
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
const rows: unknown[][] = []
|
const rows: unknown[][] = []
|
||||||
while (statement.step()) {
|
while (statement.step()) {
|
||||||
rows.push(statement.get())
|
rows.push(statement.get())
|
||||||
if (rows.length > ROW_LIMIT) {
|
if (rows.length > ROW_LIMIT) {
|
||||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, `标准答案结果超过 ${ROW_LIMIT} 行`)
|
throw new SqlCaseError(
|
||||||
|
JudgeStatus.SYSTEM_ERROR,
|
||||||
|
`标准答案结果超过 ${ROW_LIMIT} 行`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expected = {
|
expected = {
|
||||||
columns: queryResultColumns(names, tables),
|
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,
|
total_rows: rows.length,
|
||||||
truncated: rows.length > DISPLAY_ROW_LIMIT,
|
truncated: rows.length > DISPLAY_ROW_LIMIT,
|
||||||
}
|
}
|
||||||
@@ -566,10 +686,16 @@ export async function buildDisplay(
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SqlCaseError) throw 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) {
|
if (expected === null) {
|
||||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未产生查询结果集")
|
throw new SqlCaseError(
|
||||||
|
JudgeStatus.SYSTEM_ERROR,
|
||||||
|
"标准答案未产生查询结果集",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return { tables, expected }
|
return { tables, expected }
|
||||||
}
|
}
|
||||||
@@ -578,18 +704,31 @@ export async function buildDisplay(
|
|||||||
executeTrusted(db, refSql, deadline, "标准答案执行失败")
|
executeTrusted(db, refSql, deadline, "标准答案执行失败")
|
||||||
const after = dumpTables(db)
|
const after = dumpTables(db)
|
||||||
const changed = new Set<string>()
|
const changed = new Set<string>()
|
||||||
for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
for (const name of new Set([
|
||||||
if (JSON.stringify(before[name]) !== JSON.stringify(after[name])) changed.add(name)
|
...Object.keys(before),
|
||||||
|
...Object.keys(after),
|
||||||
|
])) {
|
||||||
|
if (JSON.stringify(before[name]) !== JSON.stringify(after[name]))
|
||||||
|
changed.add(name)
|
||||||
}
|
}
|
||||||
if (changed.size === 0) {
|
if (changed.size === 0) {
|
||||||
throw new SqlCaseError(JudgeStatus.SYSTEM_ERROR, "标准答案未修改任何表数据,请检查题目配置")
|
throw new SqlCaseError(
|
||||||
|
JudgeStatus.SYSTEM_ERROR,
|
||||||
|
"标准答案未修改任何表数据,请检查题目配置",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const changedTables = dumpDisplayTables(db, changed)
|
const changedTables = dumpDisplayTables(db, changed)
|
||||||
// 被标准答案 DROP 的表已不在库中,用初始展示数据补齐条目(前端据 dropped 提示「表已删除」)
|
// 被标准答案 DROP 的表已不在库中,用初始展示数据补齐条目(前端据 dropped 提示「表已删除」)
|
||||||
const existing = new Set(changedTables.map((table) => table.name))
|
const existing = new Set(changedTables.map((table) => table.name))
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
if (changed.has(table.name) && !existing.has(table.name)) {
|
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 } }
|
return { tables, expected: { changed_tables: changedTables } }
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { SqlDisplay } from "@oj2/contract"
|
||||||
|
|
||||||
import { selfCommand } from "../../runtime"
|
import { selfCommand } from "../../runtime"
|
||||||
import { JudgeStatus, type JudgeStatusValue } from "../status"
|
import { JudgeStatus, type JudgeStatusValue } from "../status"
|
||||||
import { DISPLAY_BUDGET_MS, trustedBudgetMs, type CaseResult } from "./engine"
|
import { DISPLAY_BUDGET_MS, trustedBudgetMs, type CaseResult } from "./engine"
|
||||||
@@ -83,16 +85,24 @@ const PHASE_FAILURE: Record<string, SqlJobFailure> = {
|
|||||||
result: JudgeStatus.SYSTEM_ERROR,
|
result: JudgeStatus.SYSTEM_ERROR,
|
||||||
message: "初始化脚本或标准答案超时/内存超限,请检查题目配置",
|
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 子进程 —— 见文件头「为什么必须有这道闸」。
|
// 递归闸。子进程里绝不允许再 spawn 子进程 —— 见文件头「为什么必须有这道闸」。
|
||||||
if (process.env[CHILD_MARKER]) {
|
if (process.env[CHILD_MARKER]) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
result: JudgeStatus.SYSTEM_ERROR,
|
result: JudgeStatus.SYSTEM_ERROR,
|
||||||
message: "SQL 判题子进程试图再起子进程,已阻断(入口子命令分发可能不正确)",
|
message:
|
||||||
|
"SQL 判题子进程试图再起子进程,已阻断(入口子命令分发可能不正确)",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +123,10 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
|
|||||||
child.stdin.write(JSON.stringify(job))
|
child.stdin.write(JSON.stringify(job))
|
||||||
await child.stdin.end()
|
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 = ""
|
let phase = ""
|
||||||
// stderr 要边读边看:阶段标记一到就得马上换兜底时限,攒到进程结束再读就没意义了
|
// stderr 要边读边看:阶段标记一到就得马上换兜底时限,攒到进程结束再读就没意义了
|
||||||
const readStderr = (async () => {
|
const readStderr = (async () => {
|
||||||
@@ -130,7 +143,10 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
|
|||||||
phase = latest
|
phase = latest
|
||||||
if (phase === "student" && budget.studentMs !== null) {
|
if (phase === "student" && budget.studentMs !== null) {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
timer = setTimeout(() => child.kill("SIGKILL"), budget.studentMs + STUDENT_SLACK_MS)
|
timer = setTimeout(
|
||||||
|
() => child.kill("SIGKILL"),
|
||||||
|
budget.studentMs + STUDENT_SLACK_MS,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,7 +154,10 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
|
|||||||
|
|
||||||
let stdout = ""
|
let stdout = ""
|
||||||
try {
|
try {
|
||||||
;[stdout] = await Promise.all([new Response(child.stdout).text(), readStderr])
|
;[stdout] = await Promise.all([
|
||||||
|
new Response(child.stdout).text(),
|
||||||
|
readStderr,
|
||||||
|
])
|
||||||
await child.exited
|
await child.exited
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer)
|
clearTimeout(timer)
|
||||||
@@ -158,12 +177,15 @@ async function runJob<T>(job: SqlJob, budget: JobBudget): Promise<SqlJobOutcome<
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(stdout) as
|
const parsed = JSON.parse(stdout) as
|
||||||
| { ok: true; case?: CaseResult; display?: unknown }
|
{ ok: true; case?: CaseResult; display?: unknown } | SqlJobFailure
|
||||||
| SqlJobFailure
|
|
||||||
if (!parsed.ok) return parsed
|
if (!parsed.ok) return parsed
|
||||||
return { ok: true, value: (parsed.case ?? parsed.display) as T }
|
return { ok: true, value: (parsed.case ?? parsed.display) as T }
|
||||||
} catch {
|
} catch {
|
||||||
return { ok: false, result: JudgeStatus.SYSTEM_ERROR, message: "SQL 判题子进程返回了无法解析的结果" }
|
return {
|
||||||
|
ok: false,
|
||||||
|
result: JudgeStatus.SYSTEM_ERROR,
|
||||||
|
message: "SQL 判题子进程返回了无法解析的结果",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,8 +196,14 @@ export function runSqlCase(job: Extract<SqlJob, { kind: "judge" }>) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") {
|
export function buildSqlDisplay(
|
||||||
return runJob<{ tables: unknown[]; expected: unknown }>(
|
initSql: string,
|
||||||
|
refSql: string,
|
||||||
|
mode: "query" | "modify",
|
||||||
|
) {
|
||||||
|
// 子进程产出的形状由 engine.ts 的 dumpDisplayTables / runDisplay 决定,就是契约里的
|
||||||
|
// SqlDisplay —— 同一个仓库里的两端,不在这儿再 parse 一遍
|
||||||
|
return runJob<SqlDisplay>(
|
||||||
{ kind: "display", initSql, refSql, mode },
|
{ kind: "display", initSql, refSql, mode },
|
||||||
{ trustedMs: DISPLAY_BUDGET_MS, studentMs: null },
|
{ trustedMs: DISPLAY_BUDGET_MS, studentMs: null },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,58 @@
|
|||||||
export const JudgeStatus = {
|
import { JudgeStatus, type JudgeStatusValue } from "@oj2/contract"
|
||||||
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
|
|
||||||
|
|
||||||
export type JudgeStatusValue = (typeof JudgeStatus)[keyof typeof JudgeStatus]
|
// 状态码的唯一一份在 packages/contract/src/judge-status.ts,这里只再导出,
|
||||||
|
// 省得二十几处 import 一起改
|
||||||
|
export { JudgeStatus, type JudgeStatusValue }
|
||||||
|
|
||||||
export function isAccepted(result: number) {
|
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` 措辞对应(状态码本身已收进契约,名字仍是两份)。
|
||||||
|
* 目前只用在喂给模型的 prompt 里 —— 原来那里拼的是裸状态码(`结果:-1`),
|
||||||
|
* 模型根本不知道 -1 是「答案错误」还是别的什么,等于白给一条信息。
|
||||||
|
*/
|
||||||
|
export const JUDGE_STATUS_NAME: Record<number, string> = {
|
||||||
|
[JudgeStatus.COMPILE_ERROR]: "编译失败",
|
||||||
|
[JudgeStatus.WRONG_ANSWER]: "答案错误",
|
||||||
|
[JudgeStatus.ACCEPTED]: "答案正确",
|
||||||
|
[JudgeStatus.CPU_TIME_LIMIT_EXCEEDED]: "运行超时",
|
||||||
|
[JudgeStatus.REAL_TIME_LIMIT_EXCEEDED]: "运行超时",
|
||||||
|
[JudgeStatus.MEMORY_LIMIT_EXCEEDED]: "内存超限",
|
||||||
|
[JudgeStatus.RUNTIME_ERROR]: "运行时错误",
|
||||||
|
[JudgeStatus.SYSTEM_ERROR]: "系统错误",
|
||||||
|
[JudgeStatus.PENDING]: "等待评分",
|
||||||
|
[JudgeStatus.JUDGING]: "正在评分",
|
||||||
|
[JudgeStatus.PARTIALLY_ACCEPTED]: "部分正确",
|
||||||
|
[JudgeStatus.AST_CHECK_FAILED]: "答案正确,但语法未通过",
|
||||||
|
}
|
||||||
|
|
||||||
|
export function judgeStatusName(result: number) {
|
||||||
|
return JUDGE_STATUS_NAME[result] ?? `未知状态(${result})`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 还没判完的两个状态。这类提交**已经落库但结果未定**,凡是算「正确率」的地方都得把
|
||||||
|
* 它们从分母里摘掉 —— 否则全班同时交卷的那几秒,分母涨了分子没涨,正确率凭空掉一截。
|
||||||
|
* 人数口径不受影响:交了但还在判的学生仍然算「交过」,不该被点名成「没做」。
|
||||||
|
*/
|
||||||
|
export const UNJUDGED_RESULTS: JudgeStatusValue[] = [
|
||||||
|
JudgeStatus.PENDING,
|
||||||
|
JudgeStatus.JUDGING,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* **不**计入「这道题失败了几次」的状态。除了通过(含 AST_CHECK_FAILED,那也是答案对了)
|
||||||
|
* 和还没判完的两个,还排掉 SYSTEM_ERROR —— 判题机自己崩了不是学生的问题,
|
||||||
|
* 不该推着 AI 提示的解锁进度往前走。
|
||||||
|
*/
|
||||||
|
export const NON_FAILURE_RESULTS: JudgeStatusValue[] = [
|
||||||
|
JudgeStatus.ACCEPTED,
|
||||||
|
JudgeStatus.AST_CHECK_FAILED,
|
||||||
|
JudgeStatus.PENDING,
|
||||||
|
JudgeStatus.JUDGING,
|
||||||
|
JudgeStatus.SYSTEM_ERROR,
|
||||||
|
]
|
||||||
|
|||||||
+12
-1
@@ -12,6 +12,7 @@
|
|||||||
* oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用
|
* oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用
|
||||||
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调
|
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调
|
||||||
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
|
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
|
||||||
|
* oj2-api recount # 把题目/用户的计数列、成就的已解锁数重算回与明细一致,默认只读预演
|
||||||
*
|
*
|
||||||
* 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用
|
* 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用
|
||||||
* (Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
|
* (Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
|
||||||
@@ -33,6 +34,14 @@ switch (command) {
|
|||||||
await runMigrations()
|
await runMigrations()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
// 数据订正,跟着二进制走而不是留成源码脚本 —— 生产镜像里没有 bun 也没有源码。
|
||||||
|
// 反范式计数列被重判等操作带偏之后拿它对账,默认只读预演,--apply 才写。
|
||||||
|
case "recount": {
|
||||||
|
const { recount } = await import("./scripts/recount")
|
||||||
|
process.exit(
|
||||||
|
await recount({ apply: process.argv.slice(3).includes("--apply") }),
|
||||||
|
)
|
||||||
|
}
|
||||||
case "sql-child": {
|
case "sql-child": {
|
||||||
const { runSqlChild } = await import("./judge/sql/child")
|
const { runSqlChild } = await import("./judge/sql/child")
|
||||||
await runSqlChild()
|
await runSqlChild()
|
||||||
@@ -53,6 +62,8 @@ switch (command) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
console.error(`未知子命令:${command}\n可用:serve | worker | migrate | healthcheck | sql-child`)
|
console.error(
|
||||||
|
`未知子命令:${command}\n可用:serve | worker | migrate | recount | healthcheck | sql-child`,
|
||||||
|
)
|
||||||
process.exit(2)
|
process.exit(2)
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-9
@@ -2,18 +2,40 @@ import Redis from "ioredis"
|
|||||||
|
|
||||||
import { config } from "./config"
|
import { config } from "./config"
|
||||||
|
|
||||||
export const redis = new Redis(config.redisUrl, {
|
/**
|
||||||
maxRetriesPerRequest: 1,
|
* 每条连接都要挂 error 监听。
|
||||||
})
|
*
|
||||||
|
* ioredis 对没有监听者的 error 走 silentEmit —— 不会像普通 EventEmitter 那样崩进程,
|
||||||
|
* 但会把连接错误直接 `console.error("[ioredis] Unhandled error event:", ...)` 打到
|
||||||
|
* stderr,绕开这里的日志,而且不说是哪条连接出的事。这个进程同时开着会话读写、
|
||||||
|
* 两条队列、一条订阅,出问题时「哪条」正是要先知道的。
|
||||||
|
*/
|
||||||
|
function withErrorLogging(client: Redis, name: string) {
|
||||||
|
client.on("error", (error) => {
|
||||||
|
console.error(`Redis connection error (${name})`, error)
|
||||||
|
})
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话、限流、发布事件都走这条。`maxRetriesPerRequest: 1` 是故意的:每个带鉴权的
|
||||||
|
* 请求都要读一次会话,Redis 不可用时快速失败成 500,比让请求挂在重试里更好。
|
||||||
|
*/
|
||||||
|
export const redis = withErrorLogging(
|
||||||
|
new Redis(config.redisUrl, { maxRetriesPerRequest: 1 }),
|
||||||
|
"main",
|
||||||
|
)
|
||||||
|
|
||||||
export function createBlockingRedis() {
|
export function createBlockingRedis() {
|
||||||
return new Redis(config.redisUrl, {
|
return withErrorLogging(
|
||||||
maxRetriesPerRequest: null,
|
new Redis(config.redisUrl, { maxRetriesPerRequest: null }),
|
||||||
})
|
"blocking",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSubscriberRedis() {
|
export function createSubscriberRedis() {
|
||||||
return new Redis(config.redisUrl, {
|
return withErrorLogging(
|
||||||
maxRetriesPerRequest: null,
|
new Redis(config.redisUrl, { maxRetriesPerRequest: null }),
|
||||||
})
|
"subscriber",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+436
-125
@@ -2,14 +2,18 @@ import { randomBytes } from "node:crypto"
|
|||||||
import { extname, resolve } from "node:path"
|
import { extname, resolve } from "node:path"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
activityRankItemSchema,
|
|
||||||
metricsSchema,
|
|
||||||
problemRankSchema,
|
|
||||||
myRankSchema,
|
|
||||||
rankProfileSchema,
|
rankProfileSchema,
|
||||||
registerRequestSchema,
|
registerRequestSchema,
|
||||||
|
STUDENT_ROLES,
|
||||||
updateProfileRequestSchema,
|
updateProfileRequestSchema,
|
||||||
userRankSchema,
|
type ActivityRankItem,
|
||||||
|
type Metrics,
|
||||||
|
type MyRank,
|
||||||
|
type ProblemRank,
|
||||||
|
type RankProfile,
|
||||||
|
type UserRank,
|
||||||
|
type WeeklyRank,
|
||||||
|
type WeeklyRankItem,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
@@ -24,13 +28,18 @@ import {
|
|||||||
isNull,
|
isNull,
|
||||||
lt,
|
lt,
|
||||||
lte,
|
lte,
|
||||||
|
max,
|
||||||
min,
|
min,
|
||||||
|
ne,
|
||||||
|
notExists,
|
||||||
or,
|
or,
|
||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
|
import { alias } from "drizzle-orm/pg-core"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { hashPassword } from "../auth/password"
|
import { hashPassword } from "../auth/password"
|
||||||
|
import { onlineUserIds } from "../auth/presence"
|
||||||
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
|
import { optionalAuth, requireAuth, type AppEnv } from "../auth/middleware"
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
@@ -38,15 +47,29 @@ import { failure, success } from "../http"
|
|||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus } from "../judge/status"
|
||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
import { getUserProfileById } from "../services/profile"
|
import { getUserProfileById } from "../services/profile"
|
||||||
import { objectValue, queryInteger, sampleUser } from "./helpers"
|
import { localTime, weekStart } from "../time"
|
||||||
|
import {
|
||||||
|
isTeacherOrAbove,
|
||||||
|
objectValue,
|
||||||
|
queryInteger,
|
||||||
|
sampleUser,
|
||||||
|
} from "./helpers"
|
||||||
|
|
||||||
export const accountRoutes = new Hono<AppEnv>()
|
export const accountRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
accountRoutes.post("/users", async (c) => {
|
accountRoutes.post("/users", async (c) => {
|
||||||
const parsed = registerRequestSchema.safeParse(await c.req.json().catch(() => null))
|
const parsed = registerRequestSchema.safeParse(
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid registration payload")
|
await c.req.json().catch(() => null),
|
||||||
|
)
|
||||||
|
if (!parsed.success)
|
||||||
|
return failure(c, 400, "invalid-request", "Invalid registration payload")
|
||||||
if (!(await getBooleanOption("allow_register", true))) {
|
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()
|
const username = parsed.data.username.toLowerCase()
|
||||||
@@ -54,7 +77,12 @@ accountRoutes.post("/users", async (c) => {
|
|||||||
const [duplicate] = await db
|
const [duplicate] = await db
|
||||||
.select({ username: schema.user.username, email: schema.user.email })
|
.select({ username: schema.user.username, email: schema.user.email })
|
||||||
.from(schema.user)
|
.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)
|
.limit(1)
|
||||||
if (duplicate?.username.toLowerCase() === username) {
|
if (duplicate?.username.toLowerCase() === username) {
|
||||||
return failure(c, 409, "username-exists", "Username already exists")
|
return failure(c, 409, "username-exists", "Username already exists")
|
||||||
@@ -66,22 +94,21 @@ accountRoutes.post("/users", async (c) => {
|
|||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const password = await hashPassword(parsed.data.password)
|
const password = await hashPassword(parsed.data.password)
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const [created] = await tx.insert(schema.user).values({
|
const [created] = await tx
|
||||||
username,
|
.insert(schema.user)
|
||||||
email,
|
.values({
|
||||||
password,
|
username,
|
||||||
rawPassword: parsed.data.password.slice(0, 20),
|
email,
|
||||||
lastLogin: null,
|
password,
|
||||||
createTime: now,
|
rawPassword: parsed.data.password.slice(0, 20),
|
||||||
adminType: "Regular User",
|
lastLogin: null,
|
||||||
authToken: null,
|
createTime: now,
|
||||||
openApi: false,
|
adminType: "Regular User",
|
||||||
openApiAppkey: null,
|
isDisabled: false,
|
||||||
isDisabled: false,
|
problemPermission: "None",
|
||||||
problemPermission: "None",
|
className: null,
|
||||||
sessionKeys: [],
|
})
|
||||||
className: null,
|
.returning({ id: schema.user.id })
|
||||||
}).returning({ id: schema.user.id })
|
|
||||||
if (!created) throw new Error("User insert did not return an id")
|
if (!created) throw new Error("User insert did not return an id")
|
||||||
await tx.insert(schema.userProfile).values({
|
await tx.insert(schema.userProfile).values({
|
||||||
userId: created.id,
|
userId: created.id,
|
||||||
@@ -101,31 +128,57 @@ accountRoutes.get("/profiles/:username", optionalAuth, async (c) => {
|
|||||||
// `if not user.is_authenticated: return self.success()` —— 匿名一律返回空,
|
// `if not user.is_authenticated: return self.success()` —— 匿名一律返回空,
|
||||||
// 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
|
// 否则用户名可经 /rankings/users 公开枚举,进而无 cookie 批量收集全校学生的邮箱与最后登录时间。
|
||||||
if (!c.get("user")) return success(c, null)
|
if (!c.get("user")) return success(c, null)
|
||||||
const [target] = await db.select({ id: schema.user.id }).from(schema.user)
|
const [target] = await db
|
||||||
.where(and(sql`lower(${schema.user.username}) = lower(${c.req.param("username")})`, eq(schema.user.isDisabled, false))).limit(1)
|
.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")
|
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)
|
const profile = await getUserProfileById(
|
||||||
if (!profile) return failure(c, 404, "profile-not-found", "User profile does not exist")
|
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)
|
return success(c, profile)
|
||||||
})
|
})
|
||||||
|
|
||||||
accountRoutes.put("/me/profile", requireAuth, async (c) => {
|
accountRoutes.put("/me/profile", requireAuth, async (c) => {
|
||||||
const parsed = updateProfileRequestSchema.safeParse(await c.req.json().catch(() => null))
|
const parsed = updateProfileRequestSchema.safeParse(
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid profile payload")
|
await c.req.json().catch(() => null),
|
||||||
const values = Object.fromEntries(
|
|
||||||
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))
|
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,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
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)
|
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)
|
return success(c, profile)
|
||||||
})
|
})
|
||||||
|
|
||||||
accountRoutes.post("/me/avatar", requireAuth, async (c) => {
|
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
|
const image = body.image
|
||||||
if (!(image instanceof File)) return failure(c, 400, "invalid-file", "Invalid file content")
|
if (!(image instanceof File))
|
||||||
if (image.size > 2 * 1024 * 1024) return failure(c, 400, "file-too-large", "Picture is too large")
|
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()
|
const extension = extname(image.name).toLowerCase()
|
||||||
if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) {
|
if (![".gif", ".jpg", ".jpeg", ".bmp", ".png"].includes(extension)) {
|
||||||
return failure(c, 400, "unsupported-file", "Unsupported file format")
|
return failure(c, 400, "unsupported-file", "Unsupported file format")
|
||||||
@@ -135,17 +188,34 @@ accountRoutes.post("/me/avatar", requireAuth, async (c) => {
|
|||||||
await Bun.$`mkdir -p ${directory}`.quiet()
|
await Bun.$`mkdir -p ${directory}`.quiet()
|
||||||
await Bun.write(resolve(directory, filename), image)
|
await Bun.write(resolve(directory, filename), image)
|
||||||
const avatar = `${config.avatarUriPrefix}/${filename}`
|
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 })
|
return success(c, { avatar })
|
||||||
})
|
})
|
||||||
|
|
||||||
accountRoutes.get("/users/:id/metrics", async (c) => {
|
accountRoutes.get("/users/:id/metrics", async (c) => {
|
||||||
const userId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const userId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const [row] = await db.select({ total: count(), first: min(schema.submission.createTime), latest: sql<string>`max(${schema.submission.createTime})` })
|
// 比赛提交也算:首末提交时间、学习天数都连比赛一起统计
|
||||||
|
const [row] = await db
|
||||||
|
.select({
|
||||||
|
first: min(schema.submission.createTime),
|
||||||
|
latest: max(schema.submission.createTime),
|
||||||
|
activeDays: countDistinct(
|
||||||
|
sql`date(${localTime(schema.submission.createTime)})`,
|
||||||
|
),
|
||||||
|
})
|
||||||
.from(schema.submission)
|
.from(schema.submission)
|
||||||
.where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId)))
|
.where(eq(schema.submission.userId, userId))
|
||||||
if (!row?.total || !row.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交")
|
if (!row?.first || !row.latest)
|
||||||
return success(c, metricsSchema.parse({ now: new Date().toISOString(), first: row.first, latest: 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)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -161,7 +231,7 @@ const LEADERBOARD_SIZE = 100
|
|||||||
|
|
||||||
/** 入榜人群:正常状态的学生与学生管理员。教师和超管不参与排名。 */
|
/** 入榜人群:正常状态的学生与学生管理员。教师和超管不参与排名。 */
|
||||||
const leaderboardWhere = and(
|
const leaderboardWhere = and(
|
||||||
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
eq(schema.user.isDisabled, false),
|
eq(schema.user.isDisabled, false),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,40 +248,64 @@ const leaderboardOrder = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
|
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 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
|
|
||||||
const [totalRow] = await db.select({ value: count() }).from(schema.userProfile)
|
// 榜单封顶 100 名,所以这一页最多还能取几条只取决于 offset,**不取决于总人数** ——
|
||||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
// 真人不够时数据库自己会少返回。不拿 total 当上限,三段查询就能并发发出去,
|
||||||
.where(leaderboardWhere)
|
// 端点延迟从「四个来回相加」变成「最慢的那个」。越界页一条不剩,直接不发 SQL。
|
||||||
const total = Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE)
|
const pageLimit = Math.max(0, Math.min(limit, LEADERBOARD_SIZE - offset))
|
||||||
|
|
||||||
// 末页可能只剩不足 limit 条,越界页一条不剩 —— 后者直接不发 SQL
|
// 谁在线只给老师看,学生那边整列都是 null(见 rankProfileSchema.isOnline)
|
||||||
const pageLimit = Math.max(0, Math.min(limit, total - offset))
|
const [totalRow, rows, me, online] = await Promise.all([
|
||||||
const rows = pageLimit === 0 ? [] : await db
|
db
|
||||||
.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
.select({ value: count() })
|
||||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
.from(schema.userProfile)
|
||||||
.where(leaderboardWhere).orderBy(...leaderboardOrder)
|
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||||
.limit(pageLimit).offset(offset)
|
.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),
|
||||||
|
myLeaderboardRank(c.get("user")?.id),
|
||||||
|
isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null,
|
||||||
|
])
|
||||||
|
|
||||||
return success(c, userRankSchema.parse({
|
return success(c, {
|
||||||
results: rows.map(serializeRankRow),
|
results: rows.map((row) => serializeRankRow(row, online)),
|
||||||
total,
|
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
|
||||||
me: await myLeaderboardRank(c.get("user")?.id),
|
me,
|
||||||
}))
|
} satisfies UserRank)
|
||||||
})
|
})
|
||||||
|
|
||||||
function serializeRankRow({ profile, user }: {
|
function serializeRankRow(
|
||||||
profile: typeof schema.userProfile.$inferSelect
|
{
|
||||||
user: typeof schema.user.$inferSelect
|
profile,
|
||||||
}) {
|
user,
|
||||||
return rankProfileSchema.parse({
|
}: {
|
||||||
|
profile: typeof schema.userProfile.$inferSelect
|
||||||
|
user: typeof schema.user.$inferSelect
|
||||||
|
},
|
||||||
|
online: Set<number> | null = null,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
user: sampleUser(user, profile.realName),
|
user: sampleUser(user, profile.realName),
|
||||||
acceptedNumber: profile.acceptedNumber,
|
acceptedNumber: profile.acceptedNumber,
|
||||||
submissionNumber: profile.submissionNumber,
|
submissionNumber: profile.submissionNumber,
|
||||||
mood: profile.mood,
|
mood: profile.mood,
|
||||||
})
|
isOnline: online ? online.has(user.id) : null,
|
||||||
|
} satisfies RankProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -224,72 +318,270 @@ function serializeRankRow({ profile, user }: {
|
|||||||
async function myLeaderboardRank(userId: number | undefined) {
|
async function myLeaderboardRank(userId: number | undefined) {
|
||||||
if (!userId) return null
|
if (!userId) return null
|
||||||
const [mine] = await db
|
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))
|
.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
|
if (!mine) return null
|
||||||
|
|
||||||
const { acceptedNumber, submissionNumber } = mine.profile
|
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))
|
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id))
|
||||||
.where(and(leaderboardWhere, or(
|
.where(
|
||||||
gt(schema.userProfile.acceptedNumber, acceptedNumber),
|
|
||||||
and(
|
and(
|
||||||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
leaderboardWhere,
|
||||||
lt(schema.userProfile.submissionNumber, submissionNumber),
|
or(
|
||||||
|
gt(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||||
|
and(
|
||||||
|
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||||
|
lt(schema.userProfile.submissionNumber, submissionNumber),
|
||||||
|
),
|
||||||
|
and(
|
||||||
|
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
||||||
|
eq(schema.userProfile.submissionNumber, submissionNumber),
|
||||||
|
lt(schema.user.id, userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
and(
|
)
|
||||||
eq(schema.userProfile.acceptedNumber, acceptedNumber),
|
|
||||||
eq(schema.userProfile.submissionNumber, submissionNumber),
|
|
||||||
lt(schema.user.id, userId),
|
|
||||||
),
|
|
||||||
)))
|
|
||||||
|
|
||||||
return myRankSchema.parse({
|
return {
|
||||||
...serializeRankRow(mine),
|
...serializeRankRow(mine),
|
||||||
rank: (ahead?.value ?? 0) + 1,
|
rank: (ahead?.value ?? 0) + 1,
|
||||||
})
|
} satisfies MyRank
|
||||||
}
|
}
|
||||||
|
|
||||||
accountRoutes.get("/rankings/activity", async (c) => {
|
accountRoutes.get("/rankings/activity", async (c) => {
|
||||||
const start = c.req.query("start")
|
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)))
|
||||||
const rows = await db.select({ username: schema.submission.username, value: countDistinct(schema.submission.problemId) })
|
return failure(c, 400, "invalid-start", "start time is required")
|
||||||
|
/**
|
||||||
|
* 按 **user_id** 聚合,名字从 user 表取。按 `submission.username` 分组的话,
|
||||||
|
* 改过名的学生会裂成新旧两条各算各的 AC 题数 —— 排名被拆低,运气不好还会以
|
||||||
|
* 两个名字同时挂在榜上。同 `/submissions/statistics` 那批。
|
||||||
|
*
|
||||||
|
* innerJoin user 顺带把已删号学生的孤儿提交挡在外面,不用再兜底名字。
|
||||||
|
*/
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
username: schema.user.username,
|
||||||
|
value: countDistinct(schema.submission.problemId),
|
||||||
|
})
|
||||||
.from(schema.submission)
|
.from(schema.submission)
|
||||||
.innerJoin(schema.user, eq(schema.submission.userId, schema.user.id))
|
.innerJoin(schema.user, eq(schema.submission.userId, schema.user.id))
|
||||||
.where(and(
|
.where(
|
||||||
isNull(schema.submission.contestId),
|
and(
|
||||||
gte(schema.submission.createTime, start),
|
isNull(schema.submission.contestId),
|
||||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
gte(schema.submission.createTime, start),
|
||||||
eq(schema.user.isDisabled, false),
|
inArray(schema.submission.result, [
|
||||||
sql`${schema.user.adminType} <> 'Super Admin'`,
|
JudgeStatus.ACCEPTED,
|
||||||
))
|
JudgeStatus.AST_CHECK_FAILED,
|
||||||
.groupBy(schema.submission.username).orderBy(desc(countDistinct(schema.submission.problemId))).limit(10)
|
]),
|
||||||
return success(c, rows.map((row) => activityRankItemSchema.parse({ username: row.username, count: row.value })))
|
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_time,WHERE 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)
|
||||||
})
|
})
|
||||||
|
|
||||||
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
const [problem] = await db
|
||||||
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true))).limit(1)
|
.select({ id: schema.problem.id })
|
||||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
.from(schema.problem)
|
||||||
const accepted = and(eq(schema.submission.problemId, problem.id), inArray(schema.submission.result, [0, 10]))
|
.where(
|
||||||
const [all] = await db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(accepted)
|
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 className = user.className ?? ""
|
||||||
const classWhere = 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
|
: accepted
|
||||||
const [classCount] = className
|
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 }]
|
: [{ 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)))
|
.where(and(classWhere, eq(schema.submission.userId, user.id)))
|
||||||
let rank = -1
|
let rank = -1
|
||||||
if (first?.value) {
|
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
|
rank = rankRow?.value ?? -1
|
||||||
}
|
}
|
||||||
return success(c, problemRankSchema.parse({ className, rank, classAcCount: classCount?.value ?? 0, allAcCount: all?.value ?? 0 }))
|
return success(c, {
|
||||||
|
className,
|
||||||
|
rank,
|
||||||
|
classAcCount: classCount?.value ?? 0,
|
||||||
|
allAcCount: all?.value ?? 0,
|
||||||
|
} satisfies ProblemRank)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -304,25 +596,44 @@ accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
|
|||||||
* 题目一旦被隐藏或删除,display_ids 就比 ids 短 —— 轻则把编号张冠李戴写进库,
|
* 题目一旦被隐藏或删除,display_ids 就比 ids 短 —— 轻则把编号张冠李戴写进库,
|
||||||
* 重则 `id_map[k]` KeyError。这里改成按 id 建 Map、查不到就不动。
|
* 重则 `id_map[k]` KeyError。这里改成按 id 建 Map、查不到就不动。
|
||||||
*/
|
*/
|
||||||
accountRoutes.post("/me/problem-display-ids/refresh", requireAuth, async (c) => {
|
accountRoutes.post(
|
||||||
const user = c.get("user")!
|
"/me/problem-display-ids/refresh",
|
||||||
const [profile] = await db.select({ value: schema.userProfile.acmProblemsStatus }).from(schema.userProfile)
|
requireAuth,
|
||||||
.where(eq(schema.userProfile.userId, user.id)).limit(1)
|
async (c) => {
|
||||||
const status = objectValue(profile?.value)
|
const user = c.get("user")!
|
||||||
const problems = objectValue(status.problems)
|
const [profile] = await db
|
||||||
const ids = Object.keys(problems).map(Number).filter(Number.isInteger)
|
.select({ value: schema.userProfile.acmProblemsStatus })
|
||||||
if (ids.length > 0) {
|
.from(schema.userProfile)
|
||||||
const rows = await db.select({ id: schema.problem.id, displayId: schema.problem.displayId }).from(schema.problem)
|
.where(eq(schema.userProfile.userId, user.id))
|
||||||
.where(and(inArray(schema.problem.id, ids), eq(schema.problem.visible, true)))
|
.limit(1)
|
||||||
const displayIds = new Map(rows.map((row) => [String(row.id), row.displayId]))
|
const status = objectValue(profile?.value)
|
||||||
for (const [id, value] of Object.entries(problems)) {
|
const problems = objectValue(status.problems)
|
||||||
const item = objectValue(value)
|
const ids = Object.keys(problems).map(Number).filter(Number.isInteger)
|
||||||
const displayId = displayIds.get(id)
|
if (ids.length > 0) {
|
||||||
if (displayId) item._id = displayId
|
const rows = await db
|
||||||
problems[id] = item
|
.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)
|
||||||
|
if (displayId) item._id = displayId
|
||||||
|
problems[id] = item
|
||||||
|
}
|
||||||
|
status.problems = problems
|
||||||
|
await db
|
||||||
|
.update(schema.userProfile)
|
||||||
|
.set({ acmProblemsStatus: status })
|
||||||
|
.where(eq(schema.userProfile.userId, user.id))
|
||||||
}
|
}
|
||||||
status.problems = problems
|
return success(c, null)
|
||||||
await db.update(schema.userProfile).set({ acmProblemsStatus: status }).where(eq(schema.userProfile.userId, user.id))
|
},
|
||||||
}
|
)
|
||||||
return success(c, null)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
achievementListSchema,
|
|
||||||
achievementSchema,
|
|
||||||
achievementSummarySchema,
|
|
||||||
markAchievementsReadSchema,
|
markAchievementsReadSchema,
|
||||||
pendingAchievementSchema,
|
type Achievement,
|
||||||
|
type AchievementList,
|
||||||
|
type AchievementSummary,
|
||||||
|
type PendingAchievement,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, count, desc, eq, inArray } from "drizzle-orm"
|
import { and, asc, count, desc, eq, inArray } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -17,33 +17,60 @@ export const achievementRoutes = new Hono<AppEnv>()
|
|||||||
|
|
||||||
async function resolveUser(requested: string | undefined, currentId: number) {
|
async function resolveUser(requested: string | undefined, currentId: number) {
|
||||||
if (!requested) {
|
if (!requested) {
|
||||||
const [current] = await db.select({ id: schema.user.id, username: schema.user.username }).from(schema.user)
|
const [current] = await db
|
||||||
.where(eq(schema.user.id, currentId)).limit(1)
|
.select({ id: schema.user.id, username: schema.user.username })
|
||||||
|
.from(schema.user)
|
||||||
|
.where(eq(schema.user.id, currentId))
|
||||||
|
.limit(1)
|
||||||
return current ?? null
|
return current ?? null
|
||||||
}
|
}
|
||||||
const [target] = await db.select({ id: schema.user.id, username: schema.user.username }).from(schema.user)
|
const [target] = await db
|
||||||
.where(and(eq(schema.user.username, requested), eq(schema.user.isDisabled, false))).limit(1)
|
.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
|
return target ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingData(row: { achievement: typeof schema.achievement.$inferSelect }) {
|
function pendingData(row: {
|
||||||
return pendingAchievementSchema.parse({
|
achievement: typeof schema.achievement.$inferSelect
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
id: row.achievement.id,
|
id: row.achievement.id,
|
||||||
name: row.achievement.name,
|
name: row.achievement.name,
|
||||||
description: row.achievement.description,
|
description: row.achievement.description,
|
||||||
icon: row.achievement.icon,
|
icon: row.achievement.icon,
|
||||||
rarity: row.achievement.rarity,
|
rarity: row.achievement.rarity,
|
||||||
})
|
} satisfies PendingAchievement
|
||||||
}
|
}
|
||||||
|
|
||||||
achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
||||||
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
|
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
|
||||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||||
const [achievements, unlockedRows, statRows, activeRows] = await Promise.all([
|
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
|
||||||
db.select().from(schema.userAchievement).where(eq(schema.userAchievement.userId, target.id)),
|
.select()
|
||||||
db.select({ metrics: schema.userStat.metrics }).from(schema.userStat).where(eq(schema.userStat.userId, target.id)).limit(1),
|
.from(schema.achievement)
|
||||||
db.select({ value: count() }).from(schema.user).where(eq(schema.user.isDisabled, false)),
|
.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 unlocked = new Map(unlockedRows.map((row) => [row.achievementId, row]))
|
||||||
const metrics = objectValue(statRows[0]?.metrics)
|
const metrics = objectValue(statRows[0]?.metrics)
|
||||||
@@ -52,7 +79,7 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
|||||||
const record = unlocked.get(achievement.id)
|
const record = unlocked.get(achievement.id)
|
||||||
const masked = achievement.hidden && !record
|
const masked = achievement.hidden && !record
|
||||||
const progress = metrics[achievement.metric]
|
const progress = metrics[achievement.metric]
|
||||||
return achievementSchema.parse({
|
return {
|
||||||
id: achievement.id,
|
id: achievement.id,
|
||||||
name: masked ? "???" : achievement.name,
|
name: masked ? "???" : achievement.name,
|
||||||
description: masked ? "达成条件保密" : achievement.description,
|
description: masked ? "达成条件保密" : achievement.description,
|
||||||
@@ -66,56 +93,105 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
|
|||||||
unlockTime: record?.unlockTime ?? null,
|
unlockTime: record?.unlockTime ?? null,
|
||||||
backfilled: record?.backfilled ?? false,
|
backfilled: record?.backfilled ?? false,
|
||||||
progress: masked ? null : typeof progress === "number" ? progress : 0,
|
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, achievementListSchema.parse({ username: target.username, achievements: result }))
|
return success(c, {
|
||||||
|
username: target.username,
|
||||||
|
achievements: result,
|
||||||
|
} satisfies AchievementList)
|
||||||
})
|
})
|
||||||
|
|
||||||
achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
|
achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
|
||||||
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
|
const target = await resolveUser(c.req.query("username"), c.get("user")!.id)
|
||||||
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||||
const [achievements, unlockedRows] = await Promise.all([
|
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
|
||||||
db.select({ record: schema.userAchievement, achievement: schema.achievement }).from(schema.userAchievement)
|
.select({ id: schema.achievement.id, rarity: schema.achievement.rarity })
|
||||||
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
.from(schema.achievement)
|
||||||
.where(and(eq(schema.userAchievement.userId, target.id), eq(schema.achievement.visible, true))).orderBy(desc(schema.userAchievement.unlockTime)),
|
.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 rarities = ["bronze", "silver", "gold", "platinum"] as const
|
||||||
const total = achievements.length
|
const total = achievements.length
|
||||||
const unlocked = unlockedRows.length
|
const unlocked = unlockedRows.length
|
||||||
return success(c, achievementSummarySchema.parse({
|
return success(c, {
|
||||||
username: target.username,
|
username: target.username,
|
||||||
total,
|
total,
|
||||||
unlocked,
|
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: rarities.map((rarity) => ({
|
||||||
rarity,
|
rarity,
|
||||||
label: labels[rarity],
|
label: labels[rarity],
|
||||||
total: achievements.filter((item) => item.rarity === rarity).length,
|
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),
|
recent: unlockedRows.slice(0, 10).map(pendingData),
|
||||||
}))
|
} satisfies AchievementSummary)
|
||||||
})
|
})
|
||||||
|
|
||||||
achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {
|
achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {
|
||||||
const rows = await db.select({ record: schema.userAchievement, achievement: schema.achievement })
|
const rows = await db
|
||||||
.from(schema.userAchievement).innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
.select({ record: schema.userAchievement, achievement: schema.achievement })
|
||||||
.where(and(eq(schema.userAchievement.userId, c.get("user")!.id), eq(schema.userAchievement.notified, false), eq(schema.achievement.visible, true)))
|
.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))
|
.orderBy(asc(schema.userAchievement.unlockTime))
|
||||||
return success(c, rows.map(pendingData))
|
return success(c, rows.map(pendingData))
|
||||||
})
|
})
|
||||||
|
|
||||||
achievementRoutes.post("/achievements/pending/read", requireAuth, async (c) => {
|
achievementRoutes.post("/achievements/pending/read", requireAuth, async (c) => {
|
||||||
const parsed = markAchievementsReadSchema.safeParse(await c.req.json().catch(() => null))
|
const parsed = markAchievementsReadSchema.safeParse(
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid achievement ids")
|
await c.req.json().catch(() => null),
|
||||||
|
)
|
||||||
|
if (!parsed.success)
|
||||||
|
return failure(c, 400, "invalid-request", "Invalid achievement ids")
|
||||||
if (parsed.data.ids.length > 0) {
|
if (parsed.data.ids.length > 0) {
|
||||||
await db.update(schema.userAchievement).set({ notified: true }).where(and(
|
await db
|
||||||
eq(schema.userAchievement.userId, c.get("user")!.id),
|
.update(schema.userAchievement)
|
||||||
inArray(schema.userAchievement.achievementId, parsed.data.ids),
|
.set({ notified: true })
|
||||||
))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.userAchievement.userId, c.get("user")!.id),
|
||||||
|
inArray(schema.userAchievement.achievementId, parsed.data.ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,23 +1,40 @@
|
|||||||
import {
|
import {
|
||||||
adminUserListSchema,
|
adminTypeSchema,
|
||||||
adminUserRankSchema,
|
|
||||||
adminUserSchema,
|
|
||||||
deleteUsersRequestSchema,
|
deleteUsersRequestSchema,
|
||||||
importUsersRequestSchema,
|
importUsersRequestSchema,
|
||||||
rankProfileSchema,
|
STUDENT_ROLES,
|
||||||
resetPasswordResponseSchema,
|
|
||||||
updateUserRequestSchema,
|
updateUserRequestSchema,
|
||||||
|
type AdminType,
|
||||||
|
type AdminUser,
|
||||||
|
type AdminUserList,
|
||||||
|
type AdminUserRank,
|
||||||
|
type ProblemPermission,
|
||||||
|
type RankProfile,
|
||||||
|
type ResetPasswordResponse,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { randomInt } from "node:crypto"
|
import { randomInt } from "node:crypto"
|
||||||
import { and, asc, count, desc, eq, ilike, inArray, ne, or, sql } from "drizzle-orm"
|
import { z } from "zod"
|
||||||
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
count,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
ilike,
|
||||||
|
inArray,
|
||||||
|
ne,
|
||||||
|
or,
|
||||||
|
sql,
|
||||||
|
} from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { hashPassword } from "../../auth/password"
|
import { hashPassword } from "../../auth/password"
|
||||||
|
import { isUserOnline, onlineUserIds } from "../../auth/presence"
|
||||||
|
import { revokeUserSessions } from "../../auth/session"
|
||||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
import { failure, success } from "../../http"
|
import { failure, success } from "../../http"
|
||||||
import { queryInteger, sampleUser } from "../helpers"
|
import { queryInteger, sampleUser } from "../helpers"
|
||||||
import { publishSessionRevoked } from "../../events"
|
|
||||||
|
|
||||||
export const adminAccountRoutes = new Hono<AppEnv>()
|
export const adminAccountRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -32,11 +49,16 @@ const CLASS_NAME_MAX_DIGITS = 4
|
|||||||
* 那样 `ks251001` 会「匹配成功」并悄悄取前 4 位,正是要避免的猜测。
|
* 那样 `ks251001` 会「匹配成功」并悄悄取前 4 位,正是要避免的猜测。
|
||||||
* 对齐旧 `account/views/admin.py:get_class_name`。
|
* 对齐旧 `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)
|
const matched = /^ks(\d+)/.exec(username)
|
||||||
if (!matched) return { ok: true, value: null }
|
if (!matched) return { ok: true, value: null }
|
||||||
const digits = matched[1]!
|
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 {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: `用户名 ${username} 的班级号 ${digits} 是 ${digits.length} 位,必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
|
message: `用户名 ${username} 的班级号 ${digits} 是 ${digits.length} 位,必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
|
||||||
@@ -50,17 +72,23 @@ function classNameOf(username: string): { ok: true; value: string | null } | { o
|
|||||||
* 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。
|
* 超管恒为 All、普通用户恒为 None、两种管理员取传入值或兜底 Own。
|
||||||
* 不这么做的话,把一个超管降级成普通用户后,他还留着 All 的题目权限。
|
* 不这么做的话,把一个超管降级成普通用户后,他还留着 All 的题目权限。
|
||||||
*/
|
*/
|
||||||
function normalizePermission(adminType: string, requested: string) {
|
function normalizePermission(
|
||||||
|
adminType: AdminType,
|
||||||
|
requested: ProblemPermission,
|
||||||
|
): ProblemPermission {
|
||||||
if (adminType === "Super Admin") return "All"
|
if (adminType === "Super Admin") return "All"
|
||||||
if (adminType === "Regular User") return "None"
|
if (adminType === "Regular User") return "None"
|
||||||
return requested || "Own"
|
return requested || "Own"
|
||||||
}
|
}
|
||||||
|
|
||||||
function serialize(row: {
|
function serialize(
|
||||||
user: typeof schema.user.$inferSelect
|
row: {
|
||||||
realName: string | null
|
user: typeof schema.user.$inferSelect
|
||||||
}) {
|
realName: string | null
|
||||||
return adminUserSchema.parse({
|
},
|
||||||
|
isOnline: boolean,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
id: row.user.id,
|
id: row.user.id,
|
||||||
username: row.user.username,
|
username: row.user.username,
|
||||||
email: row.user.email,
|
email: row.user.email,
|
||||||
@@ -69,18 +97,20 @@ function serialize(row: {
|
|||||||
realName: row.realName,
|
realName: row.realName,
|
||||||
createTime: row.user.createTime,
|
createTime: row.user.createTime,
|
||||||
lastLogin: row.user.lastLogin,
|
lastLogin: row.user.lastLogin,
|
||||||
openApi: row.user.openApi,
|
|
||||||
isDisabled: row.user.isDisabled,
|
isDisabled: row.user.isDisabled,
|
||||||
|
isOnline,
|
||||||
rawPassword: row.user.rawPassword,
|
rawPassword: row.user.rawPassword,
|
||||||
className: row.user.className,
|
className: row.user.className,
|
||||||
})
|
} satisfies AdminUser
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectUser(id: number) {
|
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)
|
.from(schema.user)
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -98,34 +128,47 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
|
|||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const keyword = c.req.query("keyword")?.trim()
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
const where = and(
|
const where = and(
|
||||||
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
eq(schema.user.isDisabled, false),
|
eq(schema.user.isDisabled, false),
|
||||||
keyword ? ilike(schema.user.username, `%${keyword}%`) : undefined,
|
keyword ? ilike(schema.user.username, `%${keyword}%`) : undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.userProfile)
|
db
|
||||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where),
|
.select({ value: count() })
|
||||||
db.select({ profile: schema.userProfile, user: schema.user }).from(schema.userProfile)
|
.from(schema.userProfile)
|
||||||
.innerJoin(schema.user, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
.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(
|
.orderBy(
|
||||||
desc(schema.userProfile.acceptedNumber),
|
desc(schema.userProfile.acceptedNumber),
|
||||||
asc(schema.userProfile.submissionNumber),
|
asc(schema.userProfile.submissionNumber),
|
||||||
asc(schema.user.id),
|
asc(schema.user.id),
|
||||||
)
|
)
|
||||||
.limit(limit).offset(offset),
|
.limit(limit)
|
||||||
|
.offset(offset),
|
||||||
])
|
])
|
||||||
|
|
||||||
return success(c, adminUserRankSchema.parse({
|
return success(c, {
|
||||||
results: rows.map(({ profile, user }) => rankProfileSchema.parse({
|
results: rows.map(
|
||||||
id: profile.id,
|
({ profile, user }) =>
|
||||||
user: sampleUser(user, profile.realName),
|
({
|
||||||
acceptedNumber: profile.acceptedNumber,
|
id: profile.id,
|
||||||
submissionNumber: profile.submissionNumber,
|
user: sampleUser(user, profile.realName),
|
||||||
mood: profile.mood,
|
acceptedNumber: profile.acceptedNumber,
|
||||||
})),
|
submissionNumber: profile.submissionNumber,
|
||||||
|
mood: profile.mood,
|
||||||
|
// 这张榜不下发在线状态(null = 「调用方不该知道」,见契约里 isOnline 的注释)。
|
||||||
|
// 原来是靠 schema 的 .default(null) 填出来的,改成显式写死。
|
||||||
|
isOnline: null,
|
||||||
|
}) satisfies RankProfile,
|
||||||
|
),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
}))
|
} satisfies AdminUserRank)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
|
adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
|
||||||
@@ -134,59 +177,117 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
|
|||||||
const filters = []
|
const filters = []
|
||||||
const type = c.req.query("type")?.trim()
|
const type = c.req.query("type")?.trim()
|
||||||
const keyword = c.req.query("keyword")?.trim()
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
if (type) filters.push(eq(schema.user.adminType, type))
|
if (type) {
|
||||||
|
// 以前这里直接把 query 塞进 eq(),传个不存在的角色名只会静默返回空列表。
|
||||||
|
// 列加了 $type 之后编译器会拦下来,顺势改成校验:前端的下拉只有这四个值。
|
||||||
|
const parsedType = adminTypeSchema.safeParse(type)
|
||||||
|
if (!parsedType.success)
|
||||||
|
return failure(c, 400, "invalid-request", "角色筛选值不合法")
|
||||||
|
filters.push(eq(schema.user.adminType, parsedType.data))
|
||||||
|
}
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
filters.push(or(
|
filters.push(
|
||||||
ilike(schema.user.username, `%${keyword}%`),
|
or(
|
||||||
ilike(schema.userProfile.realName, `%${keyword}%`),
|
ilike(schema.user.username, `%${keyword}%`),
|
||||||
ilike(schema.user.email, `%${keyword}%`),
|
ilike(schema.userProfile.realName, `%${keyword}%`),
|
||||||
)!)
|
ilike(schema.user.email, `%${keyword}%`),
|
||||||
|
)!,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const where = filters.length ? and(...filters) : undefined
|
const where = filters.length ? and(...filters) : undefined
|
||||||
|
// 在线状态每行都要下发(列表里显示),所以不管怎么排都先取一次
|
||||||
|
const online = await onlineUserIds()
|
||||||
|
const orderBy = c.req.query("orderBy")
|
||||||
// 「最近登录」排序要把从未登录的排在最后,否则一堆 null 顶在最前面,这个排序就没用了
|
// 「最近登录」排序要把从未登录的排在最后,否则一堆 null 顶在最前面,这个排序就没用了
|
||||||
const order = c.req.query("orderBy") === "-lastLogin"
|
//
|
||||||
? [sql`${schema.user.lastLogin} desc nulls last`]
|
// 「在线优先」没有对应的库表列 —— 在线只存在于 Redis,所以把在线的 id 捞出来
|
||||||
: [desc(schema.user.createTime)]
|
// 在 SQL 里分两档;档内仍按最近登录排,这样一屏离线用户之间还是有意义的顺序。
|
||||||
|
// 没人在线时那个 case 恒等于 1,直接省掉(inArray 拿空数组也不合法)。
|
||||||
|
const order =
|
||||||
|
orderBy === "-online"
|
||||||
|
? [
|
||||||
|
...(online.size
|
||||||
|
? [
|
||||||
|
sql`case when ${inArray(schema.user.id, [...online])} then 0 else 1 end`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
sql`${schema.user.lastLogin} desc nulls last`,
|
||||||
|
]
|
||||||
|
: orderBy === "-lastLogin"
|
||||||
|
? [sql`${schema.user.lastLogin} desc nulls last`]
|
||||||
|
: [desc(schema.user.createTime)]
|
||||||
|
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.user)
|
db
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where),
|
.select({ value: count() })
|
||||||
db.select({ user: schema.user, realName: schema.userProfile.realName }).from(schema.user)
|
.from(schema.user)
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
.leftJoin(
|
||||||
.orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset),
|
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, adminUserListSchema.parse({
|
return success(c, {
|
||||||
results: rows.map(serialize),
|
results: rows.map((row) => serialize(row, online.has(row.user.id))),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
}))
|
} satisfies AdminUserList)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
|
adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => {
|
||||||
const [row] = await selectUser(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
const [row] = await selectUser(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
||||||
if (!row) return failure(c, 404, "user-not-found", "User does not exist")
|
if (!row) return failure(c, 404, "user-not-found", "User does not exist")
|
||||||
return success(c, serialize(row))
|
return success(c, serialize(row, await isUserOnline(row.user.id)))
|
||||||
})
|
})
|
||||||
|
|
||||||
adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
|
adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
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) {
|
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 data = parsed.data
|
||||||
const [existing] = await selectUser(id)
|
const [existing] = await selectUser(id)
|
||||||
if (!existing) return failure(c, 404, "user-not-found", "User does not exist")
|
if (!existing) return failure(c, 404, "user-not-found", "User does not exist")
|
||||||
|
|
||||||
const username = data.username.toLowerCase()
|
const username = data.username.trim().toLowerCase()
|
||||||
const email = data.email.toLowerCase()
|
const email = data.email.trim().toLowerCase()
|
||||||
const className = classNameOf(username)
|
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)
|
const [dupUsername] = await db
|
||||||
.where(and(eq(schema.user.username, username), ne(schema.user.id, id))).limit(1)
|
.select({ id: schema.user.id })
|
||||||
if (dupUsername) return failure(c, 409, "username-exists", "Username already exists")
|
.from(schema.user)
|
||||||
const [dupEmail] = await db.select({ id: schema.user.id }).from(schema.user)
|
.where(and(eq(schema.user.username, username), ne(schema.user.id, id)))
|
||||||
.where(and(eq(schema.user.email, email), ne(schema.user.id, id))).limit(1)
|
.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)
|
||||||
if (dupEmail) return failure(c, 409, "email-exists", "Email already exists")
|
if (dupEmail) return failure(c, 409, "email-exists", "Email already exists")
|
||||||
|
|
||||||
const patch: Partial<typeof schema.user.$inferInsert> = {
|
const patch: Partial<typeof schema.user.$inferInsert> = {
|
||||||
@@ -195,7 +296,10 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
|
|||||||
className: className.value,
|
className: className.value,
|
||||||
adminType: data.adminType,
|
adminType: data.adminType,
|
||||||
isDisabled: data.isDisabled,
|
isDisabled: data.isDisabled,
|
||||||
problemPermission: normalizePermission(data.adminType, data.problemPermission),
|
problemPermission: normalizePermission(
|
||||||
|
data.adminType,
|
||||||
|
data.problemPermission,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
if (data.password) {
|
if (data.password) {
|
||||||
// 与旧 User.set_password 一致:哈希与明文一起写。明文是有意保留的运营需求,
|
// 与旧 User.set_password 一致:哈希与明文一起写。明文是有意保留的运营需求,
|
||||||
@@ -203,128 +307,335 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
|
|||||||
patch.password = await hashPassword(data.password)
|
patch.password = await hashPassword(data.password)
|
||||||
patch.rawPassword = data.password
|
patch.rawPassword = data.password
|
||||||
}
|
}
|
||||||
if (data.openApi) {
|
|
||||||
// 已经开着就不重置 appkey,否则每次保存用户都会把对方的 key 换掉
|
|
||||||
if (!existing.user.openApi) patch.openApiAppkey = randomBytes32()
|
|
||||||
} else {
|
|
||||||
patch.openApiAppkey = null
|
|
||||||
}
|
|
||||||
patch.openApi = data.openApi
|
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.update(schema.user).set(patch).where(eq(schema.user.id, id))
|
await tx.update(schema.user).set(patch).where(eq(schema.user.id, id))
|
||||||
// submission.username 是冗余列(判题历史按用户名查),改名后必须一起改,否则历史提交查不到
|
/**
|
||||||
|
* submission.username 是冗余列,改名后跟着改。
|
||||||
|
*
|
||||||
|
* 条件按 **user_id** 而不是「等于旧用户名」:后者只改得动「当前正好还等于旧名」
|
||||||
|
* 的行,一个已经漂移过的账号再改一次名,更早那批仍然改不动 —— 生产库里 726 条
|
||||||
|
* 挂着旧名字的提交就是旧栈时代这么留下的,之后每次改名都从它身边绕过去。
|
||||||
|
* 按 user_id 写是幂等的,顺带把这个人的历史行一次性拉平。
|
||||||
|
*
|
||||||
|
* 读路径本身已经不依赖这一列了(列表和统计都从 user 表取当前名字),
|
||||||
|
* 这里保持同步是为了「已删号回退显示」和按名字搜索那两条路。
|
||||||
|
*/
|
||||||
if (existing.user.username !== username) {
|
if (existing.user.username !== username) {
|
||||||
await tx.update(schema.submission).set({ username })
|
await tx
|
||||||
.where(eq(schema.submission.username, existing.user.username))
|
.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))
|
.where(eq(schema.userProfile.userId, id))
|
||||||
})
|
})
|
||||||
|
|
||||||
// 禁用只改数据库这一列,不动 Redis 里的会话 —— 那个学生挂着的 WebSocket
|
// 禁用只改数据库这一列,会话在 Redis 里还好好的 —— 那个学生挂着的 WebSocket
|
||||||
// 靠会话巡检永远发现不了(token 还是好的),只能在这里主动断
|
// 靠会话巡检永远发现不了(token 还是好的),只能在这里主动断。
|
||||||
|
//
|
||||||
|
// 改密码同样要吊销:不删旧会话的话,「给被盗用的账号改个密码」这个动作对已经
|
||||||
|
// 登着的那一方毫无作用,他能一直用到会话自然过期。两件事都发生时按禁用报,
|
||||||
|
// 学生看到的提示更贴近实际。
|
||||||
if (data.isDisabled && !existing.user.isDisabled) {
|
if (data.isDisabled && !existing.user.isDisabled) {
|
||||||
await publishSessionRevoked({ userId: id }, "account-disabled")
|
await revokeUserSessions(id, "account-disabled")
|
||||||
|
} else if (data.password) {
|
||||||
|
await revokeUserSessions(id, "session-ended")
|
||||||
}
|
}
|
||||||
|
|
||||||
const [row] = await selectUser(id)
|
const [row] = await selectUser(id)
|
||||||
return success(c, serialize(row!))
|
return success(c, serialize(row!, await isUserOnline(id)))
|
||||||
})
|
})
|
||||||
|
|
||||||
adminAccountRoutes.post("/users", 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) {
|
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
|
const rows = parsed.data.users
|
||||||
const 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 才看到报错。
|
||||||
|
//
|
||||||
|
// 用户名和邮箱都归一成小写:登录是 `lower(username) = lower(?)` 比的,注册和
|
||||||
|
// PUT /users/:id 也都存小写。只有这条导入路径原样存,于是 `ks251Ab` 能绕过下面的
|
||||||
|
// 查重建出第二个账号,两个人登录时撞成同一条记录。
|
||||||
|
const prepared: Prepared[] = []
|
||||||
for (const [username, password, email, realName] of rows) {
|
for (const [username, password, email, realName] of rows) {
|
||||||
const className = classNameOf(username)
|
const name = username.toLowerCase()
|
||||||
if (!className.ok) return failure(c, 400, "invalid-class-name", className.message)
|
const className = classNameOf(name)
|
||||||
|
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 || "(空)"} 不是合法邮箱`,
|
||||||
|
)
|
||||||
|
}
|
||||||
prepared.push({
|
prepared.push({
|
||||||
username,
|
username: name,
|
||||||
password: await hashPassword(password),
|
password: "",
|
||||||
raw: password,
|
raw: password,
|
||||||
email,
|
email: mail,
|
||||||
realName,
|
realName,
|
||||||
className: className.value,
|
className: className.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await db.select({ username: schema.user.username }).from(schema.user)
|
const dupInBatch = (values: string[]) => {
|
||||||
.where(inArray(schema.user.username, prepared.map((item) => item.username)))
|
const seen = new Set<string>()
|
||||||
if (existing.length) {
|
return [
|
||||||
return failure(c, 409, "username-exists", `用户名已存在:${existing.map((row) => row.username).join("、")}`)
|
...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("、")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const batchMails = dupInBatch(prepared.map((item) => item.email))
|
||||||
|
if (batchMails.length) {
|
||||||
|
return failure(
|
||||||
|
c,
|
||||||
|
409,
|
||||||
|
"email-exists",
|
||||||
|
`这批名单里邮箱重复:${batchMails.join("、")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const takenNames = new Set(prepared.map((item) => item.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("、")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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("、")}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// argon2id 是**故意**做慢的,串行 await 的话一个班要转好几秒。但也不能 Promise.all
|
||||||
|
// 全量:每次哈希占 m=19MiB(见 auth/password.ts 的 ARGON2_OPTIONS),一个年级 300 人
|
||||||
|
// 同时开就是 5.7GB,而 oj-api 的 mem_limit 只有 512m(docker/compose.debian.yml)。
|
||||||
|
// 固定 4 路并发,瞬时峰值 76MiB 封顶。
|
||||||
|
const HASH_CONCURRENCY = 4
|
||||||
|
let cursor = 0
|
||||||
|
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 created = await db.transaction(async (tx) => {
|
||||||
const users = await tx.insert(schema.user).values(prepared.map((item) => ({
|
const users = await tx
|
||||||
username: item.username,
|
.insert(schema.user)
|
||||||
password: item.password,
|
.values(
|
||||||
rawPassword: item.raw,
|
prepared.map((item) => ({
|
||||||
email: item.email,
|
username: item.username,
|
||||||
className: item.className,
|
password: item.password,
|
||||||
adminType: "Regular User",
|
rawPassword: item.raw,
|
||||||
problemPermission: "None",
|
email: item.email,
|
||||||
createTime: new Date().toISOString(),
|
className: item.className,
|
||||||
openApi: false,
|
adminType: "Regular User" as const,
|
||||||
isDisabled: false,
|
problemPermission: "None" as const,
|
||||||
sessionKeys: [],
|
createTime: new Date().toISOString(),
|
||||||
}))).returning({ id: schema.user.id, username: schema.user.username })
|
isDisabled: false,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.returning({ id: schema.user.id, username: schema.user.username })
|
||||||
const byName = new Map(users.map((row) => [row.username, row.id]))
|
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(
|
||||||
userId: byName.get(item.username)!,
|
prepared.map((item) => ({
|
||||||
realName: item.realName,
|
userId: byName.get(item.username)!,
|
||||||
// avatar 是 notNull 且无默认值,必须显式给;路径与旧 UserProfile.avatar 的默认值一致
|
realName: item.realName,
|
||||||
avatar: "/public/avatar/default.png",
|
// avatar 是 notNull 且无默认值,必须显式给;路径与旧 UserProfile.avatar 的默认值一致
|
||||||
acmProblemsStatus: {},
|
avatar: "/public/avatar/default.png",
|
||||||
submissionNumber: 0,
|
acmProblemsStatus: {},
|
||||||
acceptedNumber: 0,
|
submissionNumber: 0,
|
||||||
})))
|
acceptedNumber: 0,
|
||||||
|
})),
|
||||||
|
)
|
||||||
return users.length
|
return users.length
|
||||||
})
|
})
|
||||||
return success(c, { imported: created }, 201)
|
return success(c, { imported: created }, 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外键冲突(PostgresError 23503)。要顺着 cause 链找 —— drizzle 0.45 把驱动的错误
|
||||||
|
* 包进 DrizzleQueryError,`error.code` 在最外层是 undefined,只看外层会把所有
|
||||||
|
* 删除失败都当成系统故障报 500。
|
||||||
|
*/
|
||||||
|
function isForeignKeyViolation(error: unknown) {
|
||||||
|
for (
|
||||||
|
let current = error;
|
||||||
|
current;
|
||||||
|
current = (current as { cause?: unknown }).cause
|
||||||
|
) {
|
||||||
|
if ((current as { code?: string }).code === "23503") return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「这人还有提交」的信号。提交那张表没有外键,拦不住,只能自己查出来再把事务掀了 */
|
||||||
|
class UserHasSubmissionsError extends Error {}
|
||||||
|
|
||||||
adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
|
adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
|
||||||
const parsed = deleteUsersRequestSchema.safeParse(await c.req.json().catch(() => null))
|
const parsed = deleteUsersRequestSchema.safeParse(
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "ids is required")
|
await c.req.json().catch(() => null),
|
||||||
|
)
|
||||||
|
if (!parsed.success)
|
||||||
|
return failure(c, 400, "invalid-request", "ids is required")
|
||||||
const me = c.get("user")!.id
|
const me = c.get("user")!.id
|
||||||
if (parsed.data.ids.includes(me)) {
|
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 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
|
// 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
|
||||||
// 撞外键说明该用户还有历史数据,应当禁用而不是删除。
|
// 撞外键说明该用户还有历史数据,应当禁用而不是删除。
|
||||||
|
//
|
||||||
|
// 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、题单进度、
|
||||||
|
// AI 分析、站内信全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE ——
|
||||||
|
// 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。
|
||||||
|
// 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个
|
||||||
|
// 裸 IntegerField),全连坐的结果是成就没了、提交却留成孤儿行,一半删一半留。
|
||||||
try {
|
try {
|
||||||
const deleted = await db.transaction(async (tx) => {
|
const deleted = await db.transaction(async (tx) => {
|
||||||
await tx.delete(schema.userProfile).where(inArray(schema.userProfile.userId, parsed.data.ids))
|
/**
|
||||||
return tx.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids))
|
* 外键拦得住成就、题单进度、比赛排名这些,**唯独提交拦不住** ——
|
||||||
|
* `submission.user_id` 没有外键(Django 那边就是个裸 IntegerField,上面已经
|
||||||
|
* 说了为什么不补)。所以下面那句报错里写的「还有提交」一直是空头支票:
|
||||||
|
* 只交过题、没拿过成就没进过题单的学生照样删得掉,提交留在库里成了孤儿 ——
|
||||||
|
* 用户没了、`submission.user_id` 还指着一个不存在的 id。生产快照实测:
|
||||||
|
* 28 个已删账号留下 935 条这样的提交。
|
||||||
|
*
|
||||||
|
* 补一次查询把它拦下来,口径和外键那批一致:有历史数据就该禁用,不该删。
|
||||||
|
* 和 delete 放同一个事务里,免得中间正好交了一发。
|
||||||
|
*/
|
||||||
|
const [withSubmission] = await tx
|
||||||
|
.select({ userId: schema.submission.userId })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(inArray(schema.submission.userId, parsed.data.ids))
|
||||||
|
.limit(1)
|
||||||
|
if (withSubmission) throw new UserHasSubmissionsError()
|
||||||
|
|
||||||
|
return tx
|
||||||
|
.delete(schema.user)
|
||||||
|
.where(inArray(schema.user.id, parsed.data.ids))
|
||||||
.returning({ id: schema.user.id })
|
.returning({ id: schema.user.id })
|
||||||
})
|
})
|
||||||
return success(c, { deleted: deleted.length })
|
return success(c, { deleted: deleted.length })
|
||||||
} catch {
|
} catch (error) {
|
||||||
return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号")
|
// 只有外键冲突(23503)和上面那条提交检查才是「这人还有历史数据」。以前这里是裸
|
||||||
|
// catch,连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见
|
||||||
|
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(
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
"/users/:id/reset-password",
|
||||||
const [existing] = await db.select({ id: schema.user.id }).from(schema.user)
|
requireSuperAdmin,
|
||||||
.where(eq(schema.user.id, id)).limit(1)
|
async (c) => {
|
||||||
if (!existing) return failure(c, 404, "user-not-found", "User does not exist")
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
// 6 位随机数字、不含 0,与旧后端一致:学生要照着念、要手输,0 和 O 分不清
|
const [existing] = await db
|
||||||
const password = Array.from({ length: 6 }, () => "123456789"[randomInt(9)]).join("")
|
.select({ id: schema.user.id })
|
||||||
await db.update(schema.user).set({
|
.from(schema.user)
|
||||||
password: await hashPassword(password),
|
.where(eq(schema.user.id, id))
|
||||||
rawPassword: password,
|
.limit(1)
|
||||||
}).where(eq(schema.user.id, id))
|
if (!existing)
|
||||||
return success(c, resetPasswordResponseSchema.parse({ password }))
|
return failure(c, 404, "user-not-found", "User does not exist")
|
||||||
})
|
// 6 位随机数字、不含 0,与旧后端一致:学生要照着念、要手输,0 和 O 分不清
|
||||||
|
const password = Array.from(
|
||||||
function randomBytes32() {
|
{ length: 6 },
|
||||||
return Array.from({ length: 32 }, () =>
|
() => "123456789"[randomInt(9)],
|
||||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"[randomInt(62)]).join("")
|
).join("")
|
||||||
}
|
await db
|
||||||
|
.update(schema.user)
|
||||||
|
.set({
|
||||||
|
password: await hashPassword(password),
|
||||||
|
rawPassword: password,
|
||||||
|
})
|
||||||
|
.where(eq(schema.user.id, id))
|
||||||
|
// 旧密码登出来的会话立刻作废,理由同 PUT /users/:id
|
||||||
|
await revokeUserSessions(id, "session-ended")
|
||||||
|
return success(c, { password } satisfies ResetPasswordResponse)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
achievementMetricSchema,
|
|
||||||
adminAchievementSchema,
|
|
||||||
createAchievementRequestSchema,
|
createAchievementRequestSchema,
|
||||||
updateAchievementRequestSchema,
|
updateAchievementRequestSchema,
|
||||||
|
type AchievementMetric,
|
||||||
|
type AdminAchievement,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { asc, eq } from "drizzle-orm"
|
import { asc, eq } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -10,14 +10,18 @@ import { Hono } from "hono"
|
|||||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
import { failure, success } from "../../http"
|
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 { rescanAchievement } from "../../services/achievements"
|
||||||
import { queryInteger } from "../helpers"
|
import { queryInteger } from "../helpers"
|
||||||
|
|
||||||
export const adminAchievementRoutes = new Hono<AppEnv>()
|
export const adminAchievementRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
function serialize(row: typeof schema.achievement.$inferSelect) {
|
function serialize(row: typeof schema.achievement.$inferSelect) {
|
||||||
return adminAchievementSchema.parse({
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
@@ -32,83 +36,140 @@ function serialize(row: typeof schema.achievement.$inferSelect) {
|
|||||||
unlockCount: row.unlockCount,
|
unlockCount: row.unlockCount,
|
||||||
order: row.order,
|
order: row.order,
|
||||||
createTime: row.createTime,
|
createTime: row.createTime,
|
||||||
})
|
} satisfies AdminAchievement
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下拉框的可选项就是代码里注册了什么,见 services/achievement-metrics.ts 的说明 */
|
/** 下拉框的可选项就是代码里注册了什么,见 services/achievement-metrics.ts 的说明 */
|
||||||
adminAchievementRoutes.get("/achievement-metrics", requireSuperAdmin, (c) =>
|
adminAchievementRoutes.get("/achievement-metrics", requireSuperAdmin, (c) =>
|
||||||
success(c, ACHIEVEMENT_METRICS.map((item) => achievementMetricSchema.parse(item))))
|
success(c, ACHIEVEMENT_METRICS satisfies AchievementMetric[]),
|
||||||
|
)
|
||||||
|
|
||||||
adminAchievementRoutes.get("/achievements", requireSuperAdmin, async (c) => {
|
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))
|
.orderBy(asc(schema.achievement.order), asc(schema.achievement.id))
|
||||||
return success(c, rows.map(serialize))
|
return success(c, rows.map(serialize))
|
||||||
})
|
})
|
||||||
|
|
||||||
adminAchievementRoutes.get("/achievements/:id", requireSuperAdmin, async (c) => {
|
adminAchievementRoutes.get(
|
||||||
const [row] = await db.select().from(schema.achievement)
|
"/achievements/:id",
|
||||||
.where(eq(schema.achievement.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1)
|
requireSuperAdmin,
|
||||||
if (!row) return failure(c, 404, "achievement-not-found", "成就不存在")
|
async (c) => {
|
||||||
return success(c, serialize(row))
|
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) => {
|
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) {
|
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
|
||||||
...parsed.data,
|
.insert(schema.achievement)
|
||||||
unlockCount: 0,
|
.values({
|
||||||
createTime: new Date().toISOString(),
|
...parsed.data,
|
||||||
}).returning()
|
unlockCount: 0,
|
||||||
|
createTime: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
|
|
||||||
// 新建的成就要补发给已达标的存量用户,否则「AC 满 10 题」这种成就
|
// 新建的成就要补发给已达标的存量用户,否则「AC 满 10 题」这种成就
|
||||||
// 只有从今往后的提交才算,老用户永远拿不到
|
// 只有从今往后的提交才算,老用户永远拿不到
|
||||||
await rescanAchievement(created!.id)
|
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)
|
return success(c, serialize(row!), 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminAchievementRoutes.put("/achievements/:id", requireSuperAdmin, async (c) => {
|
adminAchievementRoutes.put(
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
"/achievements/:id",
|
||||||
const parsed = updateAchievementRequestSchema.safeParse(await c.req.json().catch(() => null))
|
requireSuperAdmin,
|
||||||
if (!parsed.success) {
|
async (c) => {
|
||||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
}
|
const parsed = updateAchievementRequestSchema.safeParse(
|
||||||
if (!findMetric(parsed.data.metric)) return failure(c, 400, "invalid-metric", "指标不存在")
|
await c.req.json().catch(() => null),
|
||||||
|
)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return failure(
|
||||||
|
c,
|
||||||
|
400,
|
||||||
|
"invalid-request",
|
||||||
|
parsed.error.issues[0]?.message ?? "参数错误",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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
|
||||||
if (!before) return failure(c, 404, "achievement-not-found", "成就不存在")
|
.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)
|
const [after] = await db
|
||||||
.where(eq(schema.achievement.id, id)).returning()
|
.update(schema.achievement)
|
||||||
|
.set(parsed.data)
|
||||||
|
.where(eq(schema.achievement.id, id))
|
||||||
|
.returning()
|
||||||
|
|
||||||
// 只要「谁能达成」这件事可能变了就补发,不去精细判断是否放宽。补发幂等(唯一键 + 冲突忽略),
|
// 只要「谁能达成」这件事可能变了就补发,不去精细判断是否放宽。补发幂等(唯一键 + 冲突忽略),
|
||||||
// 多跑一次只花一次扫描;漏跑却是学生已达标却拿不到,两个方向代价不对称。
|
// 多跑一次只花一次扫描;漏跑却是学生已达标却拿不到,两个方向代价不对称。
|
||||||
// 判据必须包含 metric(换了维度)和 visible(草稿期已达标的人),
|
// 判据必须包含 metric(换了维度)和 visible(草稿期已达标的人),
|
||||||
// 只看 operator/threshold 会漏掉这两种。
|
// 只看 operator/threshold 会漏掉这两种。
|
||||||
const changed =
|
const changed =
|
||||||
before.metric !== after!.metric ||
|
before.metric !== after!.metric ||
|
||||||
before.operator !== after!.operator ||
|
before.operator !== after!.operator ||
|
||||||
before.threshold !== after!.threshold ||
|
before.threshold !== after!.threshold ||
|
||||||
before.visible !== after!.visible
|
before.visible !== after!.visible
|
||||||
if (after!.visible && changed) await rescanAchievement(id)
|
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
|
||||||
return success(c, serialize(row!))
|
.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(
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
"/achievements/:id",
|
||||||
// user_achievement 的外键同样是 NO ACTION(Django 的级联在应用层),先清子表
|
requireSuperAdmin,
|
||||||
const deleted = await db.transaction(async (tx) => {
|
async (c) => {
|
||||||
await tx.delete(schema.userAchievement).where(eq(schema.userAchievement.achievementId, id))
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
return tx.delete(schema.achievement).where(eq(schema.achievement.id, id))
|
// 解锁记录随成就一起没:user_achievement.achievement_id 是 CASCADE(0010)
|
||||||
|
const deleted = await db
|
||||||
|
.delete(schema.achievement)
|
||||||
|
.where(eq(schema.achievement.id, id))
|
||||||
.returning({ id: schema.achievement.id })
|
.returning({ id: schema.achievement.id })
|
||||||
})
|
if (deleted.length === 0)
|
||||||
if (deleted.length === 0) return failure(c, 404, "achievement-not-found", "成就不存在")
|
return failure(c, 404, "achievement-not-found", "成就不存在")
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import type {
|
||||||
adminAiReportListSchema,
|
AdminAiReport,
|
||||||
adminAiReportListItemSchema,
|
AdminAiReportList,
|
||||||
adminAiReportSchema,
|
AdminAiReportListItem,
|
||||||
toggleAiReportPinResponseSchema,
|
ToggleAiReportPinResponse,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, count, desc, eq, ilike } from "drizzle-orm"
|
import { and, count, desc, eq, ilike } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -21,14 +21,20 @@ function excerpt(analysis: string | null) {
|
|||||||
return text.length <= 120 ? text : `${text.slice(0, 120)}…`
|
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: {
|
||||||
return adminAiReportListItemSchema.parse({
|
id: number
|
||||||
|
username: string
|
||||||
|
createTime: string
|
||||||
|
analysis: string
|
||||||
|
isPinned: boolean
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
username: row.username,
|
username: row.username,
|
||||||
createTime: row.createTime,
|
createTime: row.createTime,
|
||||||
analysisExcerpt: excerpt(row.analysis),
|
analysisExcerpt: excerpt(row.analysis),
|
||||||
isPinned: row.isPinned,
|
isPinned: row.isPinned,
|
||||||
})
|
} satisfies AdminAiReportListItem
|
||||||
}
|
}
|
||||||
|
|
||||||
const listColumns = {
|
const listColumns = {
|
||||||
@@ -41,7 +47,9 @@ const listColumns = {
|
|||||||
|
|
||||||
adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
|
adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
|
||||||
const username = c.req.query("username")?.trim()
|
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 返回两种形状,调用方没法照着一个类型写。
|
// 但**形状必须和分页那支一样**:同一个 URL 返回两种形状,调用方没法照着一个类型写。
|
||||||
@@ -49,60 +57,93 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
|
|||||||
// 读的是 res.results,于是拿到 undefined,`pinnedReports.length` 在渲染时抛
|
// 读的是 res.results,于是拿到 undefined,`pinnedReports.length` 在渲染时抛
|
||||||
// 「Cannot read properties of undefined」——空库也照抛,这个页面每次打开都白屏。
|
// 「Cannot read properties of undefined」——空库也照抛,这个页面每次打开都白屏。
|
||||||
if (c.req.query("pinnedOnly") === "true") {
|
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))
|
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
|
||||||
.where(and(eq(schema.aiAnalysis.isPinned, true), where))
|
.where(and(eq(schema.aiAnalysis.isPinned, true), where))
|
||||||
.orderBy(desc(schema.aiAnalysis.createTime))
|
.orderBy(desc(schema.aiAnalysis.createTime))
|
||||||
return success(c, adminAiReportListSchema.parse({
|
return success(c, {
|
||||||
results: rows.map(listItem),
|
results: rows.map(listItem),
|
||||||
total: rows.length,
|
total: rows.length,
|
||||||
}))
|
} satisfies AdminAiReportList)
|
||||||
}
|
}
|
||||||
|
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.aiAnalysis)
|
db
|
||||||
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where),
|
.select({ value: count() })
|
||||||
db.select(listColumns).from(schema.aiAnalysis)
|
.from(schema.aiAnalysis)
|
||||||
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where)
|
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
|
||||||
.orderBy(desc(schema.aiAnalysis.createTime)).limit(limit).offset(offset),
|
.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, adminAiReportListSchema.parse({
|
return success(c, {
|
||||||
results: rows.map(listItem),
|
results: rows.map(listItem),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
}))
|
} satisfies AdminAiReportList)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
|
adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
|
||||||
const [row] = await db.select({
|
const [row] = await db
|
||||||
id: schema.aiAnalysis.id,
|
.select({
|
||||||
username: schema.user.username,
|
id: schema.aiAnalysis.id,
|
||||||
className: schema.user.className,
|
username: schema.user.username,
|
||||||
createTime: schema.aiAnalysis.createTime,
|
className: schema.user.className,
|
||||||
analysis: schema.aiAnalysis.analysis,
|
createTime: schema.aiAnalysis.createTime,
|
||||||
}).from(schema.aiAnalysis)
|
analysis: schema.aiAnalysis.analysis,
|
||||||
|
})
|
||||||
|
.from(schema.aiAnalysis)
|
||||||
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
|
.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")
|
if (!row) return failure(c, 404, "report-not-found", "AIAnalysis not found")
|
||||||
// data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词
|
// data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词
|
||||||
return success(c, adminAiReportSchema.parse(row))
|
return success(c, row satisfies AdminAiReport)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
|
adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
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 })
|
const [report] = await db
|
||||||
.from(schema.aiAnalysis).where(eq(schema.aiAnalysis.id, id)).limit(1)
|
.select({
|
||||||
if (!report) return failure(c, 404, "report-not-found", "AIAnalysis not found")
|
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
|
const next = !report.isPinned
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
if (next) {
|
if (next) {
|
||||||
await tx.update(schema.aiAnalysis).set({ isPinned: false })
|
await tx
|
||||||
.where(and(eq(schema.aiAnalysis.userId, report.userId), eq(schema.aiAnalysis.isPinned, true)))
|
.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, toggleAiReportPinResponseSchema.parse({ isPinned: next }))
|
return success(c, { isPinned: next } satisfies ToggleAiReportPinResponse)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
adminAnnouncementListSchema,
|
|
||||||
adminAnnouncementSchema,
|
|
||||||
createAnnouncementRequestSchema,
|
createAnnouncementRequestSchema,
|
||||||
updateAnnouncementRequestSchema,
|
updateAnnouncementRequestSchema,
|
||||||
|
type AdminAnnouncement,
|
||||||
|
type AdminAnnouncementList,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { count, desc, eq } from "drizzle-orm"
|
import { count, desc, eq } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -19,7 +19,7 @@ function serialize(row: {
|
|||||||
user: typeof schema.user.$inferSelect
|
user: typeof schema.user.$inferSelect
|
||||||
realName: string | null
|
realName: string | null
|
||||||
}) {
|
}) {
|
||||||
return adminAnnouncementSchema.parse({
|
return {
|
||||||
id: row.announcement.id,
|
id: row.announcement.id,
|
||||||
title: row.announcement.title,
|
title: row.announcement.title,
|
||||||
tag: row.announcement.tag,
|
tag: row.announcement.tag,
|
||||||
@@ -29,12 +29,16 @@ function serialize(row: {
|
|||||||
createdBy: sampleUser(row.user, row.realName),
|
createdBy: sampleUser(row.user, row.realName),
|
||||||
createTime: row.announcement.createTime,
|
createTime: row.announcement.createTime,
|
||||||
lastUpdateTime: row.announcement.lastUpdateTime,
|
lastUpdateTime: row.announcement.lastUpdateTime,
|
||||||
})
|
} satisfies AdminAnnouncement
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectOne(id: number) {
|
function selectOne(id: number) {
|
||||||
return db
|
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)
|
.from(schema.announcement)
|
||||||
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
@@ -47,68 +51,133 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
|
|||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.announcement),
|
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)
|
.from(schema.announcement)
|
||||||
.innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
.innerJoin(
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
schema.user,
|
||||||
|
eq(schema.announcement.createdById, schema.user.id),
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
schema.userProfile,
|
||||||
|
eq(schema.userProfile.userId, schema.user.id),
|
||||||
|
)
|
||||||
.orderBy(desc(schema.announcement.createTime))
|
.orderBy(desc(schema.announcement.createTime))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset),
|
.offset(offset),
|
||||||
])
|
])
|
||||||
return success(c, adminAnnouncementListSchema.parse({
|
return success(c, {
|
||||||
// 列表 schema omit 掉了 content,Zod 会 strip 掉多出来的键,这里不必手工再挑一遍
|
// 列表 schema omit 掉了 content,Zod 会 strip 掉多出来的键,这里不必手工再挑一遍
|
||||||
results: rows.map(serialize),
|
results: rows.map(serialize),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
}))
|
} satisfies AdminAnnouncementList)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminAnnouncementRoutes.post("/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) {
|
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 now = new Date().toISOString()
|
||||||
const [created] = await db.insert(schema.announcement).values({
|
const [created] = await db
|
||||||
...parsed.data,
|
.insert(schema.announcement)
|
||||||
createTime: now,
|
.values({
|
||||||
lastUpdateTime: now,
|
...parsed.data,
|
||||||
createdById: c.get("user")!.id,
|
createTime: now,
|
||||||
}).returning({ id: schema.announcement.id })
|
lastUpdateTime: now,
|
||||||
|
createdById: c.get("user")!.id,
|
||||||
|
})
|
||||||
|
.returning({ id: schema.announcement.id })
|
||||||
const [row] = await selectOne(created!.id)
|
const [row] = await selectOne(created!.id)
|
||||||
return success(c, serialize(row!), 201)
|
return success(c, serialize(row!), 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminAnnouncementRoutes.get("/announcements/:id", requireSuperAdmin, async (c) => {
|
adminAnnouncementRoutes.get(
|
||||||
const [row] = await selectOne(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
"/announcements/:id",
|
||||||
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
|
requireSuperAdmin,
|
||||||
return success(c, serialize(row))
|
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(
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
"/announcements/:id",
|
||||||
const parsed = updateAnnouncementRequestSchema.safeParse(await c.req.json().catch(() => null))
|
requireSuperAdmin,
|
||||||
if (!parsed.success) {
|
async (c) => {
|
||||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
}
|
const parsed = updateAnnouncementRequestSchema.safeParse(
|
||||||
const updated = await db.update(schema.announcement)
|
await c.req.json().catch(() => null),
|
||||||
.set({ ...parsed.data, lastUpdateTime: new Date().toISOString() })
|
)
|
||||||
.where(eq(schema.announcement.id, id))
|
if (!parsed.success) {
|
||||||
.returning({ id: schema.announcement.id })
|
return failure(
|
||||||
if (updated.length === 0) {
|
c,
|
||||||
return failure(c, 404, "announcement-not-found", "Announcement does not exist")
|
400,
|
||||||
}
|
"invalid-request",
|
||||||
const [row] = await selectOne(id)
|
parsed.error.issues[0]?.message ?? "Invalid payload",
|
||||||
return success(c, serialize(row!))
|
)
|
||||||
})
|
}
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const [row] = await selectOne(id)
|
||||||
|
return success(c, serialize(row!))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
adminAnnouncementRoutes.delete("/announcements/:id", requireSuperAdmin, async (c) => {
|
adminAnnouncementRoutes.delete(
|
||||||
// 旧后端删不存在的公告也返回成功(filter().delete() 不报错)。这里改成 404:
|
"/announcements/:id",
|
||||||
// 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。
|
requireSuperAdmin,
|
||||||
const deleted = await db.delete(schema.announcement)
|
async (c) => {
|
||||||
.where(eq(schema.announcement.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
|
// 旧后端删不存在的公告也返回成功(filter().delete() 不报错)。这里改成 404:
|
||||||
.returning({ id: schema.announcement.id })
|
// 后台是人手点删除,静默成功会让人以为删掉了,刷新后它还在。
|
||||||
if (deleted.length === 0) {
|
const deleted = await db
|
||||||
return failure(c, 404, "announcement-not-found", "Announcement does not exist")
|
.delete(schema.announcement)
|
||||||
}
|
.where(
|
||||||
return success(c, null)
|
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 success(c, null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
dashboardInfoSchema,
|
|
||||||
judgeServerListSchema,
|
|
||||||
judgeServerSchema,
|
|
||||||
orphanTestCaseSchema,
|
|
||||||
updateJudgeServerRequestSchema,
|
updateJudgeServerRequestSchema,
|
||||||
updateWebsiteConfigRequestSchema,
|
updateWebsiteConfigRequestSchema,
|
||||||
uploadImageResponseSchema,
|
type DashboardInfo,
|
||||||
websiteConfigSchema,
|
type JudgeServer,
|
||||||
|
type JudgeServerList,
|
||||||
|
type OrphanTestCase,
|
||||||
|
type UploadImageResponse,
|
||||||
|
type WebsiteConfig,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { randomInt } from "node:crypto"
|
import { randomInt } from "node:crypto"
|
||||||
import { mkdir, readdir, rm, stat } from "node:fs/promises"
|
import { mkdir, readdir, rm, stat } from "node:fs/promises"
|
||||||
@@ -14,13 +14,18 @@ import { resolve } from "node:path"
|
|||||||
import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm"
|
import { count, desc, eq, gte, ilike, not, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
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 { config } from "../../config"
|
||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
import { publishConfigUpdate } from "../../events"
|
import { publishConfigUpdate } from "../../events"
|
||||||
import { failure, success } from "../../http"
|
import { failure, success } from "../../http"
|
||||||
import { getWebsiteOptions } from "../../services/options"
|
import { getWebsiteOptions } from "../../services/options"
|
||||||
import { todayStart } from "../helpers"
|
import { todayStart } from "../../time"
|
||||||
|
import { queryInteger } from "../helpers"
|
||||||
|
|
||||||
export const adminConfRoutes = new Hono<AppEnv>()
|
export const adminConfRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
@@ -38,7 +43,9 @@ function aliveSince() {
|
|||||||
* 于是同一天的心跳永远小于阈值,**所有判题机都会被标成离线**。
|
* 于是同一天的心跳永远小于阈值,**所有判题机都会被标成离线**。
|
||||||
*/
|
*/
|
||||||
function isAlive(lastHeartbeat: string) {
|
function isAlive(lastHeartbeat: string) {
|
||||||
return Date.parse(lastHeartbeat) >= Date.now() - HEARTBEAT_ALIVE_SECONDS * 1000
|
return (
|
||||||
|
Date.parse(lastHeartbeat) >= Date.now() - HEARTBEAT_ALIVE_SECONDS * 1000
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 网站配置
|
// ---------------------------------------------------------------- 网站配置
|
||||||
@@ -57,7 +64,7 @@ const OPTION_KEYS = {
|
|||||||
|
|
||||||
adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
|
adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
|
||||||
const options = await getWebsiteOptions()
|
const options = await getWebsiteOptions()
|
||||||
return success(c, websiteConfigSchema.parse({
|
return success(c, {
|
||||||
websiteBaseUrl: options.website_base_url,
|
websiteBaseUrl: options.website_base_url,
|
||||||
websiteName: options.website_name,
|
websiteName: options.website_name,
|
||||||
websiteNameShortcut: options.website_name_shortcut,
|
websiteNameShortcut: options.website_name_shortcut,
|
||||||
@@ -66,18 +73,28 @@ adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
|
|||||||
submissionListShowAll: options.submission_list_show_all,
|
submissionListShowAll: options.submission_list_show_all,
|
||||||
classList: options.class_list,
|
classList: options.class_list,
|
||||||
enableMaxkb: options.enable_maxkb,
|
enableMaxkb: options.enable_maxkb,
|
||||||
}))
|
} satisfies WebsiteConfig)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminConfRoutes.post("/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) {
|
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][])
|
const entries = (
|
||||||
.map(([field, key]) => ({ field, key, value: parsed.data[field] }))
|
Object.entries(OPTION_KEYS) as [keyof typeof OPTION_KEYS, string][]
|
||||||
|
).map(([field, key]) => ({ field, key, value: parsed.data[field] }))
|
||||||
// 8 个键一条 upsert 写完,不再一个键一次往返
|
// 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({
|
.onConflictDoUpdate({
|
||||||
target: schema.optionsSysoptions.key,
|
target: schema.optionsSysoptions.key,
|
||||||
set: { value: sql`excluded.value` },
|
set: { value: sql`excluded.value` },
|
||||||
@@ -88,45 +105,75 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
|
|||||||
// snake_case 是这张表从 Django 继承来的存储格式,只该活在库里;线上这一跳两边
|
// snake_case 是这张表从 Django 继承来的存储格式,只该活在库里;线上这一跳两边
|
||||||
// 都是新写的,没理由让前端再写一层换名胶水。曾经推 snake、前端拿它去比驼峰字段,
|
// 都是新写的,没理由让前端再写一层换名胶水。曾经推 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)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 判题机
|
// ---------------------------------------------------------------- 判题机
|
||||||
|
|
||||||
adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => {
|
adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => {
|
||||||
const rows = await db.select().from(schema.judgeServer).orderBy(desc(schema.judgeServer.lastHeartbeat))
|
const rows = await db
|
||||||
return success(c, judgeServerListSchema.parse({
|
.select()
|
||||||
|
.from(schema.judgeServer)
|
||||||
|
.orderBy(desc(schema.judgeServer.lastHeartbeat))
|
||||||
|
return success(c, {
|
||||||
// 后台要显示 token 才能拿去配判题机。这个接口是超管专属的
|
// 后台要显示 token 才能拿去配判题机。这个接口是超管专属的
|
||||||
token: config.judgeServerToken,
|
token: config.judgeServerToken,
|
||||||
servers: rows.map((row) => judgeServerSchema.parse({
|
servers: rows.map(
|
||||||
...row,
|
(row) =>
|
||||||
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal",
|
({
|
||||||
})),
|
...row,
|
||||||
}))
|
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal",
|
||||||
|
}) satisfies JudgeServer,
|
||||||
|
),
|
||||||
|
} satisfies JudgeServerList)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
|
adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
|
||||||
const parsed = updateJudgeServerRequestSchema.safeParse(await c.req.json().catch(() => null))
|
const parsed = updateJudgeServerRequestSchema.safeParse(
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "isDisabled is required")
|
await c.req.json().catch(() => null),
|
||||||
const updated = await db.update(schema.judgeServer)
|
)
|
||||||
|
if (!parsed.success)
|
||||||
|
return failure(c, 400, "invalid-request", "isDisabled is required")
|
||||||
|
const updated = await db
|
||||||
|
.update(schema.judgeServer)
|
||||||
.set({ isDisabled: parsed.data.isDisabled })
|
.set({ isDisabled: parsed.data.isDisabled })
|
||||||
.where(eq(schema.judgeServer.id, Number(c.req.param("id"))))
|
.where(
|
||||||
|
eq(schema.judgeServer.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
|
||||||
|
)
|
||||||
.returning({ id: schema.judgeServer.id })
|
.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() 把积压的待判任务重新分发。
|
// 旧后端在这里会 process_pending_task() 把积压的待判任务重新分发。
|
||||||
// 新架构不需要:任务在 BullMQ 里排着,worker 恢复就自己接着消费,不存在「没有新提交
|
// 新架构不需要:任务在 BullMQ 里排着,worker 恢复就自己接着消费,不存在「没有新提交
|
||||||
// 就一直 waiting」那种情况 —— 那是旧的自研分发器才有的问题。
|
// 就一直 waiting」那种情况 —— 那是旧的自研分发器才有的问题。
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminConfRoutes.delete("/judge-servers/:hostname", requireSuperAdmin, async (c) => {
|
adminConfRoutes.delete(
|
||||||
const deleted = await db.delete(schema.judgeServer)
|
"/judge-servers/:hostname",
|
||||||
.where(eq(schema.judgeServer.hostname, c.req.param("hostname")))
|
requireSuperAdmin,
|
||||||
.returning({ id: schema.judgeServer.id })
|
async (c) => {
|
||||||
if (deleted.length === 0) return failure(c, 404, "judge-server-not-found", "Judge server does not exist")
|
const deleted = await db
|
||||||
return success(c, null)
|
.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",
|
||||||
|
)
|
||||||
|
return success(c, null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 孤儿测试用例
|
// ---------------------------------------------------------------- 孤儿测试用例
|
||||||
|
|
||||||
@@ -139,15 +186,24 @@ async function orphanTestCaseIds() {
|
|||||||
db.select({ id: schema.problem.testCaseId }).from(schema.problem),
|
db.select({ id: schema.problem.testCaseId }).from(schema.problem),
|
||||||
])
|
])
|
||||||
const referenced = new Set(inDb.map((row) => row.id))
|
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) => {
|
adminConfRoutes.get("/orphan-test-cases", requireSuperAdmin, async (c) => {
|
||||||
const ids = await orphanTestCaseIds()
|
const ids = await orphanTestCaseIds()
|
||||||
const rows = await Promise.all(ids.map(async (id) => {
|
const rows = await Promise.all(
|
||||||
const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null)
|
ids.map(async (id) => {
|
||||||
return orphanTestCaseSchema.parse({ id, createTime: info ? info.mtimeMs / 1000 : 0 })
|
const info = await stat(resolve(config.testCaseDirectory, id)).catch(
|
||||||
}))
|
() => null,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
createTime: info ? info.mtimeMs / 1000 : 0,
|
||||||
|
} satisfies OrphanTestCase
|
||||||
|
}),
|
||||||
|
)
|
||||||
return success(c, rows)
|
return success(c, rows)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -158,10 +214,18 @@ adminConfRoutes.delete("/orphan-test-cases", requireSuperAdmin, async (c) => {
|
|||||||
// 而测试数据没有别处备份 —— 旧后端这里是不校验的。
|
// 而测试数据没有别处备份 —— 旧后端这里是不校验的。
|
||||||
const targets = requested ? orphans.filter((id) => id === requested) : orphans
|
const targets = requested ? orphans.filter((id) => id === requested) : orphans
|
||||||
if (requested && targets.length === 0) {
|
if (requested && targets.length === 0) {
|
||||||
return failure(c, 404, "not-an-orphan", "该用例目录不存在或仍被题目引用,未删除")
|
return failure(
|
||||||
|
c,
|
||||||
|
404,
|
||||||
|
"not-an-orphan",
|
||||||
|
"该用例目录不存在或仍被题目引用,未删除",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
for (const id of targets) {
|
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 })
|
return success(c, { deleted: targets.length })
|
||||||
})
|
})
|
||||||
@@ -172,20 +236,26 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
|
|||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const [[users], [submissions], [contests], [servers]] = await Promise.all([
|
const [[users], [submissions], [contests], [servers]] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.user),
|
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())),
|
.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}`)),
|
.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())),
|
.where(gte(schema.judgeServer.lastHeartbeat, aliveSince())),
|
||||||
])
|
])
|
||||||
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发
|
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发
|
||||||
return success(c, dashboardInfoSchema.parse({
|
return success(c, {
|
||||||
userCount: users?.value ?? 0,
|
userCount: users?.value ?? 0,
|
||||||
todaySubmissionCount: submissions?.value ?? 0,
|
todaySubmissionCount: submissions?.value ?? 0,
|
||||||
recentContestCount: contests?.value ?? 0,
|
recentContestCount: contests?.value ?? 0,
|
||||||
judgeServerCount: servers?.value ?? 0,
|
judgeServerCount: servers?.value ?? 0,
|
||||||
}))
|
} satisfies DashboardInfo)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => {
|
adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => {
|
||||||
@@ -194,10 +264,16 @@ adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => {
|
|||||||
// 不额外按 className 过滤:那会改变旧行为,而这个功能就是随机点名,宁可宽松
|
// 不额外按 className 过滤:那会改变旧行为,而这个功能就是随机点名,宁可宽松
|
||||||
const classroom = c.req.query("classroom")?.trim()
|
const classroom = c.req.query("classroom")?.trim()
|
||||||
if (!classroom) return failure(c, 400, "invalid-request", "需要班级号")
|
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}%`))
|
.where(ilike(schema.user.username, `${classroom}%`))
|
||||||
.orderBy(sql`random()`).limit(10)
|
.orderBy(sql`random()`)
|
||||||
return success(c, rows.map((row) => row.username))
|
.limit(10)
|
||||||
|
return success(
|
||||||
|
c,
|
||||||
|
rows.map((row) => row.username),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 富文本图片上传
|
// ---------------------------------------------------------------- 富文本图片上传
|
||||||
@@ -218,16 +294,28 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
|
|||||||
const form = await c.req.formData().catch(() => null)
|
const form = await c.req.formData().catch(() => null)
|
||||||
const image = form?.get("image")
|
const image = form?.get("image")
|
||||||
if (!(image instanceof File)) {
|
if (!(image instanceof File)) {
|
||||||
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Upload failed", filePath: "" }))
|
return success(c, {
|
||||||
|
success: false,
|
||||||
|
msg: "Upload failed",
|
||||||
|
filePath: "",
|
||||||
|
} satisfies UploadImageResponse)
|
||||||
}
|
}
|
||||||
const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
|
const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
|
||||||
if (!IMAGE_SUFFIXES.includes(suffix)) {
|
if (!IMAGE_SUFFIXES.includes(suffix)) {
|
||||||
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Unsupported file format", filePath: "" }))
|
return success(c, {
|
||||||
|
success: false,
|
||||||
|
msg: "Unsupported file format",
|
||||||
|
filePath: "",
|
||||||
|
} satisfies UploadImageResponse)
|
||||||
}
|
}
|
||||||
// 旧后端没有大小限制,靠 nginx 兜。这里显式限一道:文件写在本地磁盘上,
|
// 旧后端没有大小限制,靠 nginx 兜。这里显式限一道:文件写在本地磁盘上,
|
||||||
// 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂
|
// 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂
|
||||||
if (image.size > MAX_IMAGE_BYTES) {
|
if (image.size > MAX_IMAGE_BYTES) {
|
||||||
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "图片不能超过 10MB", filePath: "" }))
|
return success(c, {
|
||||||
|
success: false,
|
||||||
|
msg: "图片不能超过 10MB",
|
||||||
|
filePath: "",
|
||||||
|
} satisfies UploadImageResponse)
|
||||||
}
|
}
|
||||||
// 文件名完全由服务端生成,不带用户提供的任何一段 —— 原名里的 ../ 或空字节都进不来
|
// 文件名完全由服务端生成,不带用户提供的任何一段 —— 原名里的 ../ 或空字节都进不来
|
||||||
const name = `${randomFileName()}${suffix}`
|
const name = `${randomFileName()}${suffix}`
|
||||||
@@ -236,16 +324,22 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
|
|||||||
await Bun.write(resolve(config.uploadDirectory, name), image)
|
await Bun.write(resolve(config.uploadDirectory, name), image)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save uploaded image", error)
|
console.error("Failed to save uploaded image", error)
|
||||||
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Upload Error", filePath: "" }))
|
return success(c, {
|
||||||
|
success: false,
|
||||||
|
msg: "Upload Error",
|
||||||
|
filePath: "",
|
||||||
|
} satisfies UploadImageResponse)
|
||||||
}
|
}
|
||||||
return success(c, uploadImageResponseSchema.parse({
|
return success(c, {
|
||||||
success: true,
|
success: true,
|
||||||
msg: "Success",
|
msg: "Success",
|
||||||
filePath: `${config.uploadUriPrefix}/${name}`,
|
filePath: `${config.uploadUriPrefix}/${name}`,
|
||||||
}))
|
} satisfies UploadImageResponse)
|
||||||
})
|
})
|
||||||
|
|
||||||
function randomFileName() {
|
function randomFileName() {
|
||||||
return Array.from({ length: 10 }, () =>
|
return Array.from(
|
||||||
"abcdefghijklmnopqrstuvwxyz0123456789"[randomInt(36)]).join("")
|
{ length: 10 },
|
||||||
|
() => "abcdefghijklmnopqrstuvwxyz0123456789"[randomInt(36)],
|
||||||
|
).join("")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
acmHelperItemSchema,
|
|
||||||
adminContestListSchema,
|
|
||||||
adminContestSchema,
|
|
||||||
createContestRequestSchema,
|
createContestRequestSchema,
|
||||||
updateAcmHelperRequestSchema,
|
updateAcmHelperRequestSchema,
|
||||||
updateContestRequestSchema,
|
updateContestRequestSchema,
|
||||||
|
type AcmHelperItem,
|
||||||
|
type AdminContest,
|
||||||
|
type AdminContestList,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, count, desc, eq, ilike, inArray } from "drizzle-orm"
|
import { and, count, desc, eq, ilike, inArray } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -26,22 +26,12 @@ function ownedBy(user: AuthUser, contest: { createdById: number }) {
|
|||||||
return user.adminType === "Super Admin" || contest.createdById === user.id
|
return user.adminType === "Super Admin" || contest.createdById === user.id
|
||||||
}
|
}
|
||||||
|
|
||||||
/** CIDR 校验。`ip_network(strict=False)` 的等价物:允许主机位非零,如 192.168.1.5/24 */
|
|
||||||
function validCidr(value: string) {
|
|
||||||
const [address, prefixText] = value.split("/")
|
|
||||||
const octets = (address ?? "").split(".")
|
|
||||||
if (octets.length !== 4) return false
|
|
||||||
if (!octets.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) return false
|
|
||||||
if (prefixText === undefined) return true
|
|
||||||
return /^\d{1,2}$/.test(prefixText) && Number(prefixText) <= 32
|
|
||||||
}
|
|
||||||
|
|
||||||
async function serialize(row: {
|
async function serialize(row: {
|
||||||
contest: typeof schema.contest.$inferSelect
|
contest: typeof schema.contest.$inferSelect
|
||||||
user: typeof schema.user.$inferSelect
|
user: typeof schema.user.$inferSelect
|
||||||
realName: string | null
|
realName: string | null
|
||||||
}) {
|
}) {
|
||||||
return adminContestSchema.parse({
|
return {
|
||||||
id: row.contest.id,
|
id: row.contest.id,
|
||||||
title: row.contest.title,
|
title: row.contest.title,
|
||||||
description: row.contest.description,
|
description: row.contest.description,
|
||||||
@@ -52,32 +42,33 @@ async function serialize(row: {
|
|||||||
lastUpdateTime: row.contest.lastUpdateTime,
|
lastUpdateTime: row.contest.lastUpdateTime,
|
||||||
password: row.contest.password,
|
password: row.contest.password,
|
||||||
visible: row.contest.visible,
|
visible: row.contest.visible,
|
||||||
allowedIpRanges: Array.isArray(row.contest.allowedIpRanges)
|
|
||||||
? row.contest.allowedIpRanges.filter((item): item is string => typeof item === "string")
|
|
||||||
: [],
|
|
||||||
createdBy: sampleUser(row.user, row.realName),
|
createdBy: sampleUser(row.user, row.realName),
|
||||||
status: contestStatus(row.contest),
|
status: contestStatus(row.contest),
|
||||||
contestType: row.contest.password ? "Password Protected" : "Public",
|
contestType: row.contest.password ? "Password Protected" : "Public",
|
||||||
})
|
} satisfies AdminContest
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectContest(id: number) {
|
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)
|
.from(schema.contest)
|
||||||
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
|
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 请求体里的时间与 CIDR 校验,创建和编辑共用 */
|
/** 请求体里的时间校验,创建和编辑共用 */
|
||||||
function validatePayload(data: { startTime: string; endTime: string; allowedIpRanges: string[] }) {
|
function validatePayload(data: { startTime: string; endTime: string }) {
|
||||||
const start = Date.parse(data.startTime)
|
const start = Date.parse(data.startTime)
|
||||||
const end = Date.parse(data.endTime)
|
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"
|
if (end <= start) return "Start time must occur earlier than end time"
|
||||||
for (const range of data.allowedIpRanges) {
|
|
||||||
if (!validCidr(range)) return `${range} is not a valid cidr network`
|
|
||||||
}
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,27 +78,41 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
|
|||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const filters = []
|
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()
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
|
if (keyword) filters.push(ilike(schema.contest.title, `%${keyword}%`))
|
||||||
const where = filters.length ? and(...filters) : undefined
|
const where = filters.length ? and(...filters) : undefined
|
||||||
|
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.contest).where(where),
|
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)
|
.from(schema.contest)
|
||||||
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
|
.innerJoin(schema.user, eq(schema.contest.createdById, schema.user.id))
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
.leftJoin(
|
||||||
.where(where).orderBy(desc(schema.contest.createTime)).limit(limit).offset(offset),
|
schema.userProfile,
|
||||||
|
eq(schema.userProfile.userId, schema.user.id),
|
||||||
|
)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(schema.contest.createTime))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset),
|
||||||
])
|
])
|
||||||
return success(c, adminContestListSchema.parse({
|
return success(c, {
|
||||||
results: await Promise.all(rows.map(serialize)),
|
results: await Promise.all(rows.map(serialize)),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
}))
|
} satisfies AdminContestList)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminContestRoutes.get("/contests/:id", 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)) {
|
if (!row || !ownedBy(c.get("user")!, row.contest)) {
|
||||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
}
|
}
|
||||||
@@ -115,37 +120,53 @@ adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
adminContestRoutes.post("/contests", 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) {
|
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)
|
const error = validatePayload(parsed.data)
|
||||||
if (error) return failure(c, 400, "invalid-contest", error)
|
if (error) return failure(c, 400, "invalid-contest", error)
|
||||||
|
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const [created] = await db.insert(schema.contest).values({
|
const [created] = await db
|
||||||
title: parsed.data.title,
|
.insert(schema.contest)
|
||||||
description: parsed.data.description,
|
.values({
|
||||||
tag: parsed.data.tag,
|
title: parsed.data.title,
|
||||||
startTime: new Date(parsed.data.startTime).toISOString(),
|
description: parsed.data.description,
|
||||||
endTime: new Date(parsed.data.endTime).toISOString(),
|
tag: parsed.data.tag,
|
||||||
// 空串归一成 null,否则 contestType 会把「密码是空字符串」当成密码保护赛
|
startTime: new Date(parsed.data.startTime).toISOString(),
|
||||||
password: parsed.data.password || null,
|
endTime: new Date(parsed.data.endTime).toISOString(),
|
||||||
visible: parsed.data.visible,
|
// 空串归一成 null,否则 contestType 会把「密码是空字符串」当成密码保护赛
|
||||||
allowedIpRanges: parsed.data.allowedIpRanges,
|
password: parsed.data.password || null,
|
||||||
createdById: c.get("user")!.id,
|
visible: parsed.data.visible,
|
||||||
createTime: now,
|
createdById: c.get("user")!.id,
|
||||||
lastUpdateTime: now,
|
createTime: now,
|
||||||
}).returning({ id: schema.contest.id })
|
lastUpdateTime: now,
|
||||||
|
})
|
||||||
|
.returning({ id: schema.contest.id })
|
||||||
const [row] = await selectContest(created!.id)
|
const [row] = await selectContest(created!.id)
|
||||||
return success(c, await serialize(row!), 201)
|
return success(c, await serialize(row!), 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
|
adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
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) {
|
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)
|
const [existing] = await selectContest(id)
|
||||||
if (!existing || !ownedBy(c.get("user")!, existing.contest)) {
|
if (!existing || !ownedBy(c.get("user")!, existing.contest)) {
|
||||||
@@ -154,17 +175,19 @@ adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
|
|||||||
const error = validatePayload(parsed.data)
|
const error = validatePayload(parsed.data)
|
||||||
if (error) return failure(c, 400, "invalid-contest", error)
|
if (error) return failure(c, 400, "invalid-contest", error)
|
||||||
|
|
||||||
await db.update(schema.contest).set({
|
await db
|
||||||
title: parsed.data.title,
|
.update(schema.contest)
|
||||||
description: parsed.data.description,
|
.set({
|
||||||
tag: parsed.data.tag,
|
title: parsed.data.title,
|
||||||
startTime: new Date(parsed.data.startTime).toISOString(),
|
description: parsed.data.description,
|
||||||
endTime: new Date(parsed.data.endTime).toISOString(),
|
tag: parsed.data.tag,
|
||||||
password: parsed.data.password || null,
|
startTime: new Date(parsed.data.startTime).toISOString(),
|
||||||
visible: parsed.data.visible,
|
endTime: new Date(parsed.data.endTime).toISOString(),
|
||||||
allowedIpRanges: parsed.data.allowedIpRanges,
|
password: parsed.data.password || null,
|
||||||
lastUpdateTime: new Date().toISOString(),
|
visible: parsed.data.visible,
|
||||||
}).where(eq(schema.contest.id, id))
|
lastUpdateTime: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.contest.id, id))
|
||||||
const [row] = await selectContest(id)
|
const [row] = await selectContest(id)
|
||||||
return success(c, await serialize(row!))
|
return success(c, await serialize(row!))
|
||||||
})
|
})
|
||||||
@@ -172,11 +195,27 @@ adminContestRoutes.put("/contests/:id", requireTeacher, async (c) => {
|
|||||||
adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const [original] = await selectContest(id)
|
const [original] = await selectContest(id)
|
||||||
// 克隆不要求 ownedBy:旧后端这里也没有 ensure_created_by,教师可以拿别人的比赛做模板。
|
// 这个接口是干什么的:**把以前那场比赛快速再开一场**,不用从头建一遍题。
|
||||||
// 克隆出来的归调用者所有、且默认不可见,所以不构成越权修改。
|
// 所以副本要带着整套题(含 answers 和 testCaseId),时间挪到 10 分钟后、默认不可见,
|
||||||
if (!original) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
// 前端点完「复制」直接跳进副本的编辑页改标题和时间(admin/contest/components/Actions.vue)。
|
||||||
|
//
|
||||||
|
// 克隆**故意不要求 ownedBy**,任何教师都能克隆任何一场比赛,包括别人的、隐藏的、
|
||||||
|
// 还没开始的 —— 于是克隆完就能从 GET /admin/problems/:id 读到别人的标准答案、
|
||||||
|
// 从 /test-cases 下载别人的测试点。这是**明确定过的政策**(2026-09-06 确认):
|
||||||
|
// 保密边界在师生之间,不在教师之间。别再把它当越权读取报上来。
|
||||||
|
//
|
||||||
|
// 注意这条政策**不能顺手推广到 make-public / from-public**:那两条守的是另一件事 ——
|
||||||
|
// 别让 B 把 A 还没考的卷子发布给**学生**,或者把 A 的草稿拖进自己比赛再放出去。
|
||||||
|
// 边界是学生,所以那两处的归属校验照旧(见 admin/problem.ts 的注释)。
|
||||||
|
//
|
||||||
|
// 已知的副作用,别当成 bug 去"修":副本和原题共用同一个测试点目录(testCaseId 原样复制),
|
||||||
|
// 今天无害(删题特意不删目录),但以后要是加"删题顺手清测试点",得先把这里改成复制目录。
|
||||||
|
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 分钟后开始,时长与原比赛相同 —— 给出题人留出改时间的余地,
|
// 新比赛从 10 分钟后开始,时长与原比赛相同 —— 给出题人留出改时间的余地,
|
||||||
// 又不至于建出一个已经结束的比赛
|
// 又不至于建出一个已经结束的比赛
|
||||||
const start = new Date(Date.now() + 10 * 60 * 1000)
|
const start = new Date(Date.now() + 10 * 60 * 1000)
|
||||||
@@ -185,52 +224,79 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
|||||||
const me = c.get("user")!.id
|
const me = c.get("user")!.id
|
||||||
|
|
||||||
const cloned = await db.transaction(async (tx) => {
|
const cloned = await db.transaction(async (tx) => {
|
||||||
const [contest] = await tx.insert(schema.contest).values({
|
const [contest] = await tx
|
||||||
title: original.contest.title,
|
.insert(schema.contest)
|
||||||
description: original.contest.description,
|
.values({
|
||||||
tag: original.contest.tag,
|
title: original.contest.title,
|
||||||
// 不复制原比赛的密码。两个理由:一是克隆出来是一场新比赛、时间也是新的,
|
description: original.contest.description,
|
||||||
// 沿用旧密码意味着拿着旧密码的学生直接能进;二是本接口不校验归属
|
tag: original.contest.tag,
|
||||||
// (旧后端也不校验,教师可以拿别人的比赛做模板),复制过来就等于把别人的
|
// 不复制原比赛的密码。两个理由:一是克隆出来是一场新比赛、时间也是新的,
|
||||||
// 比赛密码原样回传给调用者。克隆者自己重新设一个。
|
// 沿用旧密码意味着拿着旧密码的学生直接能进;二是本接口不校验归属
|
||||||
password: null,
|
// (旧后端也不校验,教师可以拿别人的比赛做模板),复制过来就等于把别人的
|
||||||
// 克隆出来的一律不可见:时间是拍脑袋定的 10 分钟后,直接开放会让学生看到一场没准备好的赛
|
// 比赛密码原样回传给调用者。克隆者自己重新设一个。
|
||||||
visible: false,
|
password: null,
|
||||||
allowedIpRanges: original.contest.allowedIpRanges,
|
// 克隆出来的一律不可见:时间是拍脑袋定的 10 分钟后,直接开放会让学生看到一场没准备好的赛
|
||||||
startTime: start.toISOString(),
|
visible: false,
|
||||||
endTime: end.toISOString(),
|
startTime: start.toISOString(),
|
||||||
createdById: me,
|
endTime: end.toISOString(),
|
||||||
createTime: now,
|
createdById: me,
|
||||||
lastUpdateTime: now,
|
createTime: now,
|
||||||
}).returning({ id: schema.contest.id })
|
lastUpdateTime: now,
|
||||||
|
})
|
||||||
|
.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))
|
.where(eq(schema.problem.contestId, id))
|
||||||
if (problems.length === 0) return contest!.id
|
if (problems.length === 0) return contest!.id
|
||||||
|
|
||||||
// 题面、标签各一条语句,不再按题循环。新旧题的对应关系靠 _id 认:
|
// 题面、标签各一条语句,不再按题循环。新旧题的对应关系靠 _id 认:
|
||||||
// 克隆出来的题原样保留 _id,而它们全在同一场新比赛里,彼此不会重名。
|
// 克隆出来的题原样保留 _id,而它们全在同一场新比赛里,彼此不会重名。
|
||||||
const copies = await tx.insert(schema.problem).values(problems.map(({ id: _oldId, ...rest }) => ({
|
const copies = await tx
|
||||||
...rest,
|
.insert(schema.problem)
|
||||||
contestId: contest!.id,
|
.values(
|
||||||
// 计数器归零:克隆的是题面,不是历史战绩
|
problems.map(({ id: _oldId, ...rest }) => ({
|
||||||
submissionNumber: 0,
|
...rest,
|
||||||
acceptedNumber: 0,
|
contestId: contest!.id,
|
||||||
statisticInfo: {},
|
// 计数器归零:克隆的是题面,不是历史战绩
|
||||||
createdById: me,
|
submissionNumber: 0,
|
||||||
createTime: now,
|
acceptedNumber: 0,
|
||||||
lastUpdateTime: now,
|
statisticInfo: {},
|
||||||
}))).returning({ id: schema.problem.id, displayId: schema.problem.displayId })
|
createdById: me,
|
||||||
const newIdByDisplayId = new Map(copies.map((copy) => [copy.displayId, copy.id]))
|
createTime: now,
|
||||||
|
lastUpdateTime: now,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.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) 对应这里手工复制关系行
|
// 标签是多对多中间表,Django 的 problem.tags.set(tags) 对应这里手工复制关系行
|
||||||
const tags = await tx.select({ problemId: schema.problemTags.problemId, tagId: schema.problemTags.problemtagId })
|
const tags = await tx
|
||||||
.from(schema.problemTags).where(inArray(schema.problemTags.problemId, problems.map((problem) => problem.id)))
|
.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) {
|
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 links = tags.flatMap((tag) => {
|
||||||
const newId = newIdByDisplayId.get(displayIdByOldId.get(tag.problemId) ?? "")
|
const newId = newIdByDisplayId.get(
|
||||||
return newId === undefined ? [] : [{ problemId: newId, problemtagId: tag.tagId }]
|
displayIdByOldId.get(tag.problemId) ?? "",
|
||||||
|
)
|
||||||
|
return newId === undefined
|
||||||
|
? []
|
||||||
|
: [{ problemId: newId, problemtagId: tag.tagId }]
|
||||||
})
|
})
|
||||||
if (links.length) await tx.insert(schema.problemTags).values(links)
|
if (links.length) await tx.insert(schema.problemTags).values(links)
|
||||||
}
|
}
|
||||||
@@ -243,81 +309,128 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
|
|||||||
|
|
||||||
// ---------------------------------------------------------------- ACM 赛后核查
|
// ---------------------------------------------------------------- ACM 赛后核查
|
||||||
|
|
||||||
adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
adminContestRoutes.get(
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
"/contests/:id/acm-helper",
|
||||||
const [contest] = await db.select().from(schema.contest)
|
requireTeacher,
|
||||||
.where(and(eq(schema.contest.id, id), eq(schema.contest.visible, true))).limit(1)
|
async (c) => {
|
||||||
if (!contest || !ownedBy(c.get("user")!, contest)) {
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
// 不卡 visible:赛后核查恰恰常发生在比赛已经收起来之后,而同一场比赛的
|
||||||
}
|
// PUT acm-helper 从来不卡这一条 —— 卡着就成了「标记还能改、页面打不开」
|
||||||
|
const [contest] = await db
|
||||||
const [problems, ranks] = await Promise.all([
|
.select()
|
||||||
db.select({ id: schema.problem.id, displayId: schema.problem.displayId })
|
.from(schema.contest)
|
||||||
.from(schema.problem).where(eq(schema.problem.contestId, id)),
|
.where(eq(schema.contest.id, id))
|
||||||
db.select({
|
.limit(1)
|
||||||
id: schema.acmContestRank.id,
|
if (!contest || !ownedBy(c.get("user")!, contest)) {
|
||||||
username: schema.user.username,
|
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
||||||
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))
|
|
||||||
.where(eq(schema.acmContestRank.contestId, id)),
|
|
||||||
])
|
|
||||||
const displayIds = new Map(problems.map((problem) => [String(problem.id), problem.displayId]))
|
|
||||||
|
|
||||||
const results = []
|
|
||||||
for (const rank of ranks) {
|
|
||||||
if (rank.acceptedNumber <= 0) continue
|
|
||||||
for (const [problemId, raw] of Object.entries(objectValue(rank.submissionInfo))) {
|
|
||||||
const info = objectValue(raw)
|
|
||||||
if (info.is_ac !== true) continue
|
|
||||||
results.push({
|
|
||||||
id: rank.id,
|
|
||||||
username: rank.username,
|
|
||||||
// 真名在这里是**有意下发**的:核查页就是老师对着名单一个个确认谁抄了。
|
|
||||||
// 接口已由 requireTeacher + ownedBy 双重把关。
|
|
||||||
realName: rank.realName,
|
|
||||||
problemId,
|
|
||||||
problemDisplayId: displayIds.get(problemId) ?? problemId,
|
|
||||||
acInfo: info,
|
|
||||||
checked: info.checked === true,
|
|
||||||
_acTime: typeof info.ac_time === "number" ? info.ac_time : 0,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
|
|
||||||
results.sort((left, right) => right._acTime - left._acTime)
|
|
||||||
return success(c, results.map(({ _acTime, ...item }) => acmHelperItemSchema.parse(item)))
|
|
||||||
})
|
|
||||||
|
|
||||||
adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => {
|
const [problems, ranks] = await Promise.all([
|
||||||
const contestId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
db
|
||||||
const parsed = updateAcmHelperRequestSchema.safeParse(await c.req.json().catch(() => null))
|
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
|
||||||
if (!parsed.success) {
|
.from(schema.problem)
|
||||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "Invalid payload")
|
.where(eq(schema.problem.contestId, id)),
|
||||||
}
|
db
|
||||||
const [contest] = await db.select().from(schema.contest).where(eq(schema.contest.id, contestId)).limit(1)
|
.select({
|
||||||
if (!contest || !ownedBy(c.get("user")!, contest)) {
|
id: schema.acmContestRank.id,
|
||||||
return failure(c, 404, "contest-not-found", "Contest does not exist")
|
username: schema.user.username,
|
||||||
}
|
realName: schema.userProfile.realName,
|
||||||
// rank 必须属于这场比赛。旧后端只按 rank_id 取,不校验归属 ——
|
submissionInfo: schema.acmContestRank.submissionInfo,
|
||||||
// 那样带上任意 rank_id 就能改别的比赛的核查标记
|
acceptedNumber: schema.acmContestRank.acceptedNumber,
|
||||||
const [rank] = await db.select().from(schema.acmContestRank).where(and(
|
})
|
||||||
eq(schema.acmContestRank.id, parsed.data.rankId),
|
.from(schema.acmContestRank)
|
||||||
eq(schema.acmContestRank.contestId, contestId),
|
.innerJoin(
|
||||||
)).limit(1)
|
schema.user,
|
||||||
if (!rank) return failure(c, 404, "rank-not-found", "Rank id does not exist")
|
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 info = objectValue(rank.submissionInfo)
|
const results = []
|
||||||
const entry = objectValue(info[parsed.data.problemId])
|
for (const rank of ranks) {
|
||||||
if (!info[parsed.data.problemId]) {
|
if (rank.acceptedNumber <= 0) continue
|
||||||
return failure(c, 404, "problem-not-in-rank", "Problem id does not exist")
|
for (const [problemId, info] of Object.entries(rank.submissionInfo)) {
|
||||||
}
|
if (info.is_ac !== true) continue
|
||||||
entry.checked = parsed.data.checked
|
results.push({
|
||||||
info[parsed.data.problemId] = entry
|
id: rank.id,
|
||||||
await db.update(schema.acmContestRank).set({ submissionInfo: info })
|
username: rank.username,
|
||||||
.where(eq(schema.acmContestRank.id, rank.id))
|
// 真名在这里是**有意下发**的:核查页就是老师对着名单一个个确认谁抄了。
|
||||||
return success(c, null)
|
// 接口已由 requireTeacher + ownedBy 双重把关。
|
||||||
})
|
realName: rank.realName,
|
||||||
|
problemId,
|
||||||
|
problemDisplayId: displayIds.get(problemId) ?? problemId,
|
||||||
|
acInfo: info,
|
||||||
|
checked: info.checked === true,
|
||||||
|
_acTime: typeof info.ac_time === "number" ? info.ac_time : 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
|
||||||
|
results.sort((left, right) => right._acTime - left._acTime)
|
||||||
|
return success(
|
||||||
|
c,
|
||||||
|
results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
if (!parsed.success) {
|
||||||
|
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)
|
||||||
|
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(
|
||||||
|
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")
|
||||||
|
|
||||||
|
const info = rank.submissionInfo
|
||||||
|
const entry = info[parsed.data.problemId]
|
||||||
|
if (!entry) {
|
||||||
|
return failure(c, 404, "problem-not-in-rank", "Problem id does not exist")
|
||||||
|
}
|
||||||
|
entry.checked = parsed.data.checked
|
||||||
|
info[parsed.data.problemId] = entry
|
||||||
|
await db
|
||||||
|
.update(schema.acmContestRank)
|
||||||
|
.set({ submissionInfo: info })
|
||||||
|
.where(eq(schema.acmContestRank.id, rank.id))
|
||||||
|
return success(c, null)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { adminAchievementRoutes } from "./achievement"
|
|||||||
import { adminAiRoutes } from "./ai"
|
import { adminAiRoutes } from "./ai"
|
||||||
import { adminConfRoutes } from "./conf"
|
import { adminConfRoutes } from "./conf"
|
||||||
import { adminContestRoutes } from "./contest"
|
import { adminContestRoutes } from "./contest"
|
||||||
|
import { adminLearnRoutes } from "./learn"
|
||||||
import { adminProblemRoutes } from "./problem"
|
import { adminProblemRoutes } from "./problem"
|
||||||
import { adminProblemSetRoutes } from "./problemset"
|
import { adminProblemSetRoutes } from "./problemset"
|
||||||
import { adminTagRoutes } from "./tag"
|
import { adminTagRoutes } from "./tag"
|
||||||
@@ -27,6 +28,7 @@ adminRoutes.route("/", adminAchievementRoutes)
|
|||||||
adminRoutes.route("/", adminAiRoutes)
|
adminRoutes.route("/", adminAiRoutes)
|
||||||
adminRoutes.route("/", adminConfRoutes)
|
adminRoutes.route("/", adminConfRoutes)
|
||||||
adminRoutes.route("/", adminContestRoutes)
|
adminRoutes.route("/", adminContestRoutes)
|
||||||
|
adminRoutes.route("/", adminLearnRoutes)
|
||||||
adminRoutes.route("/", adminProblemRoutes)
|
adminRoutes.route("/", adminProblemRoutes)
|
||||||
adminRoutes.route("/", adminProblemSetRoutes)
|
adminRoutes.route("/", adminProblemSetRoutes)
|
||||||
adminRoutes.route("/", adminTagRoutes)
|
adminRoutes.route("/", adminTagRoutes)
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import {
|
||||||
|
STUDENT_ROLES,
|
||||||
|
TUTORIAL_READ_SECONDS,
|
||||||
|
type LearnExerciseAttempt,
|
||||||
|
type LearnExerciseProgress,
|
||||||
|
type LearnExerciseProgressList,
|
||||||
|
type LearnStudentProgress,
|
||||||
|
type LearnStudentProgressList,
|
||||||
|
type LearnTutorialProgress,
|
||||||
|
type LearnTutorialProgressList,
|
||||||
|
} from "@oj2/contract"
|
||||||
|
import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
|
||||||
|
import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||||
|
import { db, schema } from "../../db"
|
||||||
|
import { failure, success } from "../../http"
|
||||||
|
import { queryInteger, rounded } from "../helpers"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自学情况:教程读了没、读了多久。
|
||||||
|
*
|
||||||
|
* 路径用 `/learn-analytics` 而不是挂在 `/tutorials` 下,理由同 tag.ts 里那段注释:
|
||||||
|
* Hono 按注册顺序匹配,`/tutorials/:id` 会把同级的静态段整个吃掉且不报错。
|
||||||
|
*/
|
||||||
|
export const adminLearnRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
|
function tutorialTypeOf(value: string | undefined) {
|
||||||
|
return value === "c" ? "c" : "python"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 班级筛选。班号形如 `241`(24 级 1 班),只允许纯数字:
|
||||||
|
* 直接拼进 like 的话,`%` 会变成通配符,把筛选变成「全选」。
|
||||||
|
*/
|
||||||
|
function classFilter(className: string | undefined) {
|
||||||
|
const value = className?.trim()
|
||||||
|
if (!value) return { ok: true as const, value: null }
|
||||||
|
if (!/^\d{1,4}$/.test(value)) return { ok: false as const, value: null }
|
||||||
|
return { ok: true as const, value }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `className` 传 3 位以上是具体班级(精确匹配),传 1-2 位当年级前缀(like)。
|
||||||
|
* 年级前缀这条是给「24 级整体读得怎么样」用的,不然老师得一个班一个班点。
|
||||||
|
*/
|
||||||
|
function classCondition(value: string | null) {
|
||||||
|
if (!value) return undefined
|
||||||
|
return value.length >= 3
|
||||||
|
? eq(schema.user.className, value)
|
||||||
|
: like(schema.user.className, `${value}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 没有班级的学生**照样统计**(班级列显示为空)。班级是从用户名的数字前缀推出来的,
|
||||||
|
* 推不出来时就是 null(见 admin/account.ts 的 classNameOf),把这些人过滤掉等于让他们
|
||||||
|
* 在「谁没学」这张表上凭空消失 —— 班级榜可以只算入班的人,这里不行。
|
||||||
|
*/
|
||||||
|
function studentCondition(value: string | null) {
|
||||||
|
return and(
|
||||||
|
eq(schema.user.isDisabled, false),
|
||||||
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
|
classCondition(value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminLearnRoutes.get("/learn-analytics/students", 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 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 进度:没读过的人也要出现在结果里,这是这张表的重点
|
||||||
|
const progressJoin = tutorialIds.length
|
||||||
|
? and(
|
||||||
|
eq(schema.tutorialProgress.userId, schema.user.id),
|
||||||
|
inArray(schema.tutorialProgress.tutorialId, tutorialIds),
|
||||||
|
)
|
||||||
|
: sql`false`
|
||||||
|
|
||||||
|
// 阅读和练习**分两条查**再在内存里拼。写成一条的话,一个学生读了 3 课、
|
||||||
|
// 做了 8 道练习,join 出来是 24 行,count 全是错的 —— 两个一对多挂在同一张表上
|
||||||
|
// 就是这个下场,用 filter 也救不回来
|
||||||
|
const [rows, exerciseRows] = await Promise.all([
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
.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({
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
.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)
|
||||||
|
.where(inArray(schema.exercise.tutorialId, tutorialIds))
|
||||||
|
: [{ value: 0 }]
|
||||||
|
|
||||||
|
return success(c, {
|
||||||
|
tutorialCount: tutorialIds.length,
|
||||||
|
exerciseCount: exerciseCountRow?.value ?? 0,
|
||||||
|
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 LearnStudentProgressList)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
.where(studentCondition(className.value))
|
||||||
|
const studentCount = studentCountRow?.value ?? 0
|
||||||
|
|
||||||
|
// 进度行 join 回 user 是为了让班级筛选生效,同时把老师自己试读的记录挡在外面
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
tutorialId: schema.tutorial.id,
|
||||||
|
title: schema.tutorial.title,
|
||||||
|
order: schema.tutorial.order,
|
||||||
|
// 数的是 user.id 而不是 progress.user_id:join 不上的(老师自己试读的、
|
||||||
|
// 已禁用的、不在所选班级的)在这一列是 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(读满 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(
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
|
||||||
|
.orderBy(asc(schema.tutorial.order))
|
||||||
|
|
||||||
|
return success(c, {
|
||||||
|
studentCount,
|
||||||
|
results: rows.map(
|
||||||
|
({ readSeconds, ...row }) =>
|
||||||
|
({
|
||||||
|
...row,
|
||||||
|
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
|
||||||
|
}) satisfies LearnTutorialProgress,
|
||||||
|
),
|
||||||
|
} satisfies LearnTutorialProgressList)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按练习:哪道练一练卡住了全班。
|
||||||
|
*
|
||||||
|
* 一道题一行,含做过/做对的人数、做对的人平均试了几次、一次就做对的人数。
|
||||||
|
* 没人做过的题也在列表里(一行零)—— 「这道题全班没一个人碰」同样是要看见的。
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
.where(studentCondition(className.value))
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
exerciseId: schema.exercise.id,
|
||||||
|
tutorialId: schema.tutorial.id,
|
||||||
|
tutorialTitle: schema.tutorial.title,
|
||||||
|
tutorialOrder: schema.tutorial.order,
|
||||||
|
type: schema.exercise.type,
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
// 只算做对的人:没做对的人「试了几次」还没停,混进平均值只会把它拉花
|
||||||
|
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(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
|
||||||
|
|
||||||
|
return success(c, {
|
||||||
|
studentCount: studentCountRow?.value ?? 0,
|
||||||
|
results: rows.map(
|
||||||
|
(row) =>
|
||||||
|
({
|
||||||
|
...row,
|
||||||
|
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
|
||||||
|
}) satisfies LearnExerciseProgress,
|
||||||
|
),
|
||||||
|
} satisfies LearnExerciseProgressList)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */
|
||||||
|
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({
|
||||||
|
userId: schema.user.id,
|
||||||
|
username: schema.user.username,
|
||||||
|
realName: schema.userProfile.realName,
|
||||||
|
className: schema.user.className,
|
||||||
|
attempts: schema.exerciseAttempt.attempts,
|
||||||
|
wrongAttempts: schema.exerciseAttempt.wrongAttempts,
|
||||||
|
solved: schema.exerciseAttempt.solved,
|
||||||
|
attemptsToSolve: schema.exerciseAttempt.attemptsToSolve,
|
||||||
|
lastWrongAnswer: schema.exerciseAttempt.lastWrongAnswer,
|
||||||
|
lastAttemptAt: schema.exerciseAttempt.lastAttemptAt,
|
||||||
|
})
|
||||||
|
.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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
|
||||||
|
.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
File diff suppressed because it is too large
Load Diff
+362
-167
@@ -1,30 +1,53 @@
|
|||||||
import {
|
import {
|
||||||
acTrendSchema,
|
|
||||||
adminTagSchema,
|
|
||||||
batchProblemTagRequestSchema,
|
batchProblemTagRequestSchema,
|
||||||
batchProblemTagResponseSchema,
|
|
||||||
generateFlowchartRequestSchema,
|
generateFlowchartRequestSchema,
|
||||||
generateFlowchartResponseSchema,
|
|
||||||
renameTagRequestSchema,
|
renameTagRequestSchema,
|
||||||
renameTagResponseSchema,
|
type AcTrend,
|
||||||
stuckProblemSchema,
|
type AdminTag,
|
||||||
|
type BatchProblemTagResponse,
|
||||||
|
type GenerateFlowchartResponse,
|
||||||
|
type RenameTagResponse,
|
||||||
|
type StuckProblem,
|
||||||
} from "@oj2/contract"
|
} 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 { 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 type { AuthUser } from "../../auth/session"
|
||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
import { failure, success } from "../../http"
|
import { failure, success } from "../../http"
|
||||||
import { JudgeStatus } from "../../judge/status"
|
import { JudgeStatus } from "../../judge/status"
|
||||||
import { completeChat } from "../../services/ai"
|
import { completeChat } from "../../services/ai"
|
||||||
|
import { localTime, localYear } from "../../time"
|
||||||
import { queryInteger, rounded } from "../helpers"
|
import { queryInteger, rounded } from "../helpers"
|
||||||
import { findTagsByName, normalizeTagNames } from "./problem"
|
import { findTagsByName, normalizeTagNames } from "./problem"
|
||||||
|
|
||||||
export const adminTagRoutes = new Hono<AppEnv>()
|
export const adminTagRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
const ACCEPTED = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
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 */
|
/** 能管所有题目:超管,或 problemPermission 为 All */
|
||||||
function canManageAllProblems(user: AuthUser) {
|
function canManageAllProblems(user: AuthUser) {
|
||||||
@@ -35,147 +58,250 @@ function canManageAllProblems(user: AuthUser) {
|
|||||||
|
|
||||||
adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
|
adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
|
||||||
const keyword = c.req.query("keyword")?.trim()
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
const rows = await db.select({
|
const rows = await db
|
||||||
id: schema.problemTag.id,
|
.select({
|
||||||
name: schema.problemTag.name,
|
id: schema.problemTag.id,
|
||||||
problemCount: countDistinct(schema.problemTags.problemId),
|
name: schema.problemTag.name,
|
||||||
}).from(schema.problemTag)
|
problemCount: countDistinct(schema.problemTags.problemId),
|
||||||
.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)
|
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||||
.groupBy(schema.problemTag.id, schema.problemTag.name)
|
.groupBy(schema.problemTag.id, schema.problemTag.name)
|
||||||
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
|
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
|
||||||
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0
|
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0
|
||||||
.orderBy(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name))
|
.orderBy(
|
||||||
return success(c, rows.map((row) => adminTagSchema.parse(row)))
|
desc(countDistinct(schema.problemTags.problemId)),
|
||||||
|
asc(schema.problemTag.name),
|
||||||
|
)
|
||||||
|
return success(c, rows satisfies AdminTag[])
|
||||||
})
|
})
|
||||||
|
|
||||||
adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
|
adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const parsed = renameTagRequestSchema.safeParse(await c.req.json().catch(() => null))
|
const parsed = renameTagRequestSchema.safeParse(
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "标签名不能为空")
|
await c.req.json().catch(() => null),
|
||||||
|
)
|
||||||
|
if (!parsed.success)
|
||||||
|
return failure(c, 400, "invalid-request", "标签名不能为空")
|
||||||
const name = parsed.data.name
|
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", "标签不存在,请刷新后重试")
|
if (!tag) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
|
||||||
|
|
||||||
const [target] = await db.select().from(schema.problemTag)
|
const [target] = await db
|
||||||
.where(and(sql`lower(${schema.problemTag.name}) = lower(${name})`, ne(schema.problemTag.id, id))).limit(1)
|
.select()
|
||||||
|
.from(schema.problemTag)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
sql`lower(${schema.problemTag.name}) = lower(${name})`,
|
||||||
|
ne(schema.problemTag.id, id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id))
|
await db
|
||||||
return success(c, renameTagResponseSchema.parse({ merged: false, id, name, affectedCount: 0 }))
|
.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 affected = await db.transaction(async (tx) => {
|
||||||
const links = await tx.select({ problemId: schema.problemTags.problemId })
|
const links = await tx
|
||||||
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
|
.select({ problemId: schema.problemTags.problemId })
|
||||||
const already = new Set((await tx.select({ problemId: schema.problemTags.problemId })
|
.from(schema.problemTags)
|
||||||
.from(schema.problemTags).where(eq(schema.problemTags.problemtagId, target.id)))
|
.where(eq(schema.problemTags.problemtagId, id))
|
||||||
.map((row) => row.problemId))
|
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) 唯一约束
|
// 只给还没挂目标标签的题目补关系,否则会撞 (problem_id, problemtag_id) 唯一约束
|
||||||
const missing = links.filter((link) => !already.has(link.problemId))
|
const missing = links.filter((link) => !already.has(link.problemId))
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
await tx.insert(schema.problemTags).values(missing.map((link) => ({
|
await tx.insert(schema.problemTags).values(
|
||||||
problemId: link.problemId,
|
missing.map((link) => ({
|
||||||
problemtagId: target.id,
|
problemId: link.problemId,
|
||||||
})))
|
problemtagId: target.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
await tx.delete(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
|
// 旧标签上剩下的关系行随标签一起没:problem_tags.problemtag_id 是 CASCADE(0010)。
|
||||||
|
// 上面那批 insert 已经把题目挂到 target 上了,这里删掉的只是旧的那一份关系。
|
||||||
await tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
|
await tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
|
||||||
return links.length
|
return links.length
|
||||||
})
|
})
|
||||||
return success(c, renameTagResponseSchema.parse({
|
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(
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
"/problem-tags/:id",
|
||||||
// 中间表是 NO ACTION 外键,得先清关系再删标签
|
requireProblemPermission,
|
||||||
const deleted = await db.transaction(async (tx) => {
|
async (c) => {
|
||||||
await tx.delete(schema.problemTags).where(eq(schema.problemTags.problemtagId, id))
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
return tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
|
// 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE(0010)
|
||||||
|
const deleted = await db
|
||||||
|
.delete(schema.problemTag)
|
||||||
|
.where(eq(schema.problemTag.id, id))
|
||||||
.returning({ id: schema.problemTag.id })
|
.returning({ id: schema.problemTag.id })
|
||||||
})
|
if (deleted.length === 0)
|
||||||
if (deleted.length === 0) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
|
return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
adminTagRoutes.post("/problems/batch-tag", requireProblemPermission, async (c) => {
|
adminTagRoutes.post(
|
||||||
const parsed = batchProblemTagRequestSchema.safeParse(await c.req.json().catch(() => null))
|
"/problems/batch-tag",
|
||||||
if (!parsed.success) {
|
requireProblemPermission,
|
||||||
return failure(c, 400, "invalid-request", parsed.error.issues[0]?.message ?? "参数错误")
|
async (c) => {
|
||||||
}
|
const parsed = batchProblemTagRequestSchema.safeParse(
|
||||||
const user = c.get("user")!
|
await c.req.json().catch(() => null),
|
||||||
const filters = [inArray(schema.problem.id, parsed.data.problemIds), isNull(schema.problem.contestId)]
|
)
|
||||||
if (!canManageAllProblems(user)) filters.push(eq(schema.problem.createdById, user.id))
|
if (!parsed.success) {
|
||||||
const problems = await db.select({ id: schema.problem.id }).from(schema.problem).where(and(...filters))
|
return failure(
|
||||||
if (problems.length === 0) return failure(c, 404, "no-problems", "没有可操作的题目")
|
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", "没有可操作的题目")
|
||||||
|
|
||||||
// 去重且大小写不敏感,与旧 resolve_tags / find_tags 一致
|
// 去重且大小写不敏感,与旧 resolve_tags / find_tags 一致
|
||||||
const wanted = normalizeTagNames(parsed.data.tagNames)
|
const wanted = normalizeTagNames(parsed.data.tagNames)
|
||||||
|
|
||||||
const tagIds = await db.transaction(async (tx) => {
|
const tagIds = await db.transaction(async (tx) => {
|
||||||
const existing = await findTagsByName(tx as unknown as typeof db, wanted)
|
const existing = await findTagsByName(tx as unknown as typeof db, wanted)
|
||||||
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
|
// 添加时按需新建标签,移除时只认已有标签 —— 否则「移除」会顺手造出一堆空标签
|
||||||
if (parsed.data.action === "add") {
|
if (parsed.data.action === "add") {
|
||||||
const missing = wanted.filter((name) => !existing.has(name.toLowerCase()))
|
const missing = wanted.filter(
|
||||||
if (missing.length) {
|
(name) => !existing.has(name.toLowerCase()),
|
||||||
const created = await tx.insert(schema.problemTag).values(missing.map((name) => ({ name })))
|
)
|
||||||
.returning({ id: schema.problemTag.id, name: schema.problemTag.name })
|
if (missing.length) {
|
||||||
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
|
||||||
return wanted.map((name) => existing.get(name.toLowerCase())).filter((id) => id !== undefined)
|
.map((name) => existing.get(name.toLowerCase()))
|
||||||
})
|
.filter((id) => id !== undefined)
|
||||||
if (tagIds.length === 0) return failure(c, 404, "no-tags", "没有匹配的标签")
|
})
|
||||||
|
if (tagIds.length === 0) return failure(c, 404, "no-tags", "没有匹配的标签")
|
||||||
|
|
||||||
const problemIds = problems.map((problem) => problem.id)
|
const problemIds = problems.map((problem) => problem.id)
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
if (parsed.data.action === "remove") {
|
if (parsed.data.action === "remove") {
|
||||||
await tx.delete(schema.problemTags).where(and(
|
await tx
|
||||||
inArray(schema.problemTags.problemId, problemIds),
|
.delete(schema.problemTags)
|
||||||
inArray(schema.problemTags.problemtagId, tagIds),
|
.where(
|
||||||
))
|
and(
|
||||||
return
|
inArray(schema.problemTags.problemId, problemIds),
|
||||||
}
|
inArray(schema.problemTags.problemtagId, tagIds),
|
||||||
const existing = await tx.select().from(schema.problemTags).where(and(
|
),
|
||||||
inArray(schema.problemTags.problemId, problemIds),
|
)
|
||||||
inArray(schema.problemTags.problemtagId, tagIds),
|
return
|
||||||
))
|
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
}
|
const existing = await tx
|
||||||
if (rows.length) await tx.insert(schema.problemTags).values(rows)
|
.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 rows = []
|
||||||
|
for (const problemId of problemIds) {
|
||||||
|
for (const tagId of tagIds) {
|
||||||
|
if (!have.has(`${problemId}:${tagId}`))
|
||||||
|
rows.push({ problemId, problemtagId: tagId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rows.length) await tx.insert(schema.problemTags).values(rows)
|
||||||
|
})
|
||||||
|
|
||||||
return success(c, batchProblemTagResponseSchema.parse({
|
return success(c, {
|
||||||
problemCount: problems.length,
|
problemCount: problems.length,
|
||||||
tagCount: tagIds.length,
|
tagCount: tagIds.length,
|
||||||
}))
|
} satisfies BatchProblemTagResponse)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 题目可见性
|
// ---------------------------------------------------------------- 题目可见性
|
||||||
|
|
||||||
adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (c) => {
|
adminTagRoutes.put(
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
"/problems/:id/visibility",
|
||||||
const [problem] = await db.select({ id: schema.problem.id, visible: schema.problem.visible, createdById: schema.problem.createdById })
|
requireProblemPermission,
|
||||||
.from(schema.problem).where(eq(schema.problem.id, id)).limit(1)
|
async (c) => {
|
||||||
// 旧后端这里的 `self.error(...)` 少写了 return,题目不存在时会继续往下跑并抛
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
// AttributeError(500)。这里正常返回 404。
|
const [problem] = await db
|
||||||
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
|
.select({
|
||||||
const user = c.get("user")!
|
id: schema.problem.id,
|
||||||
if (!canManageAllProblems(user) && problem.createdById !== user.id) {
|
visible: schema.problem.visible,
|
||||||
return failure(c, 404, "problem-not-found", "题目不存在")
|
createdById: schema.problem.createdById,
|
||||||
}
|
})
|
||||||
await db.update(schema.problem).set({ visible: !problem.visible }).where(eq(schema.problem.id, id))
|
.from(schema.problem)
|
||||||
return success(c, { visible: !problem.visible })
|
.where(eq(schema.problem.id, id))
|
||||||
})
|
.limit(1)
|
||||||
|
// 旧后端这里的 `self.error(...)` 少写了 return,题目不存在时会继续往下跑并抛
|
||||||
|
// AttributeError(500)。这里正常返回 404。
|
||||||
|
if (!problem) return failure(c, 404, "problem-not-found", "题目不存在")
|
||||||
|
const user = c.get("user")!
|
||||||
|
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))
|
||||||
|
return success(c, { visible: !problem.visible })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 卡点题目 / AC 趋势
|
// ---------------------------------------------------------------- 卡点题目 / AC 趋势
|
||||||
|
|
||||||
@@ -185,65 +311,127 @@ adminTagRoutes.put("/problems/:id/visibility", requireProblemPermission, async (
|
|||||||
// requireTeacher,而且完全没有报错。换个前缀,结构上就不可能再被遮蔽。
|
// requireTeacher,而且完全没有报错。换个前缀,结构上就不可能再被遮蔽。
|
||||||
adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
|
adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
|
||||||
const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})`
|
const failedFilter = sql`filter (where ${inArray(schema.submission.result, FAILED)})`
|
||||||
const rows = await db.select({
|
const rows = await db
|
||||||
displayId: schema.problem.displayId,
|
.select({
|
||||||
title: schema.problem.title,
|
displayId: schema.problem.displayId,
|
||||||
total: count(),
|
title: schema.problem.title,
|
||||||
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number),
|
total: count(),
|
||||||
failed: sql<number>`count(*) ${failedFilter}`.mapWith(Number),
|
accepted:
|
||||||
failedUsers: sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(Number),
|
sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(
|
||||||
}).from(schema.submission)
|
Number,
|
||||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
),
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
/**
|
||||||
|
* 只看公共题,和隔壁 ac-trend 同一个口径。原来这里一个 where 都没有,比赛题
|
||||||
|
* 也进榜 —— 而比赛题的题号是每场比赛各自从 1 开始编的(快照里 61 道不同的题
|
||||||
|
* 都叫「1」),一旦挤进前 40,那一行显示的题号会指向一道根本不存在的公共题。
|
||||||
|
*
|
||||||
|
* 眼下还没发生:前 40 的门槛是 97 人卡住,比赛题最多的一道是 59 人。但两个班
|
||||||
|
* 一起考的场次有 95 人,撞上一道难题就够得着了。
|
||||||
|
*
|
||||||
|
* 加了这条对公共题的数字**没有任何影响**:比赛提交挂的是比赛自己的 problem 行
|
||||||
|
* (快照实测两个方向的交叉都是 0 条),公共题那一行本来就只统计自己的提交。
|
||||||
|
* 顺带让这条查询能用上 0013 的 submission_public_metrics_idx,173ms → 82ms。
|
||||||
|
*/
|
||||||
|
.where(isNull(schema.submission.contestId))
|
||||||
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
|
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
|
||||||
.having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`)
|
.having(
|
||||||
.orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`))
|
sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`,
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`),
|
||||||
|
)
|
||||||
.limit(40)
|
.limit(40)
|
||||||
return success(c, rows.map((row) => stuckProblemSchema.parse({
|
return success(
|
||||||
problemId: row.displayId,
|
c,
|
||||||
problemTitle: row.title,
|
rows.map(
|
||||||
total: row.total,
|
(row) =>
|
||||||
failed: row.failed,
|
({
|
||||||
failedUsers: row.failedUsers,
|
problemId: row.displayId,
|
||||||
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
|
adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = localYear()
|
||||||
// 参数按旧后端的口径夹逼:越界一律回落到默认值,不报错
|
// 参数按旧后端的口径夹逼:越界一律回落到默认值,不报错
|
||||||
let sinceYear = queryInteger(c.req.query("sinceYear"), 2023)
|
let sinceYear = queryInteger(c.req.query("sinceYear"), 2023)
|
||||||
if (sinceYear < 2022 || sinceYear > currentYear) sinceYear = 2023
|
if (sinceYear < 2022 || sinceYear > currentYear) sinceYear = 2023
|
||||||
let untilYear = queryInteger(c.req.query("untilYear"), currentYear)
|
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)
|
let minPerYear = queryInteger(c.req.query("minPerYear"), 100)
|
||||||
if (![50, 100, 200].includes(minPerYear)) minPerYear = 100
|
if (![50, 100, 200].includes(minPerYear)) minPerYear = 100
|
||||||
|
|
||||||
const year = sql<number>`extract(year from ${schema.submission.createTime})`.mapWith(Number)
|
// 年份按东八区切,和上面 `currentYear` 的夹逼同口径
|
||||||
const rows = await db.select({
|
const year =
|
||||||
problemId: schema.problem.id,
|
sql<number>`extract(year from ${localTime(schema.submission.createTime)})`.mapWith(
|
||||||
displayId: schema.problem.displayId,
|
Number,
|
||||||
title: schema.problem.title,
|
)
|
||||||
year,
|
const rows = await db
|
||||||
total: count(),
|
.select({
|
||||||
accepted: sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(Number),
|
problemId: schema.problem.id,
|
||||||
}).from(schema.submission)
|
displayId: schema.problem.displayId,
|
||||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
title: schema.problem.title,
|
||||||
.where(and(
|
year,
|
||||||
isNull(schema.submission.contestId),
|
total: count(),
|
||||||
gte(sql`extract(year from ${schema.submission.createTime})`, sinceYear),
|
accepted:
|
||||||
lte(sql`extract(year from ${schema.submission.createTime})`, untilYear),
|
sql<number>`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED)})`.mapWith(
|
||||||
))
|
Number,
|
||||||
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title, year)
|
),
|
||||||
|
})
|
||||||
|
.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,
|
||||||
|
)
|
||||||
.orderBy(asc(schema.problem.id), asc(year))
|
.orderBy(asc(schema.problem.id), asc(year))
|
||||||
|
|
||||||
const required = new Set<number>()
|
const required = new Set<number>()
|
||||||
for (let y = sinceYear; y <= untilYear; y += 1) required.add(y)
|
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) {
|
for (const row of rows) {
|
||||||
const bucket = grouped.get(row.problemId)
|
const bucket = grouped.get(row.problemId)
|
||||||
if (bucket) bucket.yearly.push(row)
|
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 = []
|
const result = []
|
||||||
@@ -252,7 +440,7 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
|
|||||||
// 每一年都得有数据,且每年提交量都超过门槛 —— 否则趋势没有可比性
|
// 每一年都得有数据,且每年提交量都超过门槛 —— 否则趋势没有可比性
|
||||||
if (![...required].every((y) => years.has(y))) continue
|
if (![...required].every((y) => years.has(y))) continue
|
||||||
if (!entry.yearly.every((row) => row.total > minPerYear)) continue
|
if (!entry.yearly.every((row) => row.total > minPerYear)) continue
|
||||||
result.push(acTrendSchema.parse({
|
result.push({
|
||||||
problemId: entry.displayId,
|
problemId: entry.displayId,
|
||||||
problemTitle: entry.title,
|
problemTitle: entry.title,
|
||||||
yearly: entry.yearly
|
yearly: entry.yearly
|
||||||
@@ -263,27 +451,34 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
|
|||||||
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
|
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
|
||||||
}))
|
}))
|
||||||
.sort((left, right) => left.year - right.year),
|
.sort((left, right) => left.year - right.year),
|
||||||
}))
|
} satisfies AcTrend)
|
||||||
}
|
}
|
||||||
return success(c, result)
|
return success(c, result)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Python → 流程图
|
// ---------------------------------------------------------------- Python → 流程图
|
||||||
|
|
||||||
adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) => {
|
adminTagRoutes.post(
|
||||||
const parsed = generateFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null))
|
"/problems/flowchart",
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "python 代码不能为空")
|
requireProblemPermission,
|
||||||
try {
|
async (c) => {
|
||||||
const flowchart = await completeChat(
|
const parsed = generateFlowchartRequestSchema.safeParse(
|
||||||
`你是一个可以将Python代码转换为mermaid的助手。
|
await c.req.json().catch(() => null),
|
||||||
|
)
|
||||||
|
if (!parsed.success)
|
||||||
|
return failure(c, 400, "invalid-request", "python 代码不能为空")
|
||||||
|
try {
|
||||||
|
const flowchart = await completeChat(
|
||||||
|
`你是一个可以将Python代码转换为mermaid的助手。
|
||||||
请将用户提供的Python代码转换为 Mermaid 纯文本。
|
请将用户提供的Python代码转换为 Mermaid 纯文本。
|
||||||
注意括号内的内容用引号包裹,如果本身就有引号,请注意双引号和单引号的问题。
|
注意括号内的内容用引号包裹,如果本身就有引号,请注意双引号和单引号的问题。
|
||||||
请只返回 mermaid 代码,连 \`\`\` 都不需要。`,
|
请只返回 mermaid 代码,连 \`\`\` 都不需要。`,
|
||||||
parsed.data.python,
|
parsed.data.python,
|
||||||
)
|
)
|
||||||
return success(c, generateFlowchartResponseSchema.parse({ flowchart }))
|
return success(c, { flowchart } satisfies GenerateFlowchartResponse)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Flowchart generation failed", error)
|
console.error("Flowchart generation failed", error)
|
||||||
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
|
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
adminExerciseSchema,
|
|
||||||
adminTutorialGroupsSchema,
|
|
||||||
adminTutorialSchema,
|
|
||||||
createExerciseRequestSchema,
|
createExerciseRequestSchema,
|
||||||
createTutorialRequestSchema,
|
createTutorialRequestSchema,
|
||||||
setTutorialVisibilityRequestSchema,
|
setTutorialVisibilityRequestSchema,
|
||||||
updateExerciseRequestSchema,
|
updateExerciseRequestSchema,
|
||||||
updateTutorialRequestSchema,
|
updateTutorialRequestSchema,
|
||||||
|
type AdminExercise,
|
||||||
|
type AdminTutorial,
|
||||||
|
type AdminTutorialGroups,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { asc, desc, eq } from "drizzle-orm"
|
import { asc, desc, eq } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
@@ -14,6 +14,7 @@ import { Hono } from "hono"
|
|||||||
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
import { requireSuperAdmin, type AppEnv } from "../../auth/middleware"
|
||||||
import { db, schema } from "../../db"
|
import { db, schema } from "../../db"
|
||||||
import { failure, success } from "../../http"
|
import { failure, success } from "../../http"
|
||||||
|
import { exerciseDataError } from "../../services/exercise"
|
||||||
import { objectValue, queryInteger, sampleUser } from "../helpers"
|
import { objectValue, queryInteger, sampleUser } from "../helpers"
|
||||||
|
|
||||||
export const adminTutorialRoutes = new Hono<AppEnv>()
|
export const adminTutorialRoutes = new Hono<AppEnv>()
|
||||||
@@ -23,7 +24,7 @@ function serializeTutorial(row: {
|
|||||||
user: typeof schema.user.$inferSelect
|
user: typeof schema.user.$inferSelect
|
||||||
realName: string | null
|
realName: string | null
|
||||||
}) {
|
}) {
|
||||||
return adminTutorialSchema.parse({
|
return {
|
||||||
id: row.tutorial.id,
|
id: row.tutorial.id,
|
||||||
title: row.tutorial.title,
|
title: row.tutorial.title,
|
||||||
content: row.tutorial.content,
|
content: row.tutorial.content,
|
||||||
@@ -34,12 +35,16 @@ function serializeTutorial(row: {
|
|||||||
createdBy: sampleUser(row.user, row.realName),
|
createdBy: sampleUser(row.user, row.realName),
|
||||||
createdAt: row.tutorial.createdAt,
|
createdAt: row.tutorial.createdAt,
|
||||||
updatedAt: row.tutorial.updatedAt,
|
updatedAt: row.tutorial.updatedAt,
|
||||||
})
|
} satisfies AdminTutorial
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectTutorial(id: number) {
|
function selectTutorial(id: number) {
|
||||||
return db
|
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)
|
.from(schema.tutorial)
|
||||||
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
@@ -49,137 +54,222 @@ function selectTutorial(id: number) {
|
|||||||
|
|
||||||
adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
|
adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
|
||||||
const rows = await db
|
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)
|
.from(schema.tutorial)
|
||||||
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
.innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
.orderBy(asc(schema.tutorial.order), desc(schema.tutorial.createdAt))
|
.orderBy(asc(schema.tutorial.order), desc(schema.tutorial.createdAt))
|
||||||
const all = rows.map(serializeTutorial)
|
const all = rows.map(serializeTutorial)
|
||||||
// 分组返回,形状对齐旧 TutorialAdminAPI.get;列表 schema omit 掉了 content/code,Zod 会 strip
|
// 分组返回,形状对齐旧 TutorialAdminAPI.get;列表 schema omit 掉了 content/code,Zod 会 strip
|
||||||
return success(c, adminTutorialGroupsSchema.parse({
|
return success(c, {
|
||||||
python: all.filter((item) => item.type === "python"),
|
python: all.filter((item) => item.type === "python"),
|
||||||
c: all.filter((item) => item.type === "c"),
|
c: all.filter((item) => item.type === "c"),
|
||||||
}))
|
} satisfies AdminTutorialGroups)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminTutorialRoutes.post("/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) {
|
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 now = new Date().toISOString()
|
||||||
const [created] = await db.insert(schema.tutorial).values({
|
const [created] = await db
|
||||||
...parsed.data,
|
.insert(schema.tutorial)
|
||||||
createdAt: now,
|
.values({
|
||||||
updatedAt: now,
|
...parsed.data,
|
||||||
createdById: c.get("user")!.id,
|
createdAt: now,
|
||||||
}).returning({ id: schema.tutorial.id })
|
updatedAt: now,
|
||||||
|
createdById: c.get("user")!.id,
|
||||||
|
})
|
||||||
|
.returning({ id: schema.tutorial.id })
|
||||||
const [row] = await selectTutorial(created!.id)
|
const [row] = await selectTutorial(created!.id)
|
||||||
return success(c, serializeTutorial(row!), 201)
|
return success(c, serializeTutorial(row!), 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => {
|
adminTutorialRoutes.get("/tutorials/:id", requireSuperAdmin, async (c) => {
|
||||||
const [row] = await selectTutorial(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
const [row] = await selectTutorial(
|
||||||
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
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))
|
return success(c, serializeTutorial(row))
|
||||||
})
|
})
|
||||||
|
|
||||||
adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => {
|
adminTutorialRoutes.put("/tutorials/:id", requireSuperAdmin, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
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) {
|
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() })
|
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
|
||||||
.where(eq(schema.tutorial.id, id)).returning({ id: schema.tutorial.id })
|
.where(eq(schema.tutorial.id, id))
|
||||||
if (updated.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
.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)
|
const [row] = await selectTutorial(id)
|
||||||
return success(c, serializeTutorial(row!))
|
return success(c, serializeTutorial(row!))
|
||||||
})
|
})
|
||||||
|
|
||||||
adminTutorialRoutes.put("/tutorials/:id/visibility", requireSuperAdmin, async (c) => {
|
adminTutorialRoutes.put(
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
"/tutorials/:id/visibility",
|
||||||
const parsed = setTutorialVisibilityRequestSchema.safeParse(await c.req.json().catch(() => null))
|
requireSuperAdmin,
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "isPublic is required")
|
async (c) => {
|
||||||
// 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const updated = await db.update(schema.tutorial)
|
const parsed = setTutorialVisibilityRequestSchema.safeParse(
|
||||||
.set({ isPublic: parsed.data.isPublic })
|
await c.req.json().catch(() => null),
|
||||||
.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")
|
if (!parsed.success)
|
||||||
const [row] = await selectTutorial(id)
|
return failure(c, 400, "invalid-request", "isPublic is required")
|
||||||
return success(c, serializeTutorial(row!))
|
// 只改可见性,不动 updatedAt —— 上下架不是内容修改,改了会打乱按更新时间排序的直觉
|
||||||
})
|
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")
|
||||||
|
const [row] = await selectTutorial(id)
|
||||||
|
return success(c, serializeTutorial(row!))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
|
adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
// 必须先删练习。Django 的 on_delete=CASCADE 是**应用层**实现的,
|
// 练习与学习留痕都随教程一起没:exercise.tutorial_id 与 tutorial_progress.tutorial_id
|
||||||
// 库里的外键实际是 NO ACTION(已核对 pg_constraint.confdeltype='a'),
|
// 都是库级 CASCADE。**加子表时要回来想一遍该 CASCADE 还是该拦住**,
|
||||||
// 直接删教程会撞外键约束、变成 500。后台每个 DELETE 都要照此核一遍子表。
|
// 别默认新表会自己连坐 —— 0010 只改了当时存在的那批外键。
|
||||||
const deleted = await db.transaction(async (tx) => {
|
const deleted = await db
|
||||||
await tx.delete(schema.exercise).where(eq(schema.exercise.tutorialId, id))
|
.delete(schema.tutorial)
|
||||||
return tx.delete(schema.tutorial).where(eq(schema.tutorial.id, id))
|
.where(eq(schema.tutorial.id, id))
|
||||||
.returning({ id: schema.tutorial.id })
|
.returning({ id: schema.tutorial.id })
|
||||||
})
|
if (deleted.length === 0)
|
||||||
if (deleted.length === 0) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||||
return success(c, null)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 练习
|
// ---------------------------------------------------------------- 练习
|
||||||
|
|
||||||
function serializeExercise(row: typeof schema.exercise.$inferSelect) {
|
function serializeExercise(row: typeof schema.exercise.$inferSelect) {
|
||||||
return adminExerciseSchema.parse({
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
data: objectValue(row.data),
|
data: objectValue(row.data),
|
||||||
order: row.order,
|
order: row.order,
|
||||||
})
|
} satisfies AdminExercise
|
||||||
}
|
}
|
||||||
|
|
||||||
// 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数,
|
// 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数,
|
||||||
// 但它本来就是一对多的从属关系,嵌套路径更贴事实,也省掉「忘了传 tutorial_id」这类错误
|
// 但它本来就是一对多的从属关系,嵌套路径更贴事实,也省掉「忘了传 tutorial_id」这类错误
|
||||||
adminTutorialRoutes.get("/tutorials/:id/exercises", requireSuperAdmin, async (c) => {
|
adminTutorialRoutes.get(
|
||||||
const rows = await db.select().from(schema.exercise)
|
"/tutorials/:id/exercises",
|
||||||
.where(eq(schema.exercise.tutorialId, queryInteger(c.req.param("id"), 0, { min: 1 })))
|
requireSuperAdmin,
|
||||||
.orderBy(asc(schema.exercise.order), asc(schema.exercise.id))
|
async (c) => {
|
||||||
return success(c, rows.map(serializeExercise))
|
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) => {
|
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) {
|
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)
|
const [tutorial] = await db
|
||||||
.where(eq(schema.tutorial.id, parsed.data.tutorialId)).limit(1)
|
.select({ id: schema.tutorial.id })
|
||||||
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
.from(schema.tutorial)
|
||||||
const [created] = await db.insert(schema.exercise).values({
|
.where(eq(schema.tutorial.id, parsed.data.tutorialId))
|
||||||
tutorialId: parsed.data.tutorialId,
|
.limit(1)
|
||||||
type: parsed.data.type,
|
if (!tutorial)
|
||||||
data: parsed.data.data,
|
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||||
order: parsed.data.order,
|
const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
|
||||||
createdAt: new Date().toISOString(),
|
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
|
||||||
}).returning()
|
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()
|
||||||
return success(c, serializeExercise(created!), 201)
|
return success(c, serializeExercise(created!), 201)
|
||||||
})
|
})
|
||||||
|
|
||||||
adminTutorialRoutes.put("/exercises/:id", requireSuperAdmin, async (c) => {
|
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) {
|
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.exercise)
|
const dataError = exerciseDataError(parsed.data.type, parsed.data.data)
|
||||||
.set({ type: parsed.data.type, data: parsed.data.data, order: parsed.data.order })
|
if (dataError) return failure(c, 400, "invalid-exercise", dataError)
|
||||||
.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()
|
.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))
|
return success(c, serializeExercise(updated))
|
||||||
})
|
})
|
||||||
|
|
||||||
adminTutorialRoutes.delete("/exercises/:id", requireSuperAdmin, async (c) => {
|
adminTutorialRoutes.delete("/exercises/:id", requireSuperAdmin, async (c) => {
|
||||||
const deleted = await db.delete(schema.exercise)
|
const deleted = await db
|
||||||
.where(eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })))
|
.delete(schema.exercise)
|
||||||
|
.where(
|
||||||
|
eq(schema.exercise.id, queryInteger(c.req.param("id"), 0, { min: 1 })),
|
||||||
|
)
|
||||||
.returning({ id: schema.exercise.id })
|
.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)
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|||||||
+998
-184
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
classComparisonRequestSchema,
|
classComparisonRequestSchema,
|
||||||
classComparisonResponseSchema,
|
STUDENT_ROLES,
|
||||||
classComparisonSchema,
|
type ClassComparison,
|
||||||
classRankItemSchema,
|
type ClassComparisonResponse,
|
||||||
classUserRankSchema,
|
type ClassRankItem,
|
||||||
|
type ClassUserRank,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, eq, gte, inArray, lte, sql } from "drizzle-orm"
|
import { and, eq, gte, inArray, like, lte, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { requireAuth, type AppEnv } from "../auth/middleware"
|
import { requireAuth, type AppEnv } from "../auth/middleware"
|
||||||
@@ -24,32 +25,49 @@ interface ClassUser {
|
|||||||
submissionNumber: number
|
submissionNumber: number
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadClassUsers(classNames?: string[]) {
|
/**
|
||||||
|
* 入班学生的 AC/提交数。`gradePrefix` 是年级(班号形如 `241` = 24 级 1 班),
|
||||||
|
* 走 SQL 的 like 而不是拉全表再在内存里 startsWith —— 班级榜每换一次年级就要跑一遍,
|
||||||
|
* 没必要每次都把全校一千多号人搬进进程。年级在调用处已校验为纯数字,不含 like 通配符。
|
||||||
|
*/
|
||||||
|
async function loadClassUsers(classNames?: string[], gradePrefix?: string) {
|
||||||
const filters = [
|
const filters = [
|
||||||
eq(schema.user.isDisabled, false),
|
eq(schema.user.isDisabled, false),
|
||||||
inArray(schema.user.adminType, ["Regular User", "Student Admin"]),
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
sql`${schema.user.className} is not null`,
|
sql`${schema.user.className} is not null`,
|
||||||
]
|
]
|
||||||
if (classNames) filters.push(inArray(schema.user.className, classNames))
|
if (classNames) filters.push(inArray(schema.user.className, classNames))
|
||||||
const rows = await db.select({
|
if (gradePrefix) filters.push(like(schema.user.className, `${gradePrefix}%`))
|
||||||
userId: schema.user.id,
|
const rows = await db
|
||||||
username: schema.user.username,
|
.select({
|
||||||
className: schema.user.className,
|
userId: schema.user.id,
|
||||||
acceptedNumber: schema.userProfile.acceptedNumber,
|
username: schema.user.username,
|
||||||
submissionNumber: schema.userProfile.submissionNumber,
|
className: schema.user.className,
|
||||||
}).from(schema.user).innerJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(and(...filters))
|
acceptedNumber: schema.userProfile.acceptedNumber,
|
||||||
|
submissionNumber: schema.userProfile.submissionNumber,
|
||||||
|
})
|
||||||
|
.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)
|
return rows.filter((row): row is ClassUser => row.className !== null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function mean(values: number[]) {
|
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[]) {
|
function median(values: number[]) {
|
||||||
if (!values.length) return 0
|
if (!values.length) return 0
|
||||||
const sorted = [...values].sort((a, b) => a - b)
|
const sorted = [...values].sort((a, b) => a - b)
|
||||||
const middle = Math.floor(sorted.length / 2)
|
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) {
|
function quantile(values: number[], p: number) {
|
||||||
@@ -66,35 +84,59 @@ function quantile(values: number[], p: number) {
|
|||||||
function sampleStdDev(values: number[]) {
|
function sampleStdDev(values: number[]) {
|
||||||
if (values.length <= 1) return 0
|
if (values.length <= 1) return 0
|
||||||
const average = mean(values)
|
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) => {
|
classroomRoutes.get("/rankings/classes", async (c) => {
|
||||||
const grade = c.req.query("grade")?.trim()
|
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))
|
||||||
const users = (await loadClassUsers()).filter((user) => user.className.startsWith(grade))
|
return failure(c, 400, "invalid-grade", "grade is required")
|
||||||
|
const users = await loadClassUsers(undefined, grade)
|
||||||
const groups = new Map<string, ClassUser[]>()
|
const groups = new Map<string, ClassUser[]>()
|
||||||
for (const user of users) groups.set(user.className, [...(groups.get(user.className) ?? []), user])
|
for (const user of users)
|
||||||
const result = [...groups].map(([className, members]) => {
|
groups.set(user.className, [...(groups.get(user.className) ?? []), user])
|
||||||
const totalAc = members.reduce((sum, member) => sum + member.acceptedNumber, 0)
|
const result = [...groups]
|
||||||
const totalSubmission = members.reduce((sum, member) => sum + member.submissionNumber, 0)
|
.map(([className, members]) => {
|
||||||
return {
|
const totalAc = members.reduce(
|
||||||
className,
|
(sum, member) => sum + member.acceptedNumber,
|
||||||
userCount: members.length,
|
0,
|
||||||
totalAc,
|
)
|
||||||
totalSubmission,
|
const totalSubmission = members.reduce(
|
||||||
avgAc: rounded(totalAc / members.length),
|
(sum, member) => sum + member.submissionNumber,
|
||||||
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0,
|
0,
|
||||||
}
|
)
|
||||||
}).sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission)
|
return {
|
||||||
return success(c, result.map((item, index) => classRankItemSchema.parse({ ...item, rank: index + 1 })))
|
className,
|
||||||
|
userCount: members.length,
|
||||||
|
totalAc,
|
||||||
|
totalSubmission,
|
||||||
|
avgAc: rounded(totalAc / members.length),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
|
classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
|
||||||
const user = c.get("user")!
|
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(
|
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) => ({
|
const ranks = members.map((member, index) => ({
|
||||||
userId: member.userId,
|
userId: member.userId,
|
||||||
@@ -114,35 +156,64 @@ classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
|
|||||||
const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10)
|
const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10)
|
||||||
selected = ranks.slice(start, start + 10)
|
selected = ranks.slice(start, start + 10)
|
||||||
}
|
}
|
||||||
return success(c, classUserRankSchema.parse({ className: user.className, myRank, total: ranks.length, ranks: selected }))
|
return success(c, {
|
||||||
|
className: user.className,
|
||||||
|
myRank,
|
||||||
|
total: ranks.length,
|
||||||
|
ranks: selected,
|
||||||
|
} satisfies ClassUserRank)
|
||||||
})
|
})
|
||||||
|
|
||||||
classroomRoutes.post("/classes/comparison", async (c) => {
|
classroomRoutes.post("/classes/comparison", async (c) => {
|
||||||
const parsed = classComparisonRequestSchema.safeParse(await c.req.json().catch(() => null))
|
const parsed = classComparisonRequestSchema.safeParse(
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "At least one class is required")
|
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 users = await loadClassUsers(parsed.data.classNames)
|
||||||
const allAc = users.map((user) => user.acceptedNumber)
|
const allAc = users.map((user) => user.acceptedNumber)
|
||||||
const globalQ1 = quantile(allAc, 0.25)
|
const globalQ1 = quantile(allAc, 0.25)
|
||||||
const globalQ3 = quantile(allAc, 0.75)
|
const globalQ3 = quantile(allAc, 0.75)
|
||||||
const byClass = new Map<string, ClassUser[]>()
|
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 recentByUser = new Map<number, Set<number>>()
|
||||||
let recentSubmissionCount = new Map<string, number>()
|
let recentSubmissionCount = new Map<string, number>()
|
||||||
const hasTimeRange = Boolean(parsed.data.startTime && parsed.data.endTime)
|
const hasTimeRange = Boolean(parsed.data.startTime && parsed.data.endTime)
|
||||||
if (hasTimeRange) {
|
if (hasTimeRange) {
|
||||||
const rows = await db.select({ userId: schema.submission.userId, problemId: schema.submission.problemId, result: schema.submission.result })
|
const rows = await db
|
||||||
.from(schema.submission).where(and(
|
.select({
|
||||||
inArray(schema.submission.userId, users.map((user) => user.userId)),
|
userId: schema.submission.userId,
|
||||||
gte(schema.submission.createTime, parsed.data.startTime!),
|
problemId: schema.submission.problemId,
|
||||||
lte(schema.submission.createTime, parsed.data.endTime!),
|
result: schema.submission.result,
|
||||||
))
|
})
|
||||||
const userClass = new Map(users.map((user) => [user.userId, user.className]))
|
.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]),
|
||||||
|
)
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const className = userClass.get(row.userId)
|
const className = userClass.get(row.userId)
|
||||||
if (!className) continue
|
if (!className) continue
|
||||||
recentSubmissionCount.set(className, (recentSubmissionCount.get(className) ?? 0) + 1)
|
recentSubmissionCount.set(
|
||||||
if ([JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED].includes(row.result as 0 | 10)) {
|
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>()
|
const set = recentByUser.get(row.userId) ?? new Set<number>()
|
||||||
set.add(row.problemId)
|
set.add(row.problemId)
|
||||||
recentByUser.set(row.userId, set)
|
recentByUser.set(row.userId, set)
|
||||||
@@ -151,15 +222,20 @@ classroomRoutes.post("/classes/comparison", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const comparisons = [...byClass].map(([className, members]) => {
|
const comparisons = [...byClass].map(([className, members]) => {
|
||||||
const ac = members.map((member) => member.acceptedNumber).sort((a, b) => b - a)
|
const ac = members
|
||||||
const submissions = members.map((member) => member.submissionNumber).sort((a, b) => b - a)
|
.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 userCount = members.length
|
||||||
const topCount = Math.max(1, Math.ceil(userCount * 0.1))
|
const topCount = Math.max(1, Math.ceil(userCount * 0.1))
|
||||||
const bottomCount = topCount
|
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 totalAc = ac.reduce((sum, value) => sum + value, 0)
|
||||||
const totalSubmission = submissions.reduce((sum, value) => sum + value, 0)
|
const totalSubmission = submissions.reduce((sum, value) => sum + value, 0)
|
||||||
const base: Record<string, number | string> = {
|
const base: ClassComparison = {
|
||||||
className,
|
className,
|
||||||
userCount,
|
userCount,
|
||||||
totalAc,
|
totalAc,
|
||||||
@@ -173,38 +249,51 @@ classroomRoutes.post("/classes/comparison", async (c) => {
|
|||||||
top10Avg: rounded(mean(ac.slice(0, topCount))),
|
top10Avg: rounded(mean(ac.slice(0, topCount))),
|
||||||
middle80Avg: rounded(mean(middle)),
|
middle80Avg: rounded(mean(middle)),
|
||||||
bottom10Avg: rounded(mean(ac.slice(-bottomCount))),
|
bottom10Avg: rounded(mean(ac.slice(-bottomCount))),
|
||||||
excellentRate: rounded(ac.filter((value) => value >= globalQ3).length / userCount * 100),
|
excellentRate: rounded(
|
||||||
passRate: rounded(ac.filter((value) => value >= globalQ1).length / userCount * 100),
|
(ac.filter((value) => value >= globalQ3).length / userCount) * 100,
|
||||||
activeRate: rounded(submissions.filter((value) => value > 0).length / userCount * 100),
|
),
|
||||||
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0,
|
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,
|
compositeScore: 0,
|
||||||
}
|
}
|
||||||
if (hasTimeRange) {
|
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.recentTotalAc = recent.reduce((sum, value) => sum + value, 0)
|
||||||
base.recentTotalSubmission = recentSubmissionCount.get(className) ?? 0
|
base.recentTotalSubmission = recentSubmissionCount.get(className) ?? 0
|
||||||
base.recentAvgAc = rounded(mean(recent))
|
base.recentAvgAc = rounded(mean(recent))
|
||||||
base.recentMedianAc = rounded(median(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
|
base.recentActiveCount = recent.filter((value) => value > 0).length
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
})
|
})
|
||||||
const maxMedian = Math.max(1, ...comparisons.map((item) => Number(item.medianAc)))
|
const maxMedian = Math.max(1, ...comparisons.map((item) => item.medianAc))
|
||||||
const maxMiddle = Math.max(1, ...comparisons.map((item) => Number(item.middle80Avg)))
|
const maxMiddle = Math.max(1, ...comparisons.map((item) => item.middle80Avg))
|
||||||
for (const item of comparisons) {
|
for (const item of comparisons) {
|
||||||
item.compositeScore = rounded(
|
item.compositeScore = rounded(
|
||||||
0.4 * (Number(item.medianAc) / maxMedian * 100) +
|
0.4 * ((item.medianAc / maxMedian) * 100) +
|
||||||
0.15 * (Number(item.middle80Avg) / maxMiddle * 100) +
|
0.15 * ((item.middle80Avg / maxMiddle) * 100) +
|
||||||
0.2 * Number(item.activeRate) +
|
0.2 * item.activeRate +
|
||||||
0.15 * Number(item.passRate) +
|
0.15 * item.passRate +
|
||||||
0.1 * Number(item.excellentRate),
|
0.1 * item.excellentRate,
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
comparisons.sort((a, b) => Number(b.compositeScore) - Number(a.compositeScore) || Number(b.medianAc) - Number(a.medianAc))
|
comparisons.sort(
|
||||||
return success(c, classComparisonResponseSchema.parse({
|
(a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc,
|
||||||
comparisons: comparisons.map((item) => classComparisonSchema.parse(item)),
|
)
|
||||||
|
return success(c, {
|
||||||
|
comparisons,
|
||||||
hasTimeRange,
|
hasTimeRange,
|
||||||
}))
|
} satisfies ClassComparisonResponse)
|
||||||
})
|
})
|
||||||
|
|||||||
+492
-113
@@ -1,19 +1,24 @@
|
|||||||
import {
|
import {
|
||||||
announcementListItemSchema,
|
|
||||||
announcementListSchema,
|
|
||||||
announcementSchema,
|
|
||||||
createMessageRequestSchema,
|
createMessageRequestSchema,
|
||||||
exerciseSchema,
|
|
||||||
messageListSchema,
|
|
||||||
messageSchema,
|
|
||||||
reactionKeySchema,
|
|
||||||
reactionStateSchema,
|
|
||||||
setReactionRequestSchema,
|
|
||||||
embeddedSubmissionSchema,
|
embeddedSubmissionSchema,
|
||||||
tutorialSchema,
|
exerciseAttemptRequestSchema,
|
||||||
tutorialSummarySchema,
|
reactionKeySchema,
|
||||||
|
setReactionRequestSchema,
|
||||||
|
tutorialProgressPingSchema,
|
||||||
|
type Announcement,
|
||||||
|
type AnnouncementList,
|
||||||
|
type AnnouncementListItem,
|
||||||
|
type EmbeddedSubmission,
|
||||||
|
type Exercise,
|
||||||
|
type Message,
|
||||||
|
type MessageList,
|
||||||
|
type ReactionCounts,
|
||||||
|
type ReactionState,
|
||||||
|
type Tutorial,
|
||||||
|
type TutorialProgress,
|
||||||
|
type TutorialSummary,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, count, desc, eq, inArray } from "drizzle-orm"
|
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
|
import { requireAuth, requireSuperAdmin, type AppEnv } from "../auth/middleware"
|
||||||
@@ -28,35 +33,76 @@ contentRoutes.get("/announcements", async (c) => {
|
|||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.announcement).where(eq(schema.announcement.visible, true)),
|
db
|
||||||
db.select({ announcement: schema.announcement, user: schema.user, realName: schema.userProfile.realName })
|
.select({ value: count() })
|
||||||
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
.from(schema.announcement)
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
.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))
|
.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, announcementListSchema.parse({
|
return success(c, {
|
||||||
results: rows.map(({ announcement, user, realName }) => announcementListItemSchema.parse({
|
results: rows.map(
|
||||||
id: announcement.id,
|
({ announcement, user, realName }) =>
|
||||||
title: announcement.title,
|
({
|
||||||
tag: announcement.tag,
|
id: announcement.id,
|
||||||
top: announcement.top,
|
title: announcement.title,
|
||||||
createdBy: sampleUser(user, realName),
|
tag: announcement.tag,
|
||||||
createTime: announcement.createTime,
|
top: announcement.top,
|
||||||
lastUpdateTime: announcement.lastUpdateTime,
|
createdBy: sampleUser(user, realName),
|
||||||
})),
|
createTime: announcement.createTime,
|
||||||
|
lastUpdateTime: announcement.lastUpdateTime,
|
||||||
|
}) satisfies AnnouncementListItem,
|
||||||
|
),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
}))
|
} satisfies AnnouncementList)
|
||||||
})
|
})
|
||||||
|
|
||||||
contentRoutes.get("/announcements/:id", async (c) => {
|
contentRoutes.get("/announcements/:id", async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
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 })
|
const [row] = await db
|
||||||
.from(schema.announcement).innerJoin(schema.user, eq(schema.announcement.createdById, schema.user.id))
|
.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))
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
.where(and(eq(schema.announcement.id, id), eq(schema.announcement.visible, true))).limit(1)
|
.where(
|
||||||
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
|
and(
|
||||||
return success(c, announcementSchema.parse({
|
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,
|
id: row.announcement.id,
|
||||||
title: row.announcement.title,
|
title: row.announcement.title,
|
||||||
tag: row.announcement.tag,
|
tag: row.announcement.tag,
|
||||||
@@ -65,7 +111,7 @@ contentRoutes.get("/announcements/:id", async (c) => {
|
|||||||
createdBy: sampleUser(row.user, row.realName),
|
createdBy: sampleUser(row.user, row.realName),
|
||||||
createTime: row.announcement.createTime,
|
createTime: row.announcement.createTime,
|
||||||
lastUpdateTime: row.announcement.lastUpdateTime,
|
lastUpdateTime: row.announcement.lastUpdateTime,
|
||||||
}))
|
} satisfies Announcement)
|
||||||
})
|
})
|
||||||
|
|
||||||
contentRoutes.get("/messages", requireAuth, async (c) => {
|
contentRoutes.get("/messages", requireAuth, async (c) => {
|
||||||
@@ -73,40 +119,64 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
|
|||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.message).where(eq(schema.message.recipientId, user.id)),
|
db
|
||||||
db.select({ message: schema.message, sender: schema.user, realName: schema.userProfile.realName, submission: schema.submission, displayId: schema.problem.displayId })
|
.select({ value: count() })
|
||||||
.from(schema.message).innerJoin(schema.user, eq(schema.message.senderId, schema.user.id))
|
.from(schema.message)
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
.where(eq(schema.message.recipientId, user.id)),
|
||||||
.innerJoin(schema.submission, eq(schema.message.submissionId, schema.submission.id))
|
db
|
||||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
.select({
|
||||||
.where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset),
|
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, messageListSchema.parse({
|
return success(c, {
|
||||||
results: rows.map(({ message, sender, realName, submission, displayId }) => messageSchema.parse({
|
results: rows.map(
|
||||||
id: message.id,
|
({ message, sender, realName, submission, displayId }) =>
|
||||||
sender: sampleUser(sender, realName),
|
({
|
||||||
createTime: message.createTime,
|
id: message.id,
|
||||||
message: message.message,
|
sender: sampleUser(sender, realName),
|
||||||
submission: embeddedSubmissionSchema.parse({
|
createTime: message.createTime,
|
||||||
id: submission.id,
|
message: message.message,
|
||||||
createTime: submission.createTime,
|
submission: {
|
||||||
userId: submission.userId,
|
id: submission.id,
|
||||||
username: submission.username,
|
createTime: submission.createTime,
|
||||||
code: submission.code,
|
userId: submission.userId,
|
||||||
result: submission.result,
|
username: submission.username,
|
||||||
// info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
|
code: submission.code,
|
||||||
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中
|
result: submission.result,
|
||||||
language: submission.language,
|
// info / ip / contestId 三个字段不在 embeddedSubmissionSchema 里,故不传 ——
|
||||||
shared: submission.shared,
|
// 对齐旧后端 SubmissionSafeModelSerializer 的 exclude,这三个键不出现在响应中
|
||||||
statisticInfo: objectValue(submission.statisticInfo),
|
language: submission.language,
|
||||||
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
|
statisticInfo: objectValue(submission.statisticInfo),
|
||||||
problem: displayId,
|
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
|
||||||
showLink: true,
|
problem: displayId,
|
||||||
canUnshare: false,
|
showLink: true,
|
||||||
}),
|
} satisfies EmbeddedSubmission,
|
||||||
})),
|
}) satisfies Message,
|
||||||
|
),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
}))
|
} satisfies MessageList)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -117,15 +187,39 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
|
|||||||
*/
|
*/
|
||||||
contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
|
contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const parsed = createMessageRequestSchema.safeParse(await c.req.json().catch(() => null))
|
const parsed = createMessageRequestSchema.safeParse(
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid message payload")
|
await c.req.json().catch(() => null),
|
||||||
if (parsed.data.recipientId === user.id) return failure(c, 400, "invalid-recipient", "Can not send a message to yourself")
|
)
|
||||||
|
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([
|
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
|
||||||
db.select({ id: schema.submission.id }).from(schema.submission).where(eq(schema.submission.id, parsed.data.submissionId)).limit(1),
|
.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 (!recipient)
|
||||||
if (!submission) return failure(c, 404, "submission-not-found", "Submission does not exist")
|
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({
|
await db.insert(schema.message).values({
|
||||||
message: parsed.data.message,
|
message: parsed.data.message,
|
||||||
createTime: new Date().toISOString(),
|
createTime: new Date().toISOString(),
|
||||||
@@ -137,17 +231,29 @@ contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function reactionState(problemId: number, userId: number) {
|
async function reactionState(problemId: number, userId: number) {
|
||||||
const [mine] = await db.select({ type: schema.reaction.type }).from(schema.reaction)
|
const [mine] = await db
|
||||||
.where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1)
|
.select({ type: schema.reaction.type })
|
||||||
if (!mine) return reactionStateSchema.parse({ mine: null, counts: null })
|
.from(schema.reaction)
|
||||||
const rows = await db.select({ type: schema.reaction.type, value: count() }).from(schema.reaction)
|
.where(
|
||||||
.where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type)
|
and(
|
||||||
const counts = Object.fromEntries(reactionKeySchema.options.map((key) => [key, 0]))
|
eq(schema.reaction.problemId, problemId),
|
||||||
for (const row of rows) {
|
eq(schema.reaction.userId, userId),
|
||||||
const key = reactionKeySchema.safeParse(row.type)
|
),
|
||||||
if (key.success) counts[key.data] = row.value
|
)
|
||||||
}
|
.limit(1)
|
||||||
return reactionStateSchema.parse({ mine: mine.type, counts })
|
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)
|
||||||
|
// fromEntries 推不出这个键集,但 options 就是 ReactionKey 的全集,断言是成立的。
|
||||||
|
// row.type 不必再 safeParse:reaction.type 列上挂着 $type<ReactionKey>()
|
||||||
|
const counts = Object.fromEntries(
|
||||||
|
reactionKeySchema.options.map((key) => [key, 0]),
|
||||||
|
) as ReactionCounts
|
||||||
|
for (const row of rows) counts[row.type] = row.value
|
||||||
|
return { mine: mine.type, counts } satisfies ReactionState
|
||||||
}
|
}
|
||||||
|
|
||||||
contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
|
contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
|
||||||
@@ -157,42 +263,86 @@ contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
|
|||||||
|
|
||||||
contentRoutes.post("/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 problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const parsed = setReactionRequestSchema.safeParse(await c.req.json().catch(() => null))
|
const parsed = setReactionRequestSchema.safeParse(
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid reaction")
|
await c.req.json().catch(() => null),
|
||||||
|
)
|
||||||
|
if (!parsed.success)
|
||||||
|
return failure(c, 400, "invalid-request", "Invalid reaction")
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const [[problem], [solved]] = await Promise.all([
|
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
|
||||||
db.select({ id: schema.submission.id }).from(schema.submission).where(and(
|
.select({ id: schema.problem.id })
|
||||||
eq(schema.submission.userId, user.id), eq(schema.submission.problemId, problemId),
|
.from(schema.problem)
|
||||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
.where(
|
||||||
)).limit(1),
|
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 (!problem)
|
||||||
if (!solved) return failure(c, 403, "accepted-submission-required", "An accepted submission is required")
|
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||||
await db.insert(schema.reaction).values({
|
if (!solved)
|
||||||
problemId,
|
return failure(
|
||||||
userId: user.id,
|
c,
|
||||||
type: parsed.data.type,
|
403,
|
||||||
createTime: new Date().toISOString(),
|
"accepted-submission-required",
|
||||||
}).onConflictDoNothing({ target: [schema.reaction.problemId, schema.reaction.userId] })
|
"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],
|
||||||
|
})
|
||||||
return success(c, await reactionState(problemId, user.id))
|
return success(c, await reactionState(problemId, user.id))
|
||||||
})
|
})
|
||||||
|
|
||||||
contentRoutes.get("/tutorials", async (c) => {
|
contentRoutes.get("/tutorials", async (c) => {
|
||||||
const type = c.req.query("type") === "c" ? "c" : "python"
|
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)
|
const rows = await db
|
||||||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order))
|
.select({ id: schema.tutorial.id, title: schema.tutorial.title })
|
||||||
return success(c, rows.map((row) => tutorialSummarySchema.parse(row)))
|
.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) => {
|
contentRoutes.get("/tutorials/:id", async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
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 })
|
const [row] = await db
|
||||||
.from(schema.tutorial).innerJoin(schema.user, eq(schema.tutorial.createdById, schema.user.id))
|
.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))
|
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
|
||||||
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
.limit(1)
|
||||||
return success(c, tutorialSchema.parse({
|
if (!row)
|
||||||
|
return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||||
|
return success(c, {
|
||||||
id: row.tutorial.id,
|
id: row.tutorial.id,
|
||||||
title: row.tutorial.title,
|
title: row.tutorial.title,
|
||||||
content: row.tutorial.content,
|
content: row.tutorial.content,
|
||||||
@@ -203,14 +353,243 @@ contentRoutes.get("/tutorials/:id", async (c) => {
|
|||||||
createdBy: sampleUser(row.user, row.realName),
|
createdBy: sampleUser(row.user, row.realName),
|
||||||
createdAt: row.tutorial.createdAt,
|
createdAt: row.tutorial.createdAt,
|
||||||
updatedAt: row.tutorial.updatedAt,
|
updatedAt: row.tutorial.updatedAt,
|
||||||
}))
|
} satisfies Tutorial)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 自学留痕
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学生自己的自学进度,给学习页的目录打勾用。
|
||||||
|
*
|
||||||
|
* 路径特意不放在 `/tutorials` 下:Hono 按**注册顺序**匹配(不是静态优先),
|
||||||
|
* `/tutorials/:id` 就在上面几行,`/tutorials/progress` 会被它整个吃掉,而且不报错
|
||||||
|
* ——`queryInteger("progress")` 回落成 0,学生只会看到一个「教程不存在」。
|
||||||
|
*/
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 从 tutorial 打底 left join 进度,而不是反过来:没读过的课也要有一行零,
|
||||||
|
// 否则目录里「练习 0/5」和「这课没有练习」在前端分不出来
|
||||||
|
const [rows, exerciseRows] = await Promise.all([
|
||||||
|
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(
|
||||||
|
eq(schema.tutorialProgress.tutorialId, schema.tutorial.id),
|
||||||
|
eq(schema.tutorialProgress.userId, user.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.where(visible)
|
||||||
|
.orderBy(asc(schema.tutorial.order)),
|
||||||
|
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(
|
||||||
|
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) =>
|
||||||
|
({
|
||||||
|
tutorialId: row.tutorialId,
|
||||||
|
viewCount: row.viewCount ?? 0,
|
||||||
|
totalSeconds: row.totalSeconds ?? 0,
|
||||||
|
firstViewedAt: row.firstViewedAt,
|
||||||
|
lastViewedAt: row.lastViewedAt,
|
||||||
|
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
|
||||||
|
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
|
||||||
|
}) satisfies TutorialProgress,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上报一次自学留痕。`opened` 为真表示「刚进这一课」,计一次打开;
|
||||||
|
* 否则只是心跳补时长,见 apps/web/src/oj/learn/composables/useLearnTrace.ts。
|
||||||
|
*
|
||||||
|
* 未登录一律 401 而不是静默丢弃 —— 教程本身保持免登录可读,前端只在登录后才调它,
|
||||||
|
* 真收到匿名请求说明前端判断错了,得让它响。
|
||||||
|
*/
|
||||||
|
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 now = new Date().toISOString()
|
||||||
|
const { seconds, opened } = parsed.data
|
||||||
|
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,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
// 累加在库里做,不是「读出来加一下再写回去」:同一个学生开两个标签页
|
||||||
|
// 同时上报时,读改写会互相覆盖,时长凭空少掉一半
|
||||||
|
viewCount: sql`${schema.tutorialProgress.viewCount} + ${opened ? 1 : 0}`,
|
||||||
|
totalSeconds: sql`${schema.tutorialProgress.totalSeconds} + ${seconds}`,
|
||||||
|
lastViewedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return success(c, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上报一次练一练的作答。
|
||||||
|
*
|
||||||
|
* 对错是**前端判的** —— 练一练的答案本来就随题面一起下发给浏览器(见
|
||||||
|
* `/tutorials/:id/exercises`),后端再判一遍也挡不住任何人,只是重复实现七套判题。
|
||||||
|
* 所以这里存的是「学生自己说他做对了」,作为教学观察够用,**不能当考试成绩**。
|
||||||
|
*
|
||||||
|
* 做对之后的重复提交只更新时间,不再累加 —— 学生做对后再点几下提交,
|
||||||
|
* 不该把「他试了几次」这个数字变大。
|
||||||
|
*/
|
||||||
|
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 [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({
|
||||||
|
userId: user.id,
|
||||||
|
exerciseId: id,
|
||||||
|
attempts: 1,
|
||||||
|
wrongAttempts: correct ? 0 : 1,
|
||||||
|
solved: correct,
|
||||||
|
attemptsToSolve: correct ? 1 : null,
|
||||||
|
lastWrongAnswer: answer,
|
||||||
|
firstAttemptAt: now,
|
||||||
|
lastAttemptAt: now,
|
||||||
|
solvedAt: correct ? now : null,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [
|
||||||
|
schema.exerciseAttempt.userId,
|
||||||
|
schema.exerciseAttempt.exerciseId,
|
||||||
|
],
|
||||||
|
set: {
|
||||||
|
// 一律在库里算,不读出来改了再写回去:两个标签页同时提交会互相覆盖。
|
||||||
|
//
|
||||||
|
// 每一列都先看 `solved`:做对之后这一行就冻住了,只有 lastAttemptAt 还动。
|
||||||
|
// 不冻的话,学生做对后随手再点几下提交,「他试了几次才做对」就被改花了。
|
||||||
|
attempts: sql`${schema.exerciseAttempt.attempts} + case when ${schema.exerciseAttempt.solved} then 0 else 1 end`,
|
||||||
|
wrongAttempts: sql`${schema.exerciseAttempt.wrongAttempts} + case when ${schema.exerciseAttempt.solved} or ${correct} then 0 else 1 end`,
|
||||||
|
solved: sql`${schema.exerciseAttempt.solved} or ${correct}`,
|
||||||
|
attemptsToSolve: sql`case
|
||||||
|
when ${schema.exerciseAttempt.solved} then ${schema.exerciseAttempt.attemptsToSolve}
|
||||||
|
when ${correct} then ${schema.exerciseAttempt.attempts} + 1
|
||||||
|
else null end`,
|
||||||
|
solvedAt: sql`case
|
||||||
|
when ${schema.exerciseAttempt.solved} then ${schema.exerciseAttempt.solvedAt}
|
||||||
|
when ${correct} then ${now}::timestamptz
|
||||||
|
else null end`,
|
||||||
|
lastWrongAnswer: sql`case
|
||||||
|
when ${schema.exerciseAttempt.solved} or ${correct} then ${schema.exerciseAttempt.lastWrongAnswer}
|
||||||
|
else ${answer} end`,
|
||||||
|
lastAttemptAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return success(c, null)
|
||||||
})
|
})
|
||||||
|
|
||||||
contentRoutes.get("/tutorials/:id/exercises", async (c) => {
|
contentRoutes.get("/tutorials/:id/exercises", async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const [tutorial] = await db.select({ id: schema.tutorial.id }).from(schema.tutorial)
|
const [tutorial] = await db
|
||||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
.select({ id: schema.tutorial.id })
|
||||||
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
.from(schema.tutorial)
|
||||||
const rows = await db.select().from(schema.exercise).where(eq(schema.exercise.tutorialId, id)).orderBy(asc(schema.exercise.order))
|
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true)))
|
||||||
return success(c, rows.map((row) => exerciseSchema.parse({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order })))
|
.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,
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+332
-140
@@ -1,14 +1,26 @@
|
|||||||
import {
|
import {
|
||||||
contestAccessSchema,
|
|
||||||
contestListSchema,
|
|
||||||
contestPasswordRequestSchema,
|
contestPasswordRequestSchema,
|
||||||
contestRankItemSchema,
|
STUDENT_ROLES,
|
||||||
contestRankSchema,
|
type Contest,
|
||||||
contestSchema,
|
type ContestAccess,
|
||||||
problemDetailSchema,
|
type ContestList,
|
||||||
problemListItemSchema,
|
type ContestRank,
|
||||||
|
type ContestRankItem,
|
||||||
|
type ProblemDetail,
|
||||||
|
type ProblemListItem,
|
||||||
} from "@oj2/contract"
|
} 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 { Hono } from "hono"
|
||||||
|
|
||||||
import { optionalAuth, requireAuth } from "../auth/middleware"
|
import { optionalAuth, requireAuth } from "../auth/middleware"
|
||||||
@@ -21,12 +33,17 @@ import {
|
|||||||
checkContestPassword,
|
checkContestPassword,
|
||||||
contestDetailsAllowed,
|
contestDetailsAllowed,
|
||||||
contestStatus,
|
contestStatus,
|
||||||
findVisibleContest,
|
findAccessibleContest,
|
||||||
isContestAdmin,
|
isContestAdmin,
|
||||||
requireContestAccess,
|
requireContestAccess,
|
||||||
type ContestEnv,
|
type ContestEnv,
|
||||||
} from "../services/contest"
|
} from "../services/contest"
|
||||||
import { objectValue, publicTemplates, queryInteger, sampleUser, stringArray } from "./helpers"
|
import {
|
||||||
|
objectValue,
|
||||||
|
publicTemplates,
|
||||||
|
queryInteger,
|
||||||
|
sampleUser,
|
||||||
|
} from "./helpers"
|
||||||
|
|
||||||
export const contestRoutes = new Hono<ContestEnv>()
|
export const contestRoutes = new Hono<ContestEnv>()
|
||||||
|
|
||||||
@@ -34,8 +51,14 @@ export const contestRoutes = new Hono<ContestEnv>()
|
|||||||
async function creators(ids: number[]) {
|
async function creators(ids: number[]) {
|
||||||
const map = new Map<number, ReturnType<typeof sampleUser>>()
|
const map = new Map<number, ReturnType<typeof sampleUser>>()
|
||||||
if (ids.length === 0) return map
|
if (ids.length === 0) return map
|
||||||
const rows = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName })
|
const rows = await db
|
||||||
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
.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))
|
.where(inArray(schema.user.id, ids))
|
||||||
for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
|
for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
|
||||||
return map
|
return map
|
||||||
@@ -46,7 +69,7 @@ function serializeContest(
|
|||||||
createdBy: ReturnType<typeof sampleUser>,
|
createdBy: ReturnType<typeof sampleUser>,
|
||||||
includeNow = false,
|
includeNow = false,
|
||||||
) {
|
) {
|
||||||
return contestSchema.parse({
|
return {
|
||||||
id: contest.id,
|
id: contest.id,
|
||||||
title: contest.title,
|
title: contest.title,
|
||||||
description: contest.description,
|
description: contest.description,
|
||||||
@@ -59,7 +82,7 @@ function serializeContest(
|
|||||||
status: contestStatus(contest),
|
status: contestStatus(contest),
|
||||||
contestType: contest.password ? "Password Protected" : "Public",
|
contestType: contest.password ? "Password Protected" : "Public",
|
||||||
now: includeNow ? new Date().toISOString() : undefined,
|
now: includeNow ? new Date().toISOString() : undefined,
|
||||||
})
|
} satisfies Contest
|
||||||
}
|
}
|
||||||
|
|
||||||
contestRoutes.get("/contests", async (c) => {
|
contestRoutes.get("/contests", async (c) => {
|
||||||
@@ -74,160 +97,329 @@ contestRoutes.get("/contests", async (c) => {
|
|||||||
if (tag) filters.push(eq(schema.contest.tag, tag))
|
if (tag) filters.push(eq(schema.contest.tag, tag))
|
||||||
if (status === "1") filters.push(gte(schema.contest.startTime, now))
|
if (status === "1") filters.push(gte(schema.contest.startTime, now))
|
||||||
else if (status === "-1") filters.push(lte(schema.contest.endTime, 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 where = and(...filters)
|
||||||
const [totalRow, rows] = await Promise.all([
|
const [totalRow, rows] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.contest).where(where),
|
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))])
|
const byId = await creators([...new Set(rows.map((row) => row.createdById))])
|
||||||
return success(c, contestListSchema.parse({
|
return success(c, {
|
||||||
results: rows.map((row) => serializeContest(
|
results: rows.map((row) =>
|
||||||
row,
|
serializeContest(
|
||||||
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
|
row,
|
||||||
)),
|
byId.get(row.createdById) ??
|
||||||
|
sampleUser({ id: row.createdById, username: "" }, null),
|
||||||
|
),
|
||||||
|
),
|
||||||
total: totalRow[0]?.value ?? 0,
|
total: totalRow[0]?.value ?? 0,
|
||||||
}))
|
} satisfies ContestList)
|
||||||
})
|
})
|
||||||
|
|
||||||
contestRoutes.get("/contests/:id", async (c) => {
|
// optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」——
|
||||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
// 隐藏的比赛只有他看得到详情,匿名访问照旧当作不存在
|
||||||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
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 byId = await creators([contest.createdById])
|
const byId = await creators([contest.createdById])
|
||||||
return success(c, serializeContest(
|
return success(
|
||||||
contest,
|
c,
|
||||||
byId.get(contest.createdById) ?? sampleUser({ id: contest.createdById, username: "" }, null),
|
serializeContest(
|
||||||
true,
|
contest,
|
||||||
))
|
byId.get(contest.createdById) ??
|
||||||
|
sampleUser({ id: contest.createdById, username: "" }, null),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
contestRoutes.post("/contests/:id/access", requireAuth, async (c) => {
|
||||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
const contest = await findAccessibleContest(
|
||||||
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
c.get("user"),
|
||||||
const parsed = contestPasswordRequestSchema.safeParse(await c.req.json().catch(() => null))
|
queryInteger(c.req.param("id"), 0, { min: 1 }),
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Password is required")
|
)
|
||||||
|
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)) {
|
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)
|
await setContestPassword(c, contest.id, parsed.data.password)
|
||||||
return success(c, true)
|
return success(c, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
|
contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
|
||||||
const contest = await findVisibleContest(queryInteger(c.req.param("id"), 0, { min: 1 }))
|
const contest = await findAccessibleContest(
|
||||||
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
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")
|
const access = await canAccessContest(c, contest, "details")
|
||||||
return success(c, contestAccessSchema.parse({ access: access.ok }))
|
return success(c, { access: access.ok } satisfies ContestAccess)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前用户在**比赛题**上的做题状态。判题回写记在 user_profile 的
|
||||||
|
* `acm_problems_status.contest_problems`(judge/run.ts),公开题库那份记在 `problems`
|
||||||
|
* 下,两边互不干扰。
|
||||||
|
*
|
||||||
|
* 原来这两条路由一律下发空状态,于是比赛题目页的「状态」列永远是「未做」,赛后也不
|
||||||
|
* 恢复 —— 而库里其实一直记着。
|
||||||
|
*
|
||||||
|
* 不按「比赛结没结束」分档:这是学生自己的判题结果,赛中赛后都不泄露别人的任何信息
|
||||||
|
* (旧后端赛中不下发,纯粹是因为它整条路换了个 serializer,不是什么保密考虑)。
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
return objectValue(objectValue(profile?.status).contest_problems)
|
||||||
|
}
|
||||||
|
|
||||||
|
function myStatusOf(statuses: Record<string, unknown>, problemId: number) {
|
||||||
|
const status = objectValue(statuses[String(problemId)]).status
|
||||||
|
return typeof status === "number" ? status : null
|
||||||
|
}
|
||||||
|
|
||||||
async function contestProblemTags(problemIds: number[]) {
|
async function contestProblemTags(problemIds: number[]) {
|
||||||
if (problemIds.length === 0) return new Map<number, string[]>()
|
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
|
||||||
.from(schema.problemTags).innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
.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))
|
.where(inArray(schema.problemTags.problemId, problemIds))
|
||||||
const map = new Map<number, string[]>()
|
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
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("problems"), async (c) => {
|
contestRoutes.get(
|
||||||
const contest = c.get("contest")!
|
"/contests/:id/problems",
|
||||||
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
optionalAuth,
|
||||||
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
requireContestAccess("problems"),
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
async (c) => {
|
||||||
.where(and(eq(schema.problem.contestId, contest.id), eq(schema.problem.visible, true))).orderBy(asc(schema.problem.displayId))
|
const contest = c.get("contest")!
|
||||||
const tags = await contestProblemTags(rows.map((row) => row.problem.id))
|
const rows = await db
|
||||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
.select({
|
||||||
return success(c, rows.map(({ problem, user, realName }) => problemListItemSchema.parse({
|
problem: schema.problem,
|
||||||
id: problem.id,
|
user: schema.user,
|
||||||
_id: problem.displayId,
|
realName: schema.userProfile.realName,
|
||||||
title: problem.title,
|
})
|
||||||
submissionNumber: allowed ? problem.submissionNumber : 0,
|
.from(schema.problem)
|
||||||
acceptedNumber: allowed ? problem.acceptedNumber : 0,
|
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||||
difficulty: allowed ? problem.difficulty : "",
|
.leftJoin(
|
||||||
createdBy: sampleUser(user, realName),
|
schema.userProfile,
|
||||||
tags: tags.get(problem.id) ?? [],
|
eq(schema.userProfile.userId, schema.user.id),
|
||||||
contestId: contest.id,
|
)
|
||||||
allowFlowchart: problem.allowFlowchart,
|
.where(
|
||||||
showFlowchart: problem.showFlowchart,
|
and(
|
||||||
hasAstRules: problem.astRules !== null,
|
eq(schema.problem.contestId, contest.id),
|
||||||
myStatus: null,
|
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 }) =>
|
||||||
|
({
|
||||||
|
id: problem.id,
|
||||||
|
_id: problem.displayId,
|
||||||
|
title: problem.title,
|
||||||
|
submissionNumber: allowed ? problem.submissionNumber : 0,
|
||||||
|
acceptedNumber: allowed ? problem.acceptedNumber : 0,
|
||||||
|
difficulty: allowed ? problem.difficulty : null,
|
||||||
|
createdBy: sampleUser(user, realName),
|
||||||
|
tags: tags.get(problem.id) ?? [],
|
||||||
|
contestId: contest.id,
|
||||||
|
allowFlowchart: problem.allowFlowchart,
|
||||||
|
showFlowchart: problem.showFlowchart,
|
||||||
|
hasAstRules: problem.astRules !== null,
|
||||||
|
myStatus: myStatusOf(statuses, problem.id),
|
||||||
|
}) satisfies ProblemListItem,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => {
|
contestRoutes.get(
|
||||||
const contest = c.get("contest")!
|
"/contests/:id/problems/:displayId",
|
||||||
const [row] = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
optionalAuth,
|
||||||
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
requireContestAccess("problems"),
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
async (c) => {
|
||||||
.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)
|
const contest = c.get("contest")!
|
||||||
if (!row) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
const [row] = await db
|
||||||
const tags = await contestProblemTags([row.problem.id])
|
.select({
|
||||||
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
problem: schema.problem,
|
||||||
return success(c, problemDetailSchema.parse({
|
user: schema.user,
|
||||||
id: row.problem.id,
|
realName: schema.userProfile.realName,
|
||||||
_id: row.problem.displayId,
|
})
|
||||||
title: row.problem.title,
|
.from(schema.problem)
|
||||||
description: row.problem.description,
|
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||||
inputDescription: row.problem.inputDescription,
|
.leftJoin(
|
||||||
outputDescription: row.problem.outputDescription,
|
schema.userProfile,
|
||||||
samples: Array.isArray(row.problem.samples) ? row.problem.samples : [],
|
eq(schema.userProfile.userId, schema.user.id),
|
||||||
hint: row.problem.hint,
|
)
|
||||||
languages: stringArray(row.problem.languages),
|
.where(
|
||||||
template: publicTemplates(row.problem.template),
|
and(
|
||||||
createTime: row.problem.createTime,
|
eq(schema.problem.contestId, contest.id),
|
||||||
lastUpdateTime: row.problem.lastUpdateTime,
|
eq(schema.problem.visible, true),
|
||||||
timeLimit: row.problem.timeLimit,
|
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
|
||||||
memoryLimit: row.problem.memoryLimit,
|
),
|
||||||
difficulty: allowed ? row.problem.difficulty : "",
|
)
|
||||||
source: row.problem.source,
|
.limit(1)
|
||||||
prompt: row.problem.prompt,
|
if (!row)
|
||||||
submissionNumber: allowed ? row.problem.submissionNumber : 0,
|
return failure(c, 404, "problem-not-found", "Problem does not exist")
|
||||||
acceptedNumber: allowed ? row.problem.acceptedNumber : 0,
|
const tags = await contestProblemTags([row.problem.id])
|
||||||
statisticInfo: allowed ? objectValue(row.problem.statisticInfo) : {},
|
const allowed = contestDetailsAllowed(c.get("user"), contest)
|
||||||
shareSubmission: row.problem.shareSubmission,
|
const statuses = await contestProblemStatuses(c.get("user")?.id)
|
||||||
contestId: contest.id,
|
return success(c, {
|
||||||
tags: tags.get(row.problem.id) ?? [],
|
id: row.problem.id,
|
||||||
createdBy: sampleUser(row.user, row.realName),
|
_id: row.problem.displayId,
|
||||||
myStatus: null,
|
title: row.problem.title,
|
||||||
myFailedCount: 0,
|
description: row.problem.description,
|
||||||
allowFlowchart: row.problem.allowFlowchart,
|
inputDescription: row.problem.inputDescription,
|
||||||
showFlowchart: row.problem.showFlowchart,
|
outputDescription: row.problem.outputDescription,
|
||||||
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
samples: Array.isArray(row.problem.samples) ? row.problem.samples : [],
|
||||||
flowchartData: row.problem.allowFlowchart ? null : objectValue(row.problem.flowchartData),
|
hint: row.problem.hint,
|
||||||
flowchartHint: row.problem.flowchartHint,
|
languages: row.problem.languages,
|
||||||
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
|
template: publicTemplates(row.problem.template),
|
||||||
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
|
createTime: row.problem.createTime,
|
||||||
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
lastUpdateTime: row.problem.lastUpdateTime,
|
||||||
astRequirements: astRequirements(row.problem.astRules),
|
timeLimit: row.problem.timeLimit,
|
||||||
}))
|
memoryLimit: row.problem.memoryLimit,
|
||||||
})
|
difficulty: allowed ? row.problem.difficulty : null,
|
||||||
|
source: row.problem.source,
|
||||||
|
prompt: row.problem.prompt,
|
||||||
|
submissionNumber: allowed ? row.problem.submissionNumber : 0,
|
||||||
|
acceptedNumber: allowed ? row.problem.acceptedNumber : 0,
|
||||||
|
statisticInfo: allowed ? objectValue(row.problem.statisticInfo) : {},
|
||||||
|
contestId: contest.id,
|
||||||
|
tags: tags.get(row.problem.id) ?? [],
|
||||||
|
createdBy: sampleUser(row.user, row.realName),
|
||||||
|
myStatus: myStatusOf(statuses, row.problem.id),
|
||||||
|
// 比赛里不给 AI 提示(POST /ai/hint 见到比赛提交直接 403),这个数只喂那个按钮,恒 0
|
||||||
|
myFailedCount: 0,
|
||||||
|
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),
|
||||||
|
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(
|
||||||
const contest = c.get("contest")!
|
"/contests/:id/rank",
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
optionalAuth,
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
requireContestAccess("ranks"),
|
||||||
const where = and(eq(schema.acmContestRank.contestId, contest.id), inArray(schema.user.adminType, ["Regular User", "Student Admin"]), eq(schema.user.isDisabled, false))
|
async (c) => {
|
||||||
const [totalRows, rows] = await Promise.all([
|
const contest = c.get("contest")!
|
||||||
db.select({ value: count() }).from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id)).where(where),
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
db.select({ rank: schema.acmContestRank, user: schema.user, realName: schema.userProfile.realName })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
.from(schema.acmContestRank).innerJoin(schema.user, eq(schema.acmContestRank.userId, schema.user.id))
|
const where = and(
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
|
eq(schema.acmContestRank.contestId, contest.id),
|
||||||
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime)).limit(limit).offset(offset),
|
inArray(schema.user.adminType, [...STUDENT_ROLES]),
|
||||||
])
|
eq(schema.user.isDisabled, false),
|
||||||
const admin = isContestAdmin(c.get("user"), contest)
|
)
|
||||||
return success(c, contestRankSchema.parse({
|
const [totalRows, rows] = await Promise.all([
|
||||||
results: rows.map(({ rank, user, realName }) => contestRankItemSchema.parse({
|
db
|
||||||
id: rank.id,
|
.select({ value: count() })
|
||||||
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
|
.from(schema.acmContestRank)
|
||||||
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
|
.innerJoin(
|
||||||
user: sampleUser(user, realName, { includeRealName: admin }),
|
schema.user,
|
||||||
submissionNumber: rank.submissionNumber,
|
eq(schema.acmContestRank.userId, schema.user.id),
|
||||||
acceptedNumber: rank.acceptedNumber,
|
)
|
||||||
totalTime: rank.totalTime,
|
.where(where),
|
||||||
submissionInfo: objectValue(rank.submissionInfo),
|
db
|
||||||
contestId: rank.contestId,
|
.select({
|
||||||
})),
|
rank: schema.acmContestRank,
|
||||||
total: totalRows[0]?.value ?? 0,
|
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),
|
||||||
|
])
|
||||||
|
const admin = isContestAdmin(c.get("user"), contest)
|
||||||
|
return success(c, {
|
||||||
|
results: rows.map(
|
||||||
|
({ rank, user, realName }) =>
|
||||||
|
({
|
||||||
|
id: rank.id,
|
||||||
|
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
|
||||||
|
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
|
||||||
|
user: sampleUser(user, realName, { includeRealName: admin }),
|
||||||
|
submissionNumber: rank.submissionNumber,
|
||||||
|
acceptedNumber: rank.acceptedNumber,
|
||||||
|
totalTime: rank.totalTime,
|
||||||
|
submissionInfo: rank.submissionInfo,
|
||||||
|
contestId: rank.contestId,
|
||||||
|
}) satisfies ContestRankItem,
|
||||||
|
),
|
||||||
|
total: totalRows[0]?.value ?? 0,
|
||||||
|
} satisfies ContestRank)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
+534
-177
@@ -2,15 +2,25 @@ import { randomBytes } from "node:crypto"
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createFlowchartRequestSchema,
|
createFlowchartRequestSchema,
|
||||||
createFlowchartResponseSchema,
|
type CreateFlowchartResponse,
|
||||||
flowchartCurrentSchema,
|
type FlowchartCurrent,
|
||||||
flowchartDetailSchema,
|
type FlowchartDetail,
|
||||||
flowchartListItemSchema,
|
type FlowchartList,
|
||||||
flowchartListSchema,
|
type FlowchartListItem,
|
||||||
flowchartStatisticsSchema,
|
type FlowchartStatistics,
|
||||||
flowchartSubmissionSchema,
|
type FlowchartSubmission,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
|
import {
|
||||||
|
and,
|
||||||
|
asc,
|
||||||
|
count,
|
||||||
|
desc,
|
||||||
|
eq,
|
||||||
|
inArray,
|
||||||
|
isNull,
|
||||||
|
sql,
|
||||||
|
type SQL,
|
||||||
|
} from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
import { requireAuth, requireTeacher, type AppEnv } from "../auth/middleware"
|
||||||
@@ -21,13 +31,14 @@ import { flowchartQueue } from "../queue"
|
|||||||
import { getBooleanOption } from "../services/options"
|
import { getBooleanOption } from "../services/options"
|
||||||
import { consumeToken } from "../services/throttling"
|
import { consumeToken } from "../services/throttling"
|
||||||
import { buildWordFrequencies } from "../services/word-frequency"
|
import { buildWordFrequencies } from "../services/word-frequency"
|
||||||
|
import { todayStart } from "../time"
|
||||||
import {
|
import {
|
||||||
isAdminRole,
|
isAdminRole,
|
||||||
|
matchedUsers,
|
||||||
objectValue,
|
objectValue,
|
||||||
queryInteger,
|
queryInteger,
|
||||||
rounded,
|
rounded,
|
||||||
stripClassPrefix,
|
stripClassPrefix,
|
||||||
todayStart,
|
|
||||||
} from "./helpers"
|
} from "./helpers"
|
||||||
|
|
||||||
export const flowchartRoutes = new Hono<AppEnv>()
|
export const flowchartRoutes = new Hono<AppEnv>()
|
||||||
@@ -37,15 +48,23 @@ function flowchartThrottleKey(userId: number) {
|
|||||||
return `flowchart:${userId}`
|
return `flowchart:${userId}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function canView(user: import("../auth/session").AuthUser, row: { userId: number }, problem: { createdById: number }) {
|
function canView(
|
||||||
return row.userId === user.id || isAdminRole(user) || problem.createdById === user.id
|
user: import("../auth/session").AuthUser,
|
||||||
|
row: { userId: number },
|
||||||
|
problem: { createdById: number },
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
row.userId === user.id ||
|
||||||
|
isAdminRole(user) ||
|
||||||
|
problem.createdById === user.id
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function flowchartData(
|
function flowchartData(
|
||||||
flowchart: typeof schema.flowchartSubmission.$inferSelect,
|
flowchart: typeof schema.flowchartSubmission.$inferSelect,
|
||||||
username: string,
|
username: string,
|
||||||
) {
|
) {
|
||||||
return flowchartSubmissionSchema.parse({
|
return {
|
||||||
id: flowchart.id,
|
id: flowchart.id,
|
||||||
username,
|
username,
|
||||||
problemId: flowchart.problemId,
|
problemId: flowchart.problemId,
|
||||||
@@ -62,24 +81,52 @@ function flowchartData(
|
|||||||
aiModel: flowchart.aiModel,
|
aiModel: flowchart.aiModel,
|
||||||
processingTime: flowchart.processingTime,
|
processingTime: flowchart.processingTime,
|
||||||
evaluationTime: flowchart.evaluationTime,
|
evaluationTime: flowchart.evaluationTime,
|
||||||
})
|
} satisfies FlowchartSubmission
|
||||||
}
|
}
|
||||||
|
|
||||||
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
||||||
const parsed = createFlowchartRequestSchema.safeParse(await c.req.json().catch(() => null))
|
const parsed = createFlowchartRequestSchema.safeParse(
|
||||||
if (!parsed.success || JSON.stringify(parsed.data?.flowchartData ?? {}).length > 500 * 1024) {
|
await c.req.json().catch(() => null),
|
||||||
return failure(c, 400, "invalid-request", parsed.error?.issues[0]?.message ?? "Flowchart data is too large")
|
)
|
||||||
|
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)
|
const [problem] = await db
|
||||||
.where(eq(schema.problem.id, parsed.data.problemId)).limit(1)
|
.select({ id: schema.problem.id, allow: schema.problem.allowFlowchart })
|
||||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
.from(schema.problem)
|
||||||
if (!problem.allow) return failure(c, 400, "flowchart-not-allowed", "This problem does not allow flowchart submission")
|
.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 调用,是和判题沙箱同级的有限资源。
|
// 限流:每次提交都会触发一次外部 AI 调用,是和判题沙箱同级的有限资源。
|
||||||
// 身份前缀单独开一个桶,**不能**直接用 user id —— 那是代码提交在用的桶,
|
// 身份前缀单独开一个桶,**不能**直接用 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) {
|
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")
|
const id = randomBytes(16).toString("hex")
|
||||||
await db.insert(schema.flowchartSubmission).values({
|
await db.insert(schema.flowchartSubmission).values({
|
||||||
@@ -103,71 +150,194 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
|
|||||||
try {
|
try {
|
||||||
await flowchartQueue.add("evaluate", { submissionId: id }, { jobId: id })
|
await flowchartQueue.add("evaluate", { submissionId: id }, { jobId: id })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, id))
|
await db
|
||||||
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
|
.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, createFlowchartResponseSchema.parse({ submissionId: id, status: "pending" }), 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_code、flowchart_data、ai_feedback、ai_suggestions、ai_criteria_details
|
||||||
|
* 和**整张题目表**(description / 标准答案 / 标准流程图…)一起拉回来,而响应一个
|
||||||
|
* 都用不到:生产快照实测流程图行均 4.9KB(p90 6.9KB)、题目行均 2.2KB,默认 10 行
|
||||||
|
* 一页白拉 ~70KB,limit=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) => {
|
flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const filters = []
|
|
||||||
const displayId = c.req.query("problemId")?.trim()
|
const displayId = c.req.query("problemId")?.trim()
|
||||||
const username = c.req.query("username")?.trim()
|
const username = c.req.query("username")?.trim()
|
||||||
const grade = c.req.query("grade")
|
const grade = c.req.query("grade")
|
||||||
// 与代码提交列表同一套口径(submission.ts 的 GET /submissions):关掉
|
// 与代码提交列表同一套口径(submission.ts 的 GET /submissions):关掉
|
||||||
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
|
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
|
||||||
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
|
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
|
||||||
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
|
if (
|
||||||
return success(c, flowchartListSchema.parse({ results: [], total: 0 }))
|
!(await getBooleanOption("submission_list_show_all", true)) &&
|
||||||
|
!isAdminRole(user)
|
||||||
|
) {
|
||||||
|
return success(c, { results: [], total: 0 } satisfies FlowchartList)
|
||||||
}
|
}
|
||||||
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))
|
const onlyMyself =
|
||||||
else if (username) filters.push(ilike(schema.user.username, `%${username}%`))
|
c.req.query("myself") === "1" ||
|
||||||
if (c.req.query("today") === "1") filters.push(sql`${schema.flowchartSubmission.createTime} >= ${todayStart()}`)
|
(!username && user.adminType === "Regular User")
|
||||||
if (["S", "A", "B", "C"].includes(grade ?? "")) filters.push(eq(schema.flowchartSubmission.aiGrade, grade!))
|
const filters: Array<SQL | undefined> = []
|
||||||
const where = filters.length ? and(...filters) : 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)
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
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),
|
// 筛条件已经全落在 flowchart_submission 自己的列上,count 不挂任何 join
|
||||||
db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username, problem: schema.problem })
|
db.select({ value: count() }).from(schema.flowchartSubmission).where(where),
|
||||||
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
db
|
||||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)).where(where)
|
.select(flowchartListColumns)
|
||||||
.orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset),
|
.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, flowchartListSchema.parse({
|
return success(c, {
|
||||||
results: rows.map(({ flowchart, username, problem }) => flowchartListItemSchema.parse({
|
results: rows.map(
|
||||||
id: flowchart.id,
|
({ flowchart, username, problem }) =>
|
||||||
username,
|
({
|
||||||
problem: problem.displayId,
|
id: flowchart.id,
|
||||||
problemTitle: problem.title,
|
username,
|
||||||
status: flowchart.status,
|
problem: problem.displayId,
|
||||||
createTime: flowchart.createTime,
|
problemTitle: problem.title,
|
||||||
aiScore: flowchart.aiScore,
|
status: flowchart.status,
|
||||||
aiGrade: flowchart.aiGrade,
|
createTime: flowchart.createTime,
|
||||||
aiProvider: flowchart.aiProvider,
|
aiScore: flowchart.aiScore,
|
||||||
aiModel: flowchart.aiModel,
|
aiGrade: flowchart.aiGrade,
|
||||||
processingTime: flowchart.processingTime,
|
aiProvider: flowchart.aiProvider,
|
||||||
evaluationTime: flowchart.evaluationTime,
|
aiModel: flowchart.aiModel,
|
||||||
showLink: canView(user, flowchart, problem),
|
processingTime: flowchart.processingTime,
|
||||||
})),
|
evaluationTime: flowchart.evaluationTime,
|
||||||
|
showLink: canView(user, flowchart, problem),
|
||||||
|
}) satisfies FlowchartListItem,
|
||||||
|
),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
}))
|
} satisfies FlowchartList)
|
||||||
})
|
})
|
||||||
|
|
||||||
const FLOWCHART_COMPLETED = 2
|
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
|
const WORDCLOUD_TEXT_LIMIT = 3000
|
||||||
|
|
||||||
@@ -176,160 +346,293 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
|
|||||||
if (!end) return failure(c, 400, "invalid-request", "end is required")
|
if (!end) return failure(c, 400, "invalid-request", "end is required")
|
||||||
const start = c.req.query("start")?.trim()
|
const start = c.req.query("start")?.trim()
|
||||||
|
|
||||||
const filters = [
|
const filters: Array<SQL | undefined> = [
|
||||||
eq(schema.flowchartSubmission.status, FLOWCHART_COMPLETED),
|
eq(schema.flowchartSubmission.status, FLOWCHART_COMPLETED),
|
||||||
sql`${schema.flowchartSubmission.createTime} <= ${end}`,
|
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()
|
const displayId = c.req.query("problemId")?.trim()
|
||||||
if (displayId) {
|
if (displayId) {
|
||||||
const [problem] = await db
|
const [problem] = await db
|
||||||
.select({ id: schema.problem.id })
|
.select({ id: schema.problem.id })
|
||||||
.from(schema.problem)
|
.from(schema.problem)
|
||||||
.where(and(
|
.where(
|
||||||
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
and(
|
||||||
isNull(schema.problem.contestId),
|
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
|
||||||
eq(schema.problem.visible, true),
|
isNull(schema.problem.contestId),
|
||||||
))
|
eq(schema.problem.visible, true),
|
||||||
|
),
|
||||||
|
)
|
||||||
.limit(1)
|
.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))
|
filters.push(eq(schema.flowchartSubmission.problemId, problem.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = c.req.query("username")?.trim()
|
const username = c.req.query("username")?.trim()
|
||||||
if (username) filters.push(ilike(schema.user.username, `%${username}%`))
|
// 用户名先解析成账号,再拿 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)
|
||||||
|
// 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。
|
||||||
|
// 未禁用的普通用户才进分母,教师和管理员不算
|
||||||
const roster = username
|
const roster = username
|
||||||
? await db
|
? matched.filter(
|
||||||
.select({ username: schema.user.username, className: schema.user.className })
|
(row) => !row.isDisabled && row.adminType === "Regular User",
|
||||||
.from(schema.user)
|
)
|
||||||
.where(and(
|
|
||||||
ilike(schema.user.username, `%${username}%`),
|
|
||||||
eq(schema.user.isDisabled, false),
|
|
||||||
eq(schema.user.adminType, "Regular User"),
|
|
||||||
))
|
|
||||||
: []
|
: []
|
||||||
|
|
||||||
const rows = await db
|
/**
|
||||||
.select({
|
* 五条查询,每条的代价都和窗口里的行数脱钩(词云那条卡了 limit)。
|
||||||
username: schema.user.username,
|
*
|
||||||
score: schema.flowchartSubmission.aiScore,
|
* 原来是**一条**不带 limit 的 `select(username, score, grade, criteria, feedback,
|
||||||
grade: schema.flowchartSubmission.aiGrade,
|
* suggestions) order by create_time desc`,把整个时间窗的行拉进内存再用 JS 算 ——
|
||||||
criteria: schema.flowchartSubmission.aiCriteriaDetails,
|
* 词云的 3000 条上限是在 JS 里截的,行早就全回来了。备份实测每行的 AI 文本约 366B
|
||||||
feedback: schema.flowchartSubmission.aiFeedback,
|
* (criteria 255 + suggestions 64 + feedback 47),现在 2134 条无感,5 万条就是一次
|
||||||
suggestions: schema.flowchartSubmission.aiSuggestions,
|
* 点击 18MB,而老师是开着面板反复切时段、切班的。
|
||||||
})
|
*/
|
||||||
.from(schema.flowchartSubmission)
|
const [[totals], gradeRows, criteriaRows, textRows, submittedRows] =
|
||||||
.innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
await Promise.all([
|
||||||
.where(and(...filters))
|
db
|
||||||
// 按时间倒序,好让词云取到的那部分是最近的
|
.select({
|
||||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
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
|
||||||
|
.select({
|
||||||
|
criteria: schema.flowchartSubmission.aiCriteriaDetails,
|
||||||
|
feedback: schema.flowchartSubmission.aiFeedback,
|
||||||
|
suggestions: schema.flowchartSubmission.aiSuggestions,
|
||||||
|
})
|
||||||
|
.from(schema.flowchartSubmission)
|
||||||
|
.where(where)
|
||||||
|
.orderBy(desc(schema.flowchartSubmission.createTime))
|
||||||
|
.limit(WORDCLOUD_TEXT_LIMIT),
|
||||||
|
// 「谁没做」只在有花名册时算得出来,行数也就一个班
|
||||||
|
roster.length
|
||||||
|
? db
|
||||||
|
.selectDistinct({ userId: schema.flowchartSubmission.userId })
|
||||||
|
.from(schema.flowchartSubmission)
|
||||||
|
.where(where)
|
||||||
|
: [],
|
||||||
|
])
|
||||||
|
|
||||||
const empty = {
|
if (!totals || totals.total === 0) {
|
||||||
totalCount: 0,
|
return success(c, {
|
||||||
avgScore: 0,
|
totalCount: 0,
|
||||||
gradeDistribution: {},
|
avgScore: 0,
|
||||||
criteriaAverages: {},
|
gradeDistribution: {},
|
||||||
personCount: roster.length,
|
criteriaAverages: {},
|
||||||
completedCount: 0,
|
personCount: roster.length,
|
||||||
wordFrequencies: [],
|
completedCount: 0,
|
||||||
dataUnaccepted: [],
|
wordFrequencies: [],
|
||||||
|
// 一条提交都没有时,花名册上的人**全都**是「没做」—— 原来这里写死空数组,
|
||||||
|
// 于是一节课刚开始、最该点名的时候,教师面板反而一个名字都不给
|
||||||
|
dataUnaccepted: roster.map((row) => ({
|
||||||
|
username: row.username,
|
||||||
|
realName: stripClassPrefix(row.username, row.className),
|
||||||
|
})),
|
||||||
|
} satisfies FlowchartStatistics)
|
||||||
}
|
}
|
||||||
if (rows.length === 0) return success(c, flowchartStatisticsSchema.parse(empty))
|
|
||||||
|
|
||||||
const gradeDistribution: Record<string, number> = {}
|
const gradeDistribution: Record<string, number> = {}
|
||||||
const criteriaTotals = new Map<string, { sum: number; count: number; max: 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 texts: string[] = []
|
const texts: string[] = []
|
||||||
const pushText = (value: string) => {
|
const pushText = (value: string) => {
|
||||||
if (texts.length < WORDCLOUD_TEXT_LIMIT) texts.push(value)
|
if (texts.length < WORDCLOUD_TEXT_LIMIT) texts.push(value)
|
||||||
}
|
}
|
||||||
const submitted = new Set<string>()
|
for (const row of textRows) {
|
||||||
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))) {
|
for (const [key, value] of Object.entries(objectValue(row.criteria))) {
|
||||||
const detail = objectValue(value)
|
const detail = objectValue(value)
|
||||||
|
// 和上面那条聚合同一道闸:分数不是数字的项当没配过,满分和评语也都不收
|
||||||
if (typeof detail.score !== "number") continue
|
if (typeof detail.score !== "number") continue
|
||||||
const bucket = criteriaTotals.get(key)
|
if (!criteriaMax.has(key)) {
|
||||||
if (bucket) {
|
criteriaMax.set(key, typeof detail.max === "number" ? detail.max : 100)
|
||||||
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.feedback) pushText(row.feedback)
|
||||||
if (row.suggestions) pushText(row.suggestions)
|
if (row.suggestions) pushText(row.suggestions)
|
||||||
}
|
}
|
||||||
|
|
||||||
const criteriaAverages: Record<string, { avg: number; max: number }> = {}
|
const criteriaAverages: Record<string, { avg: number; max: number }> = {}
|
||||||
for (const [key, bucket] of criteriaTotals) {
|
for (const row of criteriaRows) {
|
||||||
criteriaAverages[key] = { avg: rounded(bucket.sum / bucket.count, 1), max: bucket.max }
|
criteriaAverages[row.key] = {
|
||||||
|
avg: rounded(row.avg, 1),
|
||||||
|
max: criteriaMax.get(row.key) ?? 100,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return success(c, flowchartStatisticsSchema.parse({
|
const submitted = new Set(submittedRows.map((row) => row.userId))
|
||||||
totalCount: rows.length,
|
return success(c, {
|
||||||
// 分母是有分数的条数,不是总条数 —— 对齐 Django 的 Avg(),它跳过 NULL
|
totalCount: totals.total,
|
||||||
avgScore: scoreCount ? rounded(scoreSum / scoreCount, 1) : 0,
|
avgScore: totals.scoreCount
|
||||||
|
? rounded(totals.scoreSum / totals.scoreCount, 1)
|
||||||
|
: 0,
|
||||||
gradeDistribution,
|
gradeDistribution,
|
||||||
criteriaAverages,
|
criteriaAverages,
|
||||||
personCount: roster.length,
|
personCount: roster.length,
|
||||||
completedCount: submitted.size,
|
completedCount: totals.completedCount,
|
||||||
wordFrequencies: await buildWordFrequencies(texts),
|
wordFrequencies: await buildWordFrequencies(texts),
|
||||||
dataUnaccepted: roster
|
dataUnaccepted: roster
|
||||||
.filter((row) => !submitted.has(row.username))
|
.filter((row) => !submitted.has(row.id))
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
username: row.username,
|
username: row.username,
|
||||||
realName: stripClassPrefix(row.username, row.className),
|
realName: stripClassPrefix(row.username, row.className),
|
||||||
})),
|
})),
|
||||||
}))
|
} satisfies FlowchartStatistics)
|
||||||
})
|
})
|
||||||
|
|
||||||
flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
|
||||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username, problem: schema.problem })
|
const [row] = await db
|
||||||
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
.select({
|
||||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
flowchart: schema.flowchartSubmission,
|
||||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1)
|
username: schema.user.username,
|
||||||
if (!row || !canView(c.get("user")!, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
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))
|
return success(c, flowchartData(row.flowchart, row.username))
|
||||||
})
|
})
|
||||||
|
|
||||||
flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const [row] = await db.select({ flowchart: schema.flowchartSubmission, problem: schema.problem }).from(schema.flowchartSubmission)
|
const [row] = await db
|
||||||
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id))
|
.select({ flowchart: schema.flowchartSubmission, problem: schema.problem })
|
||||||
.where(eq(schema.flowchartSubmission.id, c.req.param("id"))).limit(1)
|
.from(schema.flowchartSubmission)
|
||||||
if (!row || !canView(user, row.flowchart, row.problem)) return failure(c, 404, "flowchart-not-found", "Submission does not exist")
|
.innerJoin(
|
||||||
if (![2, 3].includes(row.flowchart.status)) return failure(c, 409, "retry-not-allowed", "Submission is not in a state that allows retry")
|
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 调用。
|
// canView 允许本人重试自己的提交,不限流的话学生可以反复点着刷 AI 调用。
|
||||||
// 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法
|
// 教师放行:重新判题是他们的日常操作,成批点几十行是正常用法
|
||||||
if (!isAdminRole(user)) {
|
if (!isAdminRole(user)) {
|
||||||
const throttle = await consumeToken("user", flowchartThrottleKey(user.id))
|
const throttle = await consumeToken("user", flowchartThrottleKey(user.id))
|
||||||
if (!throttle.allowed) {
|
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({
|
await db
|
||||||
status: 0, aiScore: null, aiGrade: null, aiFeedback: null, aiSuggestions: null,
|
.update(schema.flowchartSubmission)
|
||||||
aiCriteriaDetails: {}, processingTime: null, evaluationTime: null,
|
.set({
|
||||||
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
status: 0,
|
||||||
|
aiScore: null,
|
||||||
|
aiGrade: null,
|
||||||
|
aiFeedback: null,
|
||||||
|
aiSuggestions: null,
|
||||||
|
aiCriteriaDetails: {},
|
||||||
|
processingTime: null,
|
||||||
|
evaluationTime: null,
|
||||||
|
})
|
||||||
|
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||||
try {
|
try {
|
||||||
// jobId 必须**正好三段**:bullmq 对含 `:` 的自定义 id 有一条兼容老的可重复
|
// jobId 必须**正好三段**:bullmq 对含 `:` 的自定义 id 有一条兼容老的可重复
|
||||||
// 任务的校验(job.js 的 `split(':').length !== 3`),两段会直接抛
|
// 任务的校验(job.js 的 `split(':').length !== 3`),两段会直接抛
|
||||||
@@ -343,28 +646,82 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
|
|||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 入队失败就落 FAILED,别把提交丢在 PENDING 上 —— 和 POST /flowcharts 同一处理
|
// 入队失败就落 FAILED,别把提交丢在 PENDING 上 —— 和 POST /flowcharts 同一处理
|
||||||
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
await db
|
||||||
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
|
.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, createFlowchartResponseSchema.parse({ submissionId: row.flowchart.id, status: "pending" }))
|
return success(c, {
|
||||||
|
submissionId: row.flowchart.id,
|
||||||
|
status: "pending",
|
||||||
|
} satisfies CreateFlowchartResponse)
|
||||||
})
|
})
|
||||||
|
|
||||||
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
|
flowchartRoutes.get(
|
||||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
"/problems/:id/flowchart/current",
|
||||||
const rows = await db.select({ score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade })
|
requireAuth,
|
||||||
.from(schema.flowchartSubmission).where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2)))
|
async (c) => {
|
||||||
.orderBy(desc(schema.flowchartSubmission.createTime))
|
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
return success(c, flowchartCurrentSchema.parse({ count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" }))
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => {
|
flowchartRoutes.get(
|
||||||
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
"/problems/:id/flowchart/history",
|
||||||
const page = queryInteger(c.req.query("page"), 0, { min: 0 })
|
requireAuth,
|
||||||
const rows = await db.select({ flowchart: schema.flowchartSubmission, username: schema.user.username })
|
async (c) => {
|
||||||
.from(schema.flowchartSubmission).innerJoin(schema.user, eq(schema.flowchartSubmission.userId, schema.user.id))
|
const problemId = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
.where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2)))
|
const page = queryInteger(c.req.query("page"), 0, { min: 0 })
|
||||||
.orderBy(asc(schema.flowchartSubmission.createTime))
|
const rows = await db
|
||||||
const selected = page === 0 ? rows.at(-1) : rows[page - 1]
|
.select({
|
||||||
if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range")
|
flowchart: schema.flowchartSubmission,
|
||||||
return success(c, flowchartDetailSchema.parse({ submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length }))
|
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)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { sampleUserSchema, type SampleUser } from "@oj2/contract"
|
import { ADMIN_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
|
||||||
|
|
||||||
|
import { and, count, eq, ilike, notInArray } from "drizzle-orm"
|
||||||
|
|
||||||
import type { AuthUser } from "../auth/session"
|
import type { AuthUser } from "../auth/session"
|
||||||
|
import { db, schema } from "../db"
|
||||||
|
import { NON_FAILURE_RESULTS } from "../judge/status"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户对象的序列化层,对齐旧后端 `utils/api/_serializers.py` 的 `UsernameSerializer`。
|
* 用户对象的序列化层,对齐旧后端 `utils/api/_serializers.py` 的 `UsernameSerializer`。
|
||||||
@@ -17,11 +21,11 @@ export function sampleUser(
|
|||||||
realName: string | null | undefined,
|
realName: string | null | undefined,
|
||||||
options: { includeRealName?: boolean } = {},
|
options: { includeRealName?: boolean } = {},
|
||||||
): SampleUser {
|
): SampleUser {
|
||||||
return sampleUserSchema.parse({
|
return {
|
||||||
id: source.id,
|
id: source.id,
|
||||||
username: source.username,
|
username: source.username,
|
||||||
realName: options.includeRealName === true ? (realName ?? null) : null,
|
realName: options.includeRealName === true ? (realName ?? null) : null,
|
||||||
})
|
} satisfies SampleUser
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,18 +44,25 @@ export function stripClassPrefix(
|
|||||||
return username.startsWith(prefix) ? username.slice(prefix.length) : username
|
return username.startsWith(prefix) ? username.slice(prefix.length) : username
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拿 query 里的筛选值去比对 `$type` 收窄过的列(`submission.result`、`problem.difficulty` 这些)。
|
||||||
|
*
|
||||||
|
* 值来自 URL,不受控:前端下拉框以外的任何字符串都可能进来。对不上枚举时 SQL 一行都匹配不到,
|
||||||
|
* 和列没收窄之前的行为完全一致 —— 所以这里只做类型上的交接,**不加校验**:
|
||||||
|
* 在这儿拦一道会把「筛出空列表」变成「筛条件被忽略、返回全部」,那是另一种行为。
|
||||||
|
*/
|
||||||
|
export function asFilterValue<T extends string | number>(
|
||||||
|
value: string | number,
|
||||||
|
): T {
|
||||||
|
return value as T
|
||||||
|
}
|
||||||
|
|
||||||
export function objectValue(value: unknown): Record<string, unknown> {
|
export function objectValue(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (value as Record<string, unknown>)
|
||||||
: {}
|
: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stringArray(value: unknown): string[] {
|
|
||||||
return Array.isArray(value)
|
|
||||||
? value.filter((item): item is string => typeof item === "string")
|
|
||||||
: []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function queryInteger(
|
export function queryInteger(
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
fallback: number,
|
fallback: number,
|
||||||
@@ -64,14 +75,9 @@ export function queryInteger(
|
|||||||
return parsed
|
return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
// 角色判断一律用白名单,对齐旧后端 `account/models.py:65-73` 的 is_admin_role /
|
// 角色白名单本身在 `@oj2/contract` 的 roles.ts,那是全仓唯一的定义处;
|
||||||
// is_teacher_or_above 显式列举写法。
|
// 这里只是把它们包成吃 AuthUser 的谓词。为什么必须是白名单,见那边的注释。
|
||||||
//
|
export { TEACHER_ROLES }
|
||||||
// 不要写成黑名单(`adminType !== "Regular User"`):当前四种角色下两者等价,但将来新增
|
|
||||||
// 任何角色(助教、家长……)都会**默认拿到管理员权限**,包括 canViewSubmission 里的
|
|
||||||
//「看所有人代码」。加角色的人多半想不到要回来改这里,白名单则会默认拒绝。
|
|
||||||
const ADMIN_ROLES = ["Student Admin", "Teacher Admin", "Super Admin"]
|
|
||||||
const TEACHER_ROLES = ["Teacher Admin", "Super Admin"]
|
|
||||||
|
|
||||||
// 注意:不要再加 isRegularUser(user) 这类「是普通用户才受限」的判断 ——
|
// 注意:不要再加 isRegularUser(user) 这类「是普通用户才受限」的判断 ——
|
||||||
// 匿名用户 user 为 null 时它返回 false,守卫会整体短路,匿名的权限反而大于登录学生。
|
// 匿名用户 user 为 null 时它返回 false,守卫会整体短路,匿名的权限反而大于登录学生。
|
||||||
@@ -98,13 +104,62 @@ export function publicTemplates(value: unknown) {
|
|||||||
return templates
|
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) {
|
export function rounded(value: number, digits = 2) {
|
||||||
const factor = 10 ** digits
|
const factor = 10 ** digits
|
||||||
return Math.round(value * factor) / factor
|
return Math.round(value * factor) / factor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个用户在这道题上失败了几次 —— 也就是 AI 提示的解锁进度。
|
||||||
|
*
|
||||||
|
* 题目详情下发的 `myFailedCount` 和 `POST /ai/hint` 的服务端闸门必须用**同一个**口径,
|
||||||
|
* 所以两边都走这里。原来是各写各的:详情那边 `notInArray(result, [0, 10])` 把
|
||||||
|
* 等待评分 / 正在评分也算成失败,连点三次提交就能让按钮亮起来,而 hint 端点排掉了
|
||||||
|
* 这两个状态,于是按钮亮着、点下去回 `hint-locked`。
|
||||||
|
*/
|
||||||
|
export async function countFailedSubmissions(
|
||||||
|
userId: number,
|
||||||
|
problemId: number,
|
||||||
|
) {
|
||||||
|
const [failed] = await db
|
||||||
|
.select({ value: count() })
|
||||||
|
.from(schema.submission)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.submission.userId, userId),
|
||||||
|
eq(schema.submission.problemId, problemId),
|
||||||
|
notInArray(schema.submission.result, NON_FAILURE_RESULTS),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return failed?.value ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名模糊匹配到的账号。统计的两件事都从它出发:**筛哪些提交**(拿 id),
|
||||||
|
* 以及**花名册**(班级人数、谁没做,见调用处的过滤)。
|
||||||
|
*
|
||||||
|
* 这里必须查 `user` 表而不是 `submission.username` —— 后者是提交那一刻冻结的
|
||||||
|
* 快照,学生改名之后旧提交还挂着旧名字,`ilike submission.username` 匹配不上。
|
||||||
|
*
|
||||||
|
* 生产快照实测(2026-09-08):24 级数媒两个班改成编号制用户名之后,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}%`))
|
||||||
|
}
|
||||||
|
|||||||
+463
-276
@@ -1,25 +1,25 @@
|
|||||||
import {
|
import type {
|
||||||
problemAuthorSchema,
|
ProblemAuthor,
|
||||||
problemDetailSchema,
|
ProblemDetail,
|
||||||
problemListItemSchema,
|
ProblemList,
|
||||||
problemListSchema,
|
ProblemListItem,
|
||||||
tagSchema,
|
Tag,
|
||||||
yearlyAcSchema,
|
YearlyAc,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
asc,
|
asc,
|
||||||
count,
|
count,
|
||||||
countDistinct,
|
countDistinct,
|
||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
gte,
|
gte,
|
||||||
ilike,
|
ilike,
|
||||||
inArray,
|
inArray,
|
||||||
isNull,
|
isNull,
|
||||||
notInArray,
|
notInArray,
|
||||||
or,
|
or,
|
||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
|
|
||||||
@@ -28,308 +28,495 @@ import { db, schema } from "../db"
|
|||||||
import { astRequirements } from "../judge/ast"
|
import { astRequirements } from "../judge/ast"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
import { JudgeStatus } from "../judge/status"
|
import { JudgeStatus } from "../judge/status"
|
||||||
import { objectValue as toObject, queryInteger, sampleUser } from "./helpers"
|
import { localTime, shiftMonthsByCalendar, todayStart } from "../time"
|
||||||
|
import {
|
||||||
|
asFilterValue,
|
||||||
|
countFailedSubmissions,
|
||||||
|
objectValue as toObject,
|
||||||
|
queryInteger,
|
||||||
|
sampleUser,
|
||||||
|
} from "./helpers"
|
||||||
|
|
||||||
export const problemRoutes = new Hono<AppEnv>()
|
export const problemRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
function objectValue(value: unknown): Record<string, unknown> {
|
function objectValue(value: unknown): Record<string, unknown> {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (value as Record<string, unknown>)
|
||||||
: {}
|
: {}
|
||||||
}
|
|
||||||
|
|
||||||
function stringArray(value: unknown): string[] {
|
|
||||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function publicTemplates(value: unknown) {
|
function publicTemplates(value: unknown) {
|
||||||
const templates: Record<string, string> = {}
|
const templates: Record<string, string> = {}
|
||||||
for (const [language, raw] of Object.entries(objectValue(value))) {
|
for (const [language, raw] of Object.entries(objectValue(value))) {
|
||||||
if (typeof raw !== "string") continue
|
if (typeof raw !== "string") continue
|
||||||
const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/)
|
const match = raw.match(/\/\/TEMPLATE BEGIN\n([\s\S]+?)\/\/TEMPLATE END/)
|
||||||
templates[language] = match?.[1] ?? ""
|
templates[language] = match?.[1] ?? ""
|
||||||
}
|
}
|
||||||
return templates
|
return templates
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProblemStatuses(userId: number | undefined) {
|
async function getProblemStatuses(userId: number | undefined) {
|
||||||
if (!userId) return {}
|
if (!userId) return {}
|
||||||
const [profile] = await db.select({ value: schema.userProfile.acmProblemsStatus })
|
const [profile] = await db
|
||||||
.from(schema.userProfile).where(eq(schema.userProfile.userId, userId)).limit(1)
|
.select({ value: schema.userProfile.acmProblemsStatus })
|
||||||
return toObject(toObject(profile?.value).problems)
|
.from(schema.userProfile)
|
||||||
|
.where(eq(schema.userProfile.userId, userId))
|
||||||
|
.limit(1)
|
||||||
|
return toObject(toObject(profile?.value).problems)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getProblemTags(problemIds: number[]) {
|
async function getProblemTags(problemIds: number[]) {
|
||||||
if (problemIds.length === 0) return new Map<number, string[]>()
|
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
|
||||||
.from(schema.problemTags)
|
.select({
|
||||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
problemId: schema.problemTags.problemId,
|
||||||
.where(inArray(schema.problemTags.problemId, problemIds))
|
name: schema.problemTag.name,
|
||||||
const result = new Map<number, string[]>()
|
})
|
||||||
for (const row of rows) result.set(row.problemId, [...(result.get(row.problemId) ?? []), row.name])
|
.from(schema.problemTags)
|
||||||
return result
|
.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])
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
function listItem(
|
function listItem(
|
||||||
row: { problem: typeof schema.problem.$inferSelect; user: typeof schema.user.$inferSelect; realName: string | null },
|
row: {
|
||||||
tags: Map<number, string[]>,
|
problem: typeof schema.problem.$inferSelect
|
||||||
statuses: Record<string, unknown>,
|
user: typeof schema.user.$inferSelect
|
||||||
|
realName: string | null
|
||||||
|
},
|
||||||
|
tags: Map<number, string[]>,
|
||||||
|
statuses: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
const status = toObject(statuses[String(row.problem.id)]).status
|
const status = toObject(statuses[String(row.problem.id)]).status
|
||||||
return problemListItemSchema.parse({
|
return {
|
||||||
id: row.problem.id,
|
id: row.problem.id,
|
||||||
_id: row.problem.displayId,
|
_id: row.problem.displayId,
|
||||||
title: row.problem.title,
|
title: row.problem.title,
|
||||||
submissionNumber: row.problem.submissionNumber,
|
submissionNumber: row.problem.submissionNumber,
|
||||||
acceptedNumber: row.problem.acceptedNumber,
|
acceptedNumber: row.problem.acceptedNumber,
|
||||||
difficulty: row.problem.difficulty,
|
difficulty: row.problem.difficulty,
|
||||||
createdBy: sampleUser(row.user, row.realName),
|
createdBy: sampleUser(row.user, row.realName),
|
||||||
tags: tags.get(row.problem.id) ?? [],
|
tags: tags.get(row.problem.id) ?? [],
|
||||||
contestId: row.problem.contestId,
|
contestId: row.problem.contestId,
|
||||||
allowFlowchart: row.problem.allowFlowchart,
|
allowFlowchart: row.problem.allowFlowchart,
|
||||||
showFlowchart: row.problem.showFlowchart,
|
showFlowchart: row.problem.showFlowchart,
|
||||||
hasAstRules: row.problem.astRules !== null,
|
hasAstRules: row.problem.astRules !== null,
|
||||||
myStatus: typeof status === "number" ? status : null,
|
myStatus: typeof status === "number" ? status : null,
|
||||||
})
|
} satisfies ProblemListItem
|
||||||
}
|
}
|
||||||
|
|
||||||
problemRoutes.get("/problems", optionalAuth, async (c) => {
|
problemRoutes.get("/problems", optionalAuth, async (c) => {
|
||||||
const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 20, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const filters = [eq(schema.problem.visible, true), isNull(schema.problem.contestId)]
|
const filters = [
|
||||||
const author = c.req.query("author")?.trim()
|
eq(schema.problem.visible, true),
|
||||||
const keyword = c.req.query("keyword")?.trim()
|
isNull(schema.problem.contestId),
|
||||||
const difficulty = c.req.query("difficulty")?.trim()
|
]
|
||||||
const tag = c.req.query("tag")?.trim()
|
const author = c.req.query("author")?.trim()
|
||||||
if (author) filters.push(eq(schema.user.username, author))
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
if (keyword) filters.push(or(ilike(schema.problem.title, `%${keyword}%`), ilike(schema.problem.displayId, `%${keyword}%`))!)
|
const difficulty = c.req.query("difficulty")?.trim()
|
||||||
if (difficulty) filters.push(eq(schema.problem.difficulty, difficulty))
|
const tag = c.req.query("tag")?.trim()
|
||||||
if (tag) {
|
if (author) filters.push(eq(schema.user.username, author))
|
||||||
filters.push(inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags)
|
if (keyword)
|
||||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
filters.push(
|
||||||
.where(eq(schema.problemTag.name, tag))))
|
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)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const where = and(...filters)
|
const where = and(...filters)
|
||||||
const sort = c.req.query("sort")
|
const sort = c.req.query("sort")
|
||||||
const order = sort === "flowchart"
|
const order =
|
||||||
? [desc(schema.problem.allowFlowchart), desc(schema.problem.showFlowchart), desc(schema.problem.createTime)]
|
sort === "flowchart"
|
||||||
: sort === "ast"
|
? [
|
||||||
? [desc(sql`(${schema.problem.astRules} is not null)`), desc(schema.problem.createTime)]
|
desc(schema.problem.allowFlowchart),
|
||||||
: sort === "-accepted_number"
|
desc(schema.problem.showFlowchart),
|
||||||
? [desc(schema.problem.acceptedNumber)]
|
desc(schema.problem.createTime),
|
||||||
: sort === "accepted_number"
|
]
|
||||||
? [asc(schema.problem.acceptedNumber)]
|
: sort === "ast"
|
||||||
: sort === "-submission_number"
|
? [
|
||||||
? [desc(schema.problem.submissionNumber)]
|
desc(sql`(${schema.problem.astRules} is not null)`),
|
||||||
: sort === "submission_number"
|
desc(schema.problem.createTime),
|
||||||
? [asc(schema.problem.submissionNumber)]
|
]
|
||||||
: sort === "difficulty"
|
: sort === "-accepted_number"
|
||||||
? [asc(schema.problem.difficulty)]
|
? [desc(schema.problem.acceptedNumber)]
|
||||||
: sort === "create_time"
|
: sort === "accepted_number"
|
||||||
? [asc(schema.problem.createTime)]
|
? [asc(schema.problem.acceptedNumber)]
|
||||||
: [desc(schema.problem.createTime)]
|
: sort === "-submission_number"
|
||||||
const [totalRow] = await db.select({ value: countDistinct(schema.problem.id) }).from(schema.problem)
|
? [desc(schema.problem.submissionNumber)]
|
||||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)).where(where)
|
: sort === "submission_number"
|
||||||
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
? [asc(schema.problem.submissionNumber)]
|
||||||
.from(schema.problem)
|
: sort === "difficulty"
|
||||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
? [asc(schema.problem.difficulty)]
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
: sort === "create_time"
|
||||||
.where(where).orderBy(...order).limit(limit).offset(offset)
|
? [asc(schema.problem.createTime)]
|
||||||
const [tags, statuses] = await Promise.all([
|
: [desc(schema.problem.createTime)]
|
||||||
getProblemTags(rows.map((row) => row.problem.id)),
|
const [totalRow] = await db
|
||||||
getProblemStatuses(c.get("user")?.id),
|
.select({ value: countDistinct(schema.problem.id) })
|
||||||
])
|
.from(schema.problem)
|
||||||
return success(c, problemListSchema.parse({
|
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||||
results: rows.map((row) => listItem(row, tags, statuses)),
|
.where(where)
|
||||||
total: totalRow?.value ?? 0,
|
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)
|
||||||
|
const [tags, statuses] = await Promise.all([
|
||||||
|
getProblemTags(rows.map((row) => row.problem.id)),
|
||||||
|
getProblemStatuses(c.get("user")?.id),
|
||||||
|
])
|
||||||
|
return success(c, {
|
||||||
|
results: rows.map((row) => listItem(row, tags, statuses)),
|
||||||
|
total: totalRow?.value ?? 0,
|
||||||
|
} satisfies ProblemList)
|
||||||
})
|
})
|
||||||
|
|
||||||
problemRoutes.get("/problem-tags", async (c) => {
|
problemRoutes.get("/problem-tags", async (c) => {
|
||||||
const keyword = c.req.query("keyword")?.trim()
|
const keyword = c.req.query("keyword")?.trim()
|
||||||
// 只数公开题库里可见的题:隐藏的题和比赛题都不算,否则标签会出现在
|
// 只数公开题库里可见的题:隐藏的题和比赛题都不算,否则标签会出现在
|
||||||
// 首页列表里,点进去却一道题都筛不出来(对齐 /problems 的过滤条件)
|
// 首页列表里,点进去却一道题都筛不出来(对齐 /problems 的过滤条件)
|
||||||
const rows = await db.select({ id: schema.problemTag.id, name: schema.problemTag.name, problemCount: countDistinct(schema.problemTags.problemId) })
|
const rows = await db
|
||||||
.from(schema.problemTag)
|
.select({
|
||||||
.innerJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
id: schema.problemTag.id,
|
||||||
.innerJoin(schema.problem, and(
|
name: schema.problemTag.name,
|
||||||
eq(schema.problem.id, schema.problemTags.problemId),
|
problemCount: countDistinct(schema.problemTags.problemId),
|
||||||
eq(schema.problem.visible, true),
|
})
|
||||||
isNull(schema.problem.contestId),
|
.from(schema.problemTag)
|
||||||
))
|
.innerJoin(
|
||||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
schema.problemTags,
|
||||||
.groupBy(schema.problemTag.id, schema.problemTag.name).having(sql`count(${schema.problemTags.problemId}) > 0`)
|
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||||
.orderBy(asc(schema.problemTag.name))
|
)
|
||||||
return success(c, rows.map((row) => tagSchema.parse(row)))
|
.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`)
|
||||||
|
.orderBy(asc(schema.problemTag.name))
|
||||||
|
return success(c, rows satisfies Tag[])
|
||||||
})
|
})
|
||||||
|
|
||||||
problemRoutes.get("/problems/random", async (c) => {
|
problemRoutes.get("/problems/random", async (c) => {
|
||||||
const [row] = await db.select({ displayId: schema.problem.displayId }).from(schema.problem)
|
const [row] = await db
|
||||||
.where(and(eq(schema.problem.visible, true), isNull(schema.problem.contestId))).orderBy(sql`random()`).limit(1)
|
.select({ displayId: schema.problem.displayId })
|
||||||
if (!row) return failure(c, 404, "no-problems", "No problem to pick")
|
.from(schema.problem)
|
||||||
return success(c, row.displayId)
|
.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) => {
|
problemRoutes.get("/problem-authors", async (c) => {
|
||||||
const showAll = c.req.query("all") === "1"
|
const showAll = c.req.query("all") === "1"
|
||||||
const rows = await db.select({ username: schema.user.username, problemCount: count(schema.problem.id) })
|
const rows = await db
|
||||||
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
.select({
|
||||||
.where(and(isNull(schema.problem.contestId), eq(schema.user.isDisabled, false), showAll ? undefined : eq(schema.problem.visible, true)))
|
username: schema.user.username,
|
||||||
.groupBy(schema.user.username).orderBy(desc(count(schema.problem.id)))
|
problemCount: count(schema.problem.id),
|
||||||
return success(c, rows.map((row) => problemAuthorSchema.parse(row)))
|
})
|
||||||
|
.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[])
|
||||||
})
|
})
|
||||||
|
|
||||||
problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
|
problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
|
||||||
const user = c.get("user")
|
const user = c.get("user")
|
||||||
if (!user) return success(c, "0")
|
if (!user) return success(c, "0")
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const [mine] = await db.select({ value: count() }).from(schema.submission).where(and(
|
const [mine] = await db
|
||||||
eq(schema.submission.userId, user.id), eq(schema.submission.problemId, id),
|
.select({ value: count() })
|
||||||
inArray(schema.submission.result, [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]),
|
.from(schema.submission)
|
||||||
))
|
.where(
|
||||||
if (!mine?.value) return success(c, "0")
|
and(
|
||||||
const since = new Date(); since.setFullYear(since.getFullYear() - 2); since.setHours(0, 0, 0, 0)
|
eq(schema.submission.userId, user.id),
|
||||||
const [active, accepted] = await Promise.all([
|
eq(schema.submission.problemId, id),
|
||||||
db.select({ value: count() }).from(schema.user).where(and(eq(schema.user.isDisabled, false), gte(schema.user.lastLogin, since.toISOString()))),
|
inArray(schema.submission.result, [
|
||||||
db.select({ value: countDistinct(schema.submission.userId) }).from(schema.submission).where(and(
|
JudgeStatus.ACCEPTED,
|
||||||
eq(schema.submission.problemId, id), inArray(schema.submission.result, [0, 10]), gte(schema.submission.createTime, since.toISOString()),
|
JudgeStatus.AST_CHECK_FAILED,
|
||||||
)),
|
]),
|
||||||
])
|
),
|
||||||
const total = active[0]?.value ?? 0
|
)
|
||||||
const solved = accepted[0]?.value ?? 0
|
if (!mine?.value) return success(c, "0")
|
||||||
return success(c, total > 0 && solved < total ? (((total - solved) / total) * 100).toFixed(2) : "0")
|
// 「近两年」按东八区日历算到当天零点
|
||||||
|
const since = todayStart(shiftMonthsByCalendar(new Date(), -24))
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
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",
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
|
problemRoutes.get("/problems/:displayId/similar", optionalAuth, async (c) => {
|
||||||
const [target] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
const [target] = await db
|
||||||
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId))).limit(1)
|
.select({ id: schema.problem.id })
|
||||||
if (!target) return failure(c, 404, "problem-not-found", "Problem not found")
|
.from(schema.problem)
|
||||||
const targetTags = await db.select({ id: schema.problemTags.problemtagId }).from(schema.problemTags).where(eq(schema.problemTags.problemId, target.id))
|
.where(
|
||||||
if (targetTags.length === 0) return success(c, [])
|
and(
|
||||||
const rows = await db.select({ problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
|
||||||
.from(schema.problem)
|
isNull(schema.problem.contestId),
|
||||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
),
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
)
|
||||||
.where(and(
|
.limit(1)
|
||||||
eq(schema.problem.visible, true), isNull(schema.problem.contestId), sql`${schema.problem.id} <> ${target.id}`,
|
if (!target) return failure(c, 404, "problem-not-found", "Problem not found")
|
||||||
inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags)
|
const targetTags = await db
|
||||||
.where(inArray(schema.problemTags.problemtagId, targetTags.map((tag) => tag.id)))),
|
.select({ id: schema.problemTags.problemtagId })
|
||||||
)).groupBy(schema.problem.id, schema.user.id, schema.userProfile.realName).orderBy(asc(schema.problem.difficulty)).limit(5)
|
.from(schema.problemTags)
|
||||||
const [tags, statuses] = await Promise.all([getProblemTags(rows.map((row) => row.problem.id)), getProblemStatuses(c.get("user")?.id)])
|
.where(eq(schema.problemTags.problemId, target.id))
|
||||||
const filtered = rows.filter((row) => toObject(statuses[String(row.problem.id)]).status !== JudgeStatus.ACCEPTED)
|
if (targetTags.length === 0) return success(c, [])
|
||||||
return success(c, filtered.map((row) => listItem(row, tags, statuses)))
|
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)),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
|
problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
|
||||||
const [problem] = await db.select({ id: schema.problem.id }).from(schema.problem)
|
const [problem] = await db
|
||||||
.where(and(sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`, isNull(schema.problem.contestId), eq(schema.problem.visible, true))).limit(1)
|
.select({ id: schema.problem.id })
|
||||||
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
|
.from(schema.problem)
|
||||||
const year = sql<number>`extract(year from ${schema.submission.createTime})::int`
|
.where(
|
||||||
const rows = await db.select({
|
and(
|
||||||
year,
|
sql`lower(${schema.problem.displayId}) = lower(${c.req.param("displayId")})`,
|
||||||
total: count(),
|
isNull(schema.problem.contestId),
|
||||||
accepted: sql<number>`count(*) filter (where ${schema.submission.result} in (0, 10))::int`,
|
eq(schema.problem.visible, true),
|
||||||
}).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) => yearlyAcSchema.parse({ ...row, acRate: row.total > 0 ? Math.round(row.accepted / row.total * 10_000) / 100 : 0 })))
|
.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({
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
|
problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
problem: schema.problem,
|
problem: schema.problem,
|
||||||
creatorId: schema.user.id,
|
creatorId: schema.user.id,
|
||||||
creatorUsername: schema.user.username,
|
creatorUsername: schema.user.username,
|
||||||
})
|
})
|
||||||
.from(schema.problem)
|
.from(schema.problem)
|
||||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(schema.problem.displayId, c.req.param("displayId")),
|
eq(schema.problem.displayId, c.req.param("displayId")),
|
||||||
eq(schema.problem.visible, true),
|
eq(schema.problem.visible, true),
|
||||||
isNull(schema.problem.contestId),
|
isNull(schema.problem.contestId),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1)
|
.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
|
const tagRows = await db
|
||||||
.select({ name: schema.problemTag.name })
|
.select({ name: schema.problemTag.name })
|
||||||
.from(schema.problemTags)
|
.from(schema.problemTags)
|
||||||
.innerJoin(
|
.innerJoin(
|
||||||
schema.problemTag,
|
schema.problemTag,
|
||||||
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
eq(schema.problemTags.problemtagId, schema.problemTag.id),
|
||||||
)
|
)
|
||||||
.where(eq(schema.problemTags.problemId, row.problem.id))
|
.where(eq(schema.problemTags.problemId, row.problem.id))
|
||||||
|
|
||||||
const user = c.get("user")
|
const user = c.get("user")
|
||||||
let myStatus: number | null = null
|
let myStatus: number | null = null
|
||||||
let myFailedCount = 0
|
let myFailedCount = 0
|
||||||
if (user) {
|
if (user) {
|
||||||
const [profile] = await db
|
const [profile] = await db
|
||||||
.select({ status: schema.userProfile.acmProblemsStatus })
|
.select({ status: schema.userProfile.acmProblemsStatus })
|
||||||
.from(schema.userProfile)
|
.from(schema.userProfile)
|
||||||
.where(eq(schema.userProfile.userId, user.id))
|
.where(eq(schema.userProfile.userId, user.id))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
const statuses = objectValue(objectValue(profile?.status).problems)
|
const statuses = objectValue(objectValue(profile?.status).problems)
|
||||||
const problemStatus = objectValue(statuses[String(row.problem.id)]).status
|
const problemStatus = objectValue(statuses[String(row.problem.id)]).status
|
||||||
if (typeof problemStatus === "number") myStatus = problemStatus
|
if (typeof problemStatus === "number") myStatus = problemStatus
|
||||||
|
|
||||||
const [failed] = await db
|
// 前端拿这个数决定「让 AI 分析我的代码」露不露面,口径必须和 POST /ai/hint
|
||||||
.select({ value: count() })
|
// 的服务端闸门一致,所以两边共用 countFailedSubmissions
|
||||||
.from(schema.submission)
|
myFailedCount = await countFailedSubmissions(user.id, row.problem.id)
|
||||||
.where(
|
}
|
||||||
and(
|
|
||||||
eq(schema.submission.userId, user.id),
|
|
||||||
eq(schema.submission.problemId, row.problem.id),
|
|
||||||
notInArray(schema.submission.result, [0, 10]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
myFailedCount = failed?.value ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
|
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
|
||||||
const data = problemDetailSchema.parse({
|
const data = {
|
||||||
id: row.problem.id,
|
id: row.problem.id,
|
||||||
_id: row.problem.displayId,
|
_id: row.problem.displayId,
|
||||||
title: row.problem.title,
|
title: row.problem.title,
|
||||||
description: row.problem.description,
|
description: row.problem.description,
|
||||||
inputDescription: row.problem.inputDescription,
|
inputDescription: row.problem.inputDescription,
|
||||||
outputDescription: row.problem.outputDescription,
|
outputDescription: row.problem.outputDescription,
|
||||||
samples,
|
samples,
|
||||||
hint: row.problem.hint,
|
hint: row.problem.hint,
|
||||||
languages: stringArray(row.problem.languages),
|
languages: row.problem.languages,
|
||||||
template: publicTemplates(row.problem.template),
|
template: publicTemplates(row.problem.template),
|
||||||
createTime: row.problem.createTime,
|
createTime: row.problem.createTime,
|
||||||
lastUpdateTime: row.problem.lastUpdateTime,
|
lastUpdateTime: row.problem.lastUpdateTime,
|
||||||
timeLimit: row.problem.timeLimit,
|
timeLimit: row.problem.timeLimit,
|
||||||
memoryLimit: row.problem.memoryLimit,
|
memoryLimit: row.problem.memoryLimit,
|
||||||
difficulty: row.problem.difficulty,
|
difficulty: row.problem.difficulty,
|
||||||
source: row.problem.source,
|
source: row.problem.source,
|
||||||
prompt: row.problem.prompt,
|
prompt: row.problem.prompt,
|
||||||
submissionNumber: row.problem.submissionNumber,
|
submissionNumber: row.problem.submissionNumber,
|
||||||
acceptedNumber: row.problem.acceptedNumber,
|
acceptedNumber: row.problem.acceptedNumber,
|
||||||
statisticInfo: objectValue(row.problem.statisticInfo),
|
statisticInfo: objectValue(row.problem.statisticInfo),
|
||||||
shareSubmission: row.problem.shareSubmission,
|
contestId: row.problem.contestId,
|
||||||
contestId: row.problem.contestId,
|
tags: tagRows.map((tag) => tag.name),
|
||||||
tags: tagRows.map((tag) => tag.name),
|
createdBy: sampleUser(
|
||||||
createdBy: sampleUser({ id: row.creatorId, username: row.creatorUsername }, null),
|
{ id: row.creatorId, username: row.creatorUsername },
|
||||||
myStatus,
|
null,
|
||||||
myFailedCount,
|
),
|
||||||
allowFlowchart: row.problem.allowFlowchart,
|
myStatus,
|
||||||
showFlowchart: row.problem.showFlowchart,
|
myFailedCount,
|
||||||
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
allowFlowchart: row.problem.allowFlowchart,
|
||||||
flowchartData: row.problem.allowFlowchart
|
showFlowchart: row.problem.showFlowchart,
|
||||||
? null
|
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
|
||||||
: objectValue(row.problem.flowchartData),
|
flowchartData: row.problem.allowFlowchart
|
||||||
flowchartHint: row.problem.flowchartHint,
|
? null
|
||||||
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
|
: objectValue(row.problem.flowchartData),
|
||||||
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
|
flowchartHint: row.problem.flowchartHint,
|
||||||
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
sqlConfig: row.problem.sqlConfig,
|
||||||
astRequirements: astRequirements(row.problem.astRules),
|
sqlDisplay: row.problem.sqlDisplay,
|
||||||
})
|
// 代码要求:只给渲染好的文案,规则原文不下发给学生
|
||||||
|
astRequirements: astRequirements(row.problem.astRules),
|
||||||
|
} satisfies ProblemDetail
|
||||||
|
|
||||||
return success(c, data)
|
return success(c, data)
|
||||||
})
|
})
|
||||||
|
|||||||
+467
-306
@@ -1,14 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
problemListItemSchema,
|
|
||||||
problemSetBadgeSchema,
|
|
||||||
problemSetListSchema,
|
|
||||||
problemSetProblemSchema,
|
|
||||||
problemSetProgressListSchema,
|
|
||||||
problemSetProgressSchema,
|
|
||||||
problemSetSchema,
|
|
||||||
updateProblemSetProgressRequestSchema,
|
|
||||||
joinProblemSetRequestSchema,
|
joinProblemSetRequestSchema,
|
||||||
userBadgeSchema,
|
type ProblemSet,
|
||||||
|
type ProblemSetBadge,
|
||||||
|
type ProblemSetList,
|
||||||
|
type ProblemSetProblem,
|
||||||
|
type ProblemSetProgress,
|
||||||
|
type ProblemSetProgressList,
|
||||||
|
type UserBadge,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import {
|
import {
|
||||||
and,
|
and,
|
||||||
@@ -20,53 +18,68 @@ import {
|
|||||||
gt,
|
gt,
|
||||||
ilike,
|
ilike,
|
||||||
inArray,
|
inArray,
|
||||||
isNull,
|
|
||||||
ne,
|
ne,
|
||||||
or,
|
or,
|
||||||
sql,
|
sql,
|
||||||
} from "drizzle-orm"
|
} from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
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 { db, schema } from "../db"
|
||||||
import { publishAchievementNotification } from "../events"
|
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
import { JudgeStatus } from "../judge/status"
|
import { computeProgress } from "../services/problemset"
|
||||||
import { updateAchievementsForProblemSet } from "../services/achievements"
|
import { asFilterValue, objectValue, queryInteger, sampleUser } from "./helpers"
|
||||||
import { objectValue, queryInteger, sampleUser } from "./helpers"
|
|
||||||
|
|
||||||
export const problemsetRoutes = new Hono<AppEnv>()
|
export const problemsetRoutes = new Hono<AppEnv>()
|
||||||
|
|
||||||
type ProblemSetRow = typeof schema.problemset.$inferSelect
|
type ProblemSetRow = typeof schema.problemset.$inferSelect
|
||||||
|
|
||||||
function progressSummary(progress: typeof schema.problemsetProgress.$inferSelect | undefined) {
|
function progressSummary(
|
||||||
return progress ? {
|
progress: typeof schema.problemsetProgress.$inferSelect | undefined,
|
||||||
isJoined: true,
|
) {
|
||||||
progressPercentage: progress.progressPercentage,
|
return progress
|
||||||
completedCount: progress.completedProblemsCount,
|
? {
|
||||||
totalCount: progress.totalProblemsCount,
|
isJoined: true,
|
||||||
isCompleted: progress.isCompleted,
|
progressPercentage: progress.progressPercentage,
|
||||||
} : {
|
completedCount: progress.completedProblemsCount,
|
||||||
isJoined: false,
|
totalCount: progress.totalProblemsCount,
|
||||||
progressPercentage: 0,
|
isCompleted: progress.isCompleted,
|
||||||
completedCount: 0,
|
}
|
||||||
totalCount: 0,
|
: {
|
||||||
isCompleted: false,
|
isJoined: false,
|
||||||
}
|
progressPercentage: 0,
|
||||||
|
completedCount: 0,
|
||||||
|
totalCount: 0,
|
||||||
|
isCompleted: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function problemSetCreators(ids: number[]) {
|
async function problemSetCreators(ids: number[]) {
|
||||||
const map = new Map<number, ReturnType<typeof sampleUser>>()
|
const map = new Map<number, ReturnType<typeof sampleUser>>()
|
||||||
if (ids.length === 0) return map
|
if (ids.length === 0) return map
|
||||||
const rows = await db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName })
|
const rows = await db
|
||||||
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
.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))
|
.where(inArray(schema.user.id, ids))
|
||||||
for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
|
for (const row of rows) map.set(row.id, sampleUser(row, row.realName))
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) {
|
function badgeData(
|
||||||
return problemSetBadgeSchema.parse({
|
badge: typeof schema.problemsetBadge.$inferSelect,
|
||||||
|
earned?: boolean,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
id: badge.id,
|
id: badge.id,
|
||||||
problemsetId: badge.problemsetId,
|
problemsetId: badge.problemsetId,
|
||||||
name: badge.name,
|
name: badge.name,
|
||||||
@@ -75,7 +88,7 @@ function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: b
|
|||||||
conditionType: badge.conditionType,
|
conditionType: badge.conditionType,
|
||||||
conditionValue: badge.conditionValue,
|
conditionValue: badge.conditionValue,
|
||||||
isEarned: earned,
|
isEarned: earned,
|
||||||
})
|
} satisfies ProblemSetBadge
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,34 +104,78 @@ async function serializeProblemSets(
|
|||||||
) {
|
) {
|
||||||
if (rows.length === 0) return []
|
if (rows.length === 0) return []
|
||||||
const ids = rows.map((row) => row.id)
|
const ids = rows.map((row) => row.id)
|
||||||
const [problemCounts, progresses, badges, earnedRows, creators] = await Promise.all([
|
const [problemCounts, progresses, badges, earnedRows, creators] =
|
||||||
db.select({ problemsetId: schema.problemsetProblem.problemsetId, value: count() })
|
await Promise.all([
|
||||||
.from(schema.problemsetProblem).where(inArray(schema.problemsetProblem.problemsetId, ids))
|
db
|
||||||
.groupBy(schema.problemsetProblem.problemsetId),
|
.select({
|
||||||
userId ? db.select().from(schema.problemsetProgress)
|
problemsetId: schema.problemsetProblem.problemsetId,
|
||||||
.where(and(inArray(schema.problemsetProgress.problemsetId, ids), eq(schema.problemsetProgress.userId, userId)))
|
value: count(),
|
||||||
: Promise.resolve([] as (typeof schema.problemsetProgress.$inferSelect)[]),
|
})
|
||||||
includeBadges ? db.select().from(schema.problemsetBadge)
|
.from(schema.problemsetProblem)
|
||||||
.where(inArray(schema.problemsetBadge.problemsetId, ids)).orderBy(asc(schema.problemsetBadge.id))
|
.where(inArray(schema.problemsetProblem.problemsetId, ids))
|
||||||
: Promise.resolve([] as (typeof schema.problemsetBadge.$inferSelect)[]),
|
.groupBy(schema.problemsetProblem.problemsetId),
|
||||||
includeBadges && userId ? db.select({ id: schema.userBadge.badgeId }).from(schema.userBadge)
|
userId
|
||||||
.innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id))
|
? db
|
||||||
.where(and(eq(schema.userBadge.userId, userId), inArray(schema.problemsetBadge.problemsetId, ids)))
|
.select()
|
||||||
: Promise.resolve([] as { id: number }[]),
|
.from(schema.problemsetProgress)
|
||||||
problemSetCreators([...new Set(rows.map((row) => row.createdById))]),
|
.where(
|
||||||
])
|
and(
|
||||||
const countBySet = new Map(problemCounts.map((item) => [item.problemsetId, item.value]))
|
inArray(schema.problemsetProgress.problemsetId, ids),
|
||||||
const progressBySet = new Map(progresses.map((item) => [item.problemsetId, item]))
|
eq(schema.problemsetProgress.userId, userId),
|
||||||
const badgesBySet = new Map<number, (typeof schema.problemsetBadge.$inferSelect)[]>()
|
),
|
||||||
for (const badge of badges) badgesBySet.set(badge.problemsetId, [...(badgesBySet.get(badge.problemsetId) ?? []), badge])
|
)
|
||||||
|
: 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),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 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 earned = new Set(earnedRows.map((item) => item.id))
|
const earned = new Set(earnedRows.map((item) => item.id))
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const progress = progressBySet.get(row.id)
|
const progress = progressBySet.get(row.id)
|
||||||
return problemSetSchema.parse({
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
title: row.title,
|
title: row.title,
|
||||||
description: row.description,
|
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,
|
createTime: row.createTime,
|
||||||
lastUpdateTime: row.lastUpdateTime,
|
lastUpdateTime: row.lastUpdateTime,
|
||||||
difficulty: row.difficulty,
|
difficulty: row.difficulty,
|
||||||
@@ -128,36 +185,65 @@ async function serializeProblemSets(
|
|||||||
problemsCount: countBySet.get(row.id) ?? 0,
|
problemsCount: countBySet.get(row.id) ?? 0,
|
||||||
completedCount: progress?.completedProblemsCount ?? 0,
|
completedCount: progress?.completedProblemsCount ?? 0,
|
||||||
userProgress: progressSummary(progress),
|
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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
|
problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
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 keyword = c.req.query("keyword")?.trim()
|
||||||
const difficulty = c.req.query("difficulty")?.trim()
|
const difficulty = c.req.query("difficulty")?.trim()
|
||||||
const status = c.req.query("status")?.trim()
|
const status = c.req.query("status")?.trim()
|
||||||
if (keyword) filters.push(or(ilike(schema.problemset.title, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`))!)
|
if (keyword)
|
||||||
if (difficulty) filters.push(eq(schema.problemset.difficulty, difficulty))
|
filters.push(
|
||||||
if (status) filters.push(eq(schema.problemset.status, status))
|
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 where = and(...filters)
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.problemset).where(where),
|
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, problemSetListSchema.parse({
|
return success(c, {
|
||||||
results: await serializeProblemSets(rows, c.get("user")?.id, true),
|
results: await serializeProblemSets(rows, c.get("user")?.id, true),
|
||||||
total: totalRows[0]?.value ?? 0,
|
total: totalRows[0]?.value ?? 0,
|
||||||
}))
|
} satisfies ProblemSetList)
|
||||||
})
|
})
|
||||||
|
|
||||||
problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
|
problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const [row] = await db.select().from(schema.problemset)
|
const [row] = await db
|
||||||
.where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
|
.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", "题单不存在")
|
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
const [data] = await serializeProblemSets([row], c.get("user")?.id)
|
const [data] = await serializeProblemSets([row], c.get("user")?.id)
|
||||||
return success(c, data)
|
return success(c, data)
|
||||||
@@ -165,48 +251,71 @@ problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => {
|
|||||||
|
|
||||||
problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset)
|
const [problemSet] = await db
|
||||||
.where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
|
.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", "题单不存在")
|
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
const rows = await db.select({ link: schema.problemsetProblem, problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
// 只取卡片要渲染的四列。取 schema.problem 整行会把题面、样例、答案、ast_rules、
|
||||||
.from(schema.problemsetProblem).innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
// flowchart_data、sql_display 一起拉回来,题单页一个都不用。
|
||||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
//
|
||||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而卡片是按数组
|
||||||
.where(eq(schema.problemsetProblem.problemsetId, id)).orderBy(asc(schema.problemsetProblem.order))
|
// 下标编号的(#1 #2 #3),题单 8 / 11 / 14 实际就存在 order 重复,不定死的话
|
||||||
const problemIds = rows.map((row) => row.problem.id)
|
// 「第 3 题」指哪道题每次刷新都可能不一样。后台那条列表一直是这么排的。
|
||||||
const [tagRows, progressRows] = await Promise.all([
|
const rows = await db
|
||||||
problemIds.length ? db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags)
|
.select({
|
||||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)).where(inArray(schema.problemTags.problemId, problemIds)) : Promise.resolve([]),
|
link: schema.problemsetProblem,
|
||||||
c.get("user") ? db.select({ detail: schema.problemsetProgress.progressDetail }).from(schema.problemsetProgress)
|
problemId: schema.problem.id,
|
||||||
.where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, c.get("user")!.id))).limit(1) : Promise.resolve([]),
|
displayId: schema.problem.displayId,
|
||||||
])
|
title: schema.problem.title,
|
||||||
const tags = new Map<number, string[]>()
|
difficulty: schema.problem.difficulty,
|
||||||
for (const tag of tagRows) tags.set(tag.problemId, [...(tags.get(tag.problemId) ?? []), tag.name])
|
})
|
||||||
|
.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),
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
: []
|
||||||
const completed = objectValue(progressRows[0]?.detail)
|
const completed = objectValue(progressRows[0]?.detail)
|
||||||
return success(c, rows.map(({ link, problem, user, realName }) => problemSetProblemSchema.parse({
|
return success(
|
||||||
id: link.id,
|
c,
|
||||||
problemsetId: link.problemsetId,
|
rows.map(
|
||||||
problem: problemListItemSchema.parse({
|
({ link, problemId, displayId, title, difficulty }) =>
|
||||||
id: problem.id,
|
({
|
||||||
_id: problem.displayId,
|
id: link.id,
|
||||||
title: problem.title,
|
problemsetId: link.problemsetId,
|
||||||
submissionNumber: problem.submissionNumber,
|
problem: { id: problemId, _id: displayId, title, difficulty },
|
||||||
acceptedNumber: problem.acceptedNumber,
|
order: link.order,
|
||||||
difficulty: problem.difficulty,
|
isRequired: link.isRequired,
|
||||||
createdBy: sampleUser(user, realName),
|
score: link.score,
|
||||||
tags: tags.get(problem.id) ?? [],
|
hint: link.hint,
|
||||||
contestId: problem.contestId,
|
isCompleted: String(problemId) in completed,
|
||||||
allowFlowchart: problem.allowFlowchart,
|
}) satisfies ProblemSetProblem,
|
||||||
showFlowchart: problem.showFlowchart,
|
),
|
||||||
hasAstRules: problem.astRules !== null,
|
)
|
||||||
myStatus: null,
|
|
||||||
}),
|
|
||||||
order: link.order,
|
|
||||||
isRequired: link.isRequired,
|
|
||||||
score: link.score,
|
|
||||||
hint: link.hint,
|
|
||||||
isCompleted: String(problem.id) in completed,
|
|
||||||
})))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
async function recomputeProgress(
|
async function recomputeProgress(
|
||||||
@@ -214,226 +323,278 @@ async function recomputeProgress(
|
|||||||
progress: typeof schema.problemsetProgress.$inferSelect,
|
progress: typeof schema.problemsetProgress.$inferSelect,
|
||||||
detail: Record<string, unknown>,
|
detail: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
const links = await tx.select({ problemId: schema.problemsetProblem.problemId, score: schema.problemsetProblem.score })
|
const links = await tx
|
||||||
.from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
|
.select({
|
||||||
const valid = new Map(links.map((link) => [String(link.problemId), link.score]))
|
problemId: schema.problemsetProblem.problemId,
|
||||||
for (const key of Object.keys(detail)) if (!valid.has(key)) delete detail[key]
|
score: schema.problemsetProblem.score,
|
||||||
let totalScore = 0
|
isRequired: schema.problemsetProblem.isRequired,
|
||||||
for (const [key, value] of Object.entries(detail)) {
|
})
|
||||||
const score = valid.get(key)
|
.from(schema.problemsetProblem)
|
||||||
if (score === undefined) continue
|
.where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
|
||||||
totalScore += score
|
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份,
|
||||||
detail[key] = { ...objectValue(value), score }
|
// 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed
|
||||||
}
|
const update = computeProgress(detail, links, progress.completeTime)
|
||||||
const completed = Object.keys(detail).length
|
await tx
|
||||||
const total = links.length
|
.update(schema.problemsetProgress)
|
||||||
const isCompleted = completed === total
|
.set(update)
|
||||||
const update = {
|
.where(eq(schema.problemsetProgress.id, progress.id))
|
||||||
progressDetail: detail,
|
|
||||||
totalProblemsCount: total,
|
|
||||||
completedProblemsCount: completed,
|
|
||||||
totalScore,
|
|
||||||
progressPercentage: total > 0 ? completed / total * 100 : 0,
|
|
||||||
isCompleted,
|
|
||||||
completeTime: isCompleted ? progress.completeTime ?? new Date().toISOString() : null,
|
|
||||||
}
|
|
||||||
await tx.update(schema.problemsetProgress).set(update).where(eq(schema.problemsetProgress.id, progress.id))
|
|
||||||
return { ...progress, ...update }
|
return { ...progress, ...update }
|
||||||
}
|
}
|
||||||
|
|
||||||
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
problemsetRoutes.post("/problem-set-progress", requireAuth, async (c) => {
|
||||||
const parsed = joinProblemSetRequestSchema.safeParse(await c.req.json().catch(() => null))
|
const parsed = joinProblemSetRequestSchema.safeParse(
|
||||||
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid problem set")
|
await c.req.json().catch(() => null),
|
||||||
|
)
|
||||||
|
if (!parsed.success)
|
||||||
|
return failure(c, 400, "invalid-request", "Invalid problem set")
|
||||||
const user = c.get("user")!
|
const user = c.get("user")!
|
||||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset)
|
const [problemSet] = await db
|
||||||
.where(and(eq(schema.problemset.id, parsed.data.problemSetId), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
|
.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", "题单不存在")
|
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
const [existing] = await db.select({ id: schema.problemsetProgress.id }).from(schema.problemsetProgress)
|
const [existing] = await db
|
||||||
.where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id))).limit(1)
|
.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", "已经加入该题单")
|
if (existing) return failure(c, 409, "already-joined", "已经加入该题单")
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const [created] = await tx.insert(schema.problemsetProgress).values({
|
const [created] = await tx
|
||||||
problemsetId: problemSet.id,
|
.insert(schema.problemsetProgress)
|
||||||
userId: user.id,
|
.values({
|
||||||
joinTime: new Date().toISOString(),
|
problemsetId: problemSet.id,
|
||||||
completeTime: null,
|
userId: user.id,
|
||||||
isCompleted: false,
|
joinTime: new Date().toISOString(),
|
||||||
progressPercentage: 0,
|
completeTime: null,
|
||||||
completedProblemsCount: 0,
|
isCompleted: false,
|
||||||
totalProblemsCount: 0,
|
progressPercentage: 0,
|
||||||
totalScore: 0,
|
completedProblemsCount: 0,
|
||||||
progressDetail: {},
|
totalProblemsCount: 0,
|
||||||
}).returning()
|
totalScore: 0,
|
||||||
|
progressDetail: {},
|
||||||
|
})
|
||||||
|
.returning()
|
||||||
if (created) await recomputeProgress(tx, created, {})
|
if (created) await recomputeProgress(tx, created, {})
|
||||||
})
|
})
|
||||||
return success(c, null, 201)
|
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))
|
|
||||||
const hits = badges.filter((badge) => badge.conditionType === "all_problems"
|
|
||||||
? updated.totalProblemsCount > 0 && updated.completedProblemsCount === updated.totalProblemsCount
|
|
||||||
: badge.conditionType === "problem_count"
|
|
||||||
? updated.completedProblemsCount >= badge.conditionValue
|
|
||||||
: badge.conditionType === "score" && updated.totalScore >= badge.conditionValue)
|
|
||||||
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) => {
|
problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
|
||||||
const requested = c.req.param("username")
|
const requested = c.req.param("username")
|
||||||
const username = requested === "me" ? c.get("user")?.username : requested
|
const username = requested === "me" ? c.get("user")?.username : requested
|
||||||
if (!username) return failure(c, 401, "login-required", "Authentication required")
|
if (!username)
|
||||||
const [target] = await db.select({ id: schema.user.id }).from(schema.user)
|
return failure(c, 401, "login-required", "Authentication required")
|
||||||
.where(and(eq(schema.user.username, username), eq(schema.user.isDisabled, false))).limit(1)
|
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", "用户不存在")
|
if (!target) return failure(c, 404, "user-not-found", "用户不存在")
|
||||||
const rows = await db.select({ userBadge: schema.userBadge, badge: schema.problemsetBadge, problemSet: schema.problemset })
|
const rows = await db
|
||||||
.from(schema.userBadge).innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id))
|
.select({
|
||||||
.innerJoin(schema.problemset, eq(schema.problemsetBadge.problemsetId, schema.problemset.id))
|
userBadge: schema.userBadge,
|
||||||
.where(eq(schema.userBadge.userId, target.id)).orderBy(desc(schema.userBadge.earnedTime))
|
badge: schema.problemsetBadge,
|
||||||
return success(c, rows.map(({ userBadge, badge, problemSet }) => userBadgeSchema.parse({
|
problemSet: schema.problemset,
|
||||||
id: userBadge.id,
|
})
|
||||||
userId: userBadge.userId,
|
.from(schema.userBadge)
|
||||||
badge: badgeData(badge),
|
.innerJoin(
|
||||||
earnedTime: userBadge.earnedTime,
|
schema.problemsetBadge,
|
||||||
problemset: { id: problemSet.id, title: problemSet.title },
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
|
problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset).where(and(
|
const [problemSet] = await db
|
||||||
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
|
.select({ id: schema.problemset.id })
|
||||||
)).limit(1)
|
.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", "题单不存在")
|
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
const badges = await db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, id))
|
const badges = await db
|
||||||
return success(c, badges.map((badge) => badgeData(badge)))
|
.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(
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
"/problem-sets/:id/user-progress",
|
||||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset).where(and(
|
requireTeacher,
|
||||||
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
|
async (c) => {
|
||||||
)).limit(1)
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
const [problemSet] = await db
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
.select({
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
id: schema.problemset.id,
|
||||||
const className = c.req.query("className")?.trim()
|
createdById: schema.problemset.createdById,
|
||||||
const completion = c.req.query("completionStatus")?.trim()
|
})
|
||||||
const filters = [eq(schema.problemsetProgress.problemsetId, id)]
|
.from(schema.problemset)
|
||||||
if (className) filters.push(ilike(schema.user.username, `%${className}%`))
|
.where(
|
||||||
if (completion === "completed") filters.push(eq(schema.problemsetProgress.isCompleted, true))
|
and(
|
||||||
else if (completion === "in_progress") filters.push(and(eq(schema.problemsetProgress.isCompleted, false), gt(schema.problemsetProgress.completedProblemsCount, 0))!)
|
eq(schema.problemset.id, id),
|
||||||
else if (completion === "not_started") filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0))
|
eq(schema.problemset.visible, true),
|
||||||
const where = and(...filters)
|
ne(schema.problemset.status, "draft"),
|
||||||
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),
|
.limit(1)
|
||||||
db.select({ progress: schema.problemsetProgress, user: schema.user, realName: schema.userProfile.realName })
|
// 归属校验,和后台那条同类接口(admin/problemset.ts 的 loadOwned)一致:超管放行,
|
||||||
.from(schema.problemsetProgress).innerJoin(schema.user, eq(schema.problemsetProgress.userId, schema.user.id))
|
// 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。
|
||||||
.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),
|
const user = c.get("user")!
|
||||||
db.select({ id: schema.problem.id, _id: schema.problem.displayId, title: schema.problem.title }).from(schema.problemsetProblem)
|
if (
|
||||||
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
!problemSet ||
|
||||||
.where(eq(schema.problemsetProblem.problemsetId, id)).orderBy(asc(schema.problemsetProblem.order)),
|
(user.adminType !== "Super Admin" && problemSet.createdById !== user.id)
|
||||||
])
|
) {
|
||||||
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem]))
|
return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||||
const results = rows.map(({ progress, user: progressUser, realName }) => problemSetProgressSchema.parse({
|
}
|
||||||
id: progress.id,
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
problemsetId: progress.problemsetId,
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
user: sampleUser(progressUser, realName),
|
const className = c.req.query("className")?.trim()
|
||||||
joinTime: progress.joinTime,
|
const completion = c.req.query("completionStatus")?.trim()
|
||||||
completeTime: progress.completeTime,
|
const filters = [eq(schema.problemsetProgress.problemsetId, id)]
|
||||||
isCompleted: progress.isCompleted,
|
if (className) filters.push(ilike(schema.user.username, `%${className}%`))
|
||||||
progressPercentage: progress.progressPercentage,
|
if (completion === "completed")
|
||||||
completedProblemsCount: progress.completedProblemsCount,
|
filters.push(eq(schema.problemsetProgress.isCompleted, true))
|
||||||
totalProblemsCount: progress.totalProblemsCount,
|
else if (completion === "in_progress")
|
||||||
totalScore: progress.totalScore,
|
filters.push(
|
||||||
completedProblems: Object.keys(objectValue(progress.progressDetail)).flatMap((key) => problemMap.get(key) ?? []),
|
and(
|
||||||
}))
|
eq(schema.problemsetProgress.isCompleted, false),
|
||||||
const stats = statsRows[0]
|
gt(schema.problemsetProgress.completedProblemsCount, 0),
|
||||||
return success(c, problemSetProgressListSchema.parse({
|
)!,
|
||||||
results,
|
)
|
||||||
total: stats?.total ?? 0,
|
else if (completion === "not_started")
|
||||||
statistics: { total: stats?.total ?? 0, completed: stats?.completed ?? 0, avgProgress: Number(stats?.avgProgress ?? 0) },
|
filters.push(eq(schema.problemsetProgress.completedProblemsCount, 0))
|
||||||
problems: problemRows,
|
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),
|
||||||
|
)
|
||||||
|
.where(eq(schema.problemsetProblem.problemsetId, 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 }) =>
|
||||||
|
({
|
||||||
|
id: progress.id,
|
||||||
|
problemsetId: progress.problemsetId,
|
||||||
|
user: sampleUser(progressUser, realName),
|
||||||
|
joinTime: progress.joinTime,
|
||||||
|
completeTime: progress.completeTime,
|
||||||
|
isCompleted: progress.isCompleted,
|
||||||
|
progressPercentage: progress.progressPercentage,
|
||||||
|
completedProblemsCount: progress.completedProblemsCount,
|
||||||
|
totalProblemsCount: progress.totalProblemsCount,
|
||||||
|
totalScore: progress.totalScore,
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
problems: problemRows,
|
||||||
|
} satisfies ProblemSetProgressList)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
+43
-18
@@ -1,8 +1,9 @@
|
|||||||
import { quoteSchema, websiteConfigSchema } from "@oj2/contract"
|
import type { OnlineCount, Quote, WebsiteConfig } from "@oj2/contract"
|
||||||
import { asc, desc, eq } from "drizzle-orm"
|
import { asc, desc, eq } from "drizzle-orm"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { resolve } from "node:path"
|
import { resolve } from "node:path"
|
||||||
|
|
||||||
|
import { onlineCount } from "../auth/presence"
|
||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { failure, success } from "../http"
|
import { failure, success } from "../http"
|
||||||
@@ -13,7 +14,7 @@ export const siteRoutes = new Hono()
|
|||||||
|
|
||||||
siteRoutes.get("/site", async (c) => {
|
siteRoutes.get("/site", async (c) => {
|
||||||
const options = await getWebsiteOptions()
|
const options = await getWebsiteOptions()
|
||||||
return success(c, websiteConfigSchema.parse({
|
return success(c, {
|
||||||
websiteBaseUrl: options.website_base_url,
|
websiteBaseUrl: options.website_base_url,
|
||||||
websiteName: options.website_name,
|
websiteName: options.website_name,
|
||||||
websiteNameShortcut: options.website_name_shortcut,
|
websiteNameShortcut: options.website_name_shortcut,
|
||||||
@@ -22,12 +23,23 @@ siteRoutes.get("/site", async (c) => {
|
|||||||
submissionListShowAll: options.submission_list_show_all,
|
submissionListShowAll: options.submission_list_show_all,
|
||||||
classList: options.class_list,
|
classList: options.class_list,
|
||||||
enableMaxkb: options.enable_maxkb,
|
enableMaxkb: options.enable_maxkb,
|
||||||
}))
|
} satisfies WebsiteConfig)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前在线人数。匿名可读 —— 一个聚合数字不暴露任何人的身份,
|
||||||
|
* 而榜单页本身就允许匿名看。谁在线是另一回事,只在 /rankings/users 里对老师下发。
|
||||||
|
*/
|
||||||
|
siteRoutes.get("/site/online", async (c) => {
|
||||||
|
return success(c, { count: await onlineCount() } satisfies OnlineCount)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里)
|
// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里)
|
||||||
const fallbackQuotes = [
|
const fallbackQuotes = [
|
||||||
{ hitokoto: "程序首先是写给人读的,其次才是让机器执行。", from: "Structure and Interpretation of Computer Programs" },
|
{
|
||||||
|
hitokoto: "程序首先是写给人读的,其次才是让机器执行。",
|
||||||
|
from: "Structure and Interpretation of Computer Programs",
|
||||||
|
},
|
||||||
{ hitokoto: "把大问题拆成足够小的问题,答案就会浮现。", from: "判题狗" },
|
{ hitokoto: "把大问题拆成足够小的问题,答案就会浮现。", from: "判题狗" },
|
||||||
{ hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗" },
|
{ hitokoto: "一次没通过,只是多得到了一条线索。", from: "判题狗" },
|
||||||
]
|
]
|
||||||
@@ -38,18 +50,18 @@ const fallbackQuotes = [
|
|||||||
let categoryPaths: string[] | null = null
|
let categoryPaths: string[] | null = null
|
||||||
const sentenceCache = new Map<string, Quote[]>()
|
const sentenceCache = new Map<string, Quote[]>()
|
||||||
|
|
||||||
interface Quote {
|
|
||||||
hitokoto: string
|
|
||||||
from: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSentences(path: string) {
|
async function loadSentences(path: string) {
|
||||||
const cached = sentenceCache.get(path)
|
const cached = sentenceCache.get(path)
|
||||||
if (cached) return cached
|
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 : [])
|
const rows = (Array.isArray(raw) ? raw : [])
|
||||||
.filter((it) => typeof it.hitokoto === "string" && it.hitokoto.length > 0)
|
.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}`)
|
if (rows.length === 0) throw new Error(`empty hitokoto category: ${path}`)
|
||||||
sentenceCache.set(path, rows)
|
sentenceCache.set(path, rows)
|
||||||
return rows
|
return rows
|
||||||
@@ -57,8 +69,12 @@ async function loadSentences(path: string) {
|
|||||||
|
|
||||||
async function randomQuote() {
|
async function randomQuote() {
|
||||||
if (!categoryPaths) {
|
if (!categoryPaths) {
|
||||||
const categories = await Bun.file(resolve(config.hitokotoDirectory, "categories.json")).json() as { path?: string }[]
|
const categories = (await Bun.file(
|
||||||
const paths = categories.map((it) => it.path).filter((it): it is string => typeof it === "string")
|
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")
|
if (paths.length === 0) throw new Error("no hitokoto categories")
|
||||||
categoryPaths = paths
|
categoryPaths = paths
|
||||||
}
|
}
|
||||||
@@ -69,17 +85,23 @@ async function randomQuote() {
|
|||||||
|
|
||||||
siteRoutes.get("/quotes/random", async (c) => {
|
siteRoutes.get("/quotes/random", async (c) => {
|
||||||
try {
|
try {
|
||||||
return success(c, quoteSchema.parse(await randomQuote()))
|
return success(c, (await randomQuote()) satisfies Quote)
|
||||||
} catch {
|
} catch {
|
||||||
const item = fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]!
|
const item =
|
||||||
return success(c, quoteSchema.parse(item))
|
fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]!
|
||||||
|
return success(c, item satisfies Quote)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
siteRoutes.get("/classes/:className/usernames", async (c) => {
|
siteRoutes.get("/classes/:className/usernames", async (c) => {
|
||||||
const className = c.req.param("className").trim()
|
const className = c.req.param("className").trim()
|
||||||
if (!/^\d{3,4}$/.test(className)) {
|
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
|
const rows = await db
|
||||||
.select({ username: schema.user.username })
|
.select({ username: schema.user.username })
|
||||||
@@ -87,5 +109,8 @@ siteRoutes.get("/classes/:className/usernames", async (c) => {
|
|||||||
.where(eq(schema.user.className, className))
|
.where(eq(schema.user.className, className))
|
||||||
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
|
.orderBy(desc(schema.user.createTime), asc(schema.user.id))
|
||||||
// 用 stripClassPrefix 而不是 replace:replace 会把中间的匹配也删掉,前缀对不上时截出乱码
|
// 用 stripClassPrefix 而不是 replace:replace 会把中间的匹配也删掉,前缀对不上时截出乱码
|
||||||
return success(c, rows.map(({ username }) => stripClassPrefix(username, className)))
|
return success(
|
||||||
|
c,
|
||||||
|
rows.map(({ username }) => stripClassPrefix(username, className)),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,704 @@
|
|||||||
|
/**
|
||||||
|
* 教师统计:今日提交分布、按学生/题目的统计面板、展开行的提交明细。
|
||||||
|
*
|
||||||
|
* 从 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)
|
||||||
|
},
|
||||||
|
)
|
||||||
+573
-350
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,9 @@ export function selfCommand(subcommand: string): string[] {
|
|||||||
* 而 docker/compose.dev.yml 挂给判题沙箱的是**仓库根**的 data/test_case —— 按 cwd 解析
|
* 而 docker/compose.dev.yml 挂给判题沙箱的是**仓库根**的 data/test_case —— 按 cwd 解析
|
||||||
* 就会落到 apps/api/data/ 下,两边不是同一个目录,新传的测试点判题时报「找不到测试数据」。
|
* 就会落到 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`)所在目录。
|
* 迁移文件(`0000_*.sql` … + `meta/_journal.json`)所在目录。
|
||||||
@@ -48,4 +50,6 @@ export const pathBase = isCompiled ? process.cwd() : resolve(import.meta.dir, ".
|
|||||||
*/
|
*/
|
||||||
export const migrationsDir =
|
export const migrationsDir =
|
||||||
process.env.OJ2_MIGRATIONS_DIR ??
|
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,81 @@
|
|||||||
|
/**
|
||||||
|
* 检查契约里每个 AST target 的 `node` 在对应语言的语法里真实存在。
|
||||||
|
*
|
||||||
|
* bun run --filter '@oj2/api' check:ast
|
||||||
|
*
|
||||||
|
* ## 为什么需要这个
|
||||||
|
*
|
||||||
|
* 判题机拿 `node` 去比 tree-sitter 的节点类型,**对不上不会报错**:collectNodes
|
||||||
|
* 一个都收不到,于是「必须使用 X」永远失败、「不能使用 X」永远通过。两头都不报错,
|
||||||
|
* 只有学生受着 —— 他明明写了 f-string,「不能使用 f-string」却judge成通过。
|
||||||
|
*
|
||||||
|
* 这正是本仓库真实踩过的坑:`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)
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
*
|
*
|
||||||
* 加路由时顺手跑一下,比事后靠人眼在 200 多条路由里看出顺序问题可靠。
|
* 加路由时顺手跑一下,比事后靠人眼在 200 多条路由里看出顺序问题可靠。
|
||||||
*
|
*
|
||||||
* 局限:靠正则读源码,只认 `xxxRoutes.get("字面量", …)` 这种写法。
|
* 局限:靠正则读源码,只认 `xxxRoutes.get("字面量", …)` 这种写法,
|
||||||
|
* 以及 `xxxRoutes.route("字面量", 子路由)` 的嵌套挂载(按挂载位置展开)。
|
||||||
* 动态拼出来的路径看不见 —— 但本仓库没有那种写法,加的时候请保持。
|
* 动态拼出来的路径看不见 —— 但本仓库没有那种写法,加的时候请保持。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -63,7 +64,9 @@ export function shadows(pattern: string, target: string) {
|
|||||||
function collect(): Route[] {
|
function collect(): Route[] {
|
||||||
const routerFile = new Map<string, string>()
|
const routerFile = new Map<string, string>()
|
||||||
for (const file of walk(SRC)) {
|
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)
|
routerFile.set(m[1]!, file)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,21 +75,35 @@ function collect(): Route[] {
|
|||||||
const file = routerFile.get(router)
|
const file = routerFile.get(router)
|
||||||
if (!file) return []
|
if (!file) return []
|
||||||
const text = readFileSync(file, "utf8")
|
const text = readFileSync(file, "utf8")
|
||||||
const pattern = new RegExp(`${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"`, "g")
|
// 直接注册的路由和嵌套挂载(`router.route("/", child)`)放在一起按出现位置排序:
|
||||||
return [...text.matchAll(pattern)].map((m) => ({
|
// 子路由挂在哪个位置,它的路由就在哪个位置参与匹配
|
||||||
method: m[1]!.toUpperCase(),
|
const pattern = new RegExp(
|
||||||
path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/",
|
`${router}\\.(get|post|put|delete|patch)\\(\\s*"([^"]+)"|${router}\\.route\\(\\s*"([^"]*)"\\s*,\\s*(\\w+)\\s*\\)`,
|
||||||
file: file.replace(SRC + "/", ""),
|
"g",
|
||||||
}))
|
)
|
||||||
|
return [...text.matchAll(pattern)].flatMap((m) => {
|
||||||
|
if (m[4]) return routesOf(m[4], prefix + m[3]!)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
method: m[1]!.toUpperCase(),
|
||||||
|
path: (prefix + m[2]!).replace(/\/+/g, "/").replace(/\/$/, "") || "/",
|
||||||
|
file: file.replace(SRC + "/", ""),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平
|
// 挂载顺序就是匹配顺序,所以必须按 index.ts 里出现的先后来摊平
|
||||||
const index = readFileSync(join(SRC, "index.ts"), "utf8")
|
const index = readFileSync(join(SRC, "index.ts"), "utf8")
|
||||||
const adminIndex = readFileSync(join(SRC, "routes/admin/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[] = []
|
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
|
const [, prefix, router] = m
|
||||||
if (router === "adminRoutes") {
|
if (router === "adminRoutes") {
|
||||||
for (const a of adminMounts) all.push(...routesOf(a[2]!, prefix! + a[1]!))
|
for (const a of adminMounts) all.push(...routesOf(a[2]!, prefix! + a[1]!))
|
||||||
@@ -102,7 +119,8 @@ const hits: [Route, Route][] = []
|
|||||||
for (let i = 0; i < routes.length; i++) {
|
for (let i = 0; i < routes.length; i++) {
|
||||||
for (let j = i + 1; j < routes.length; j++) {
|
for (let j = i + 1; j < routes.length; j++) {
|
||||||
if (routes[i]!.method !== routes[j]!.method) continue
|
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]!])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +131,9 @@ if (hits.length === 0) {
|
|||||||
}
|
}
|
||||||
for (const [first, second] of hits) {
|
for (const [first, second] of hits) {
|
||||||
console.log(`\n⚠ ${second.method} ${second.path} (${second.file})`)
|
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(` 改法:把它挪到那条之前注册,或换一个不同形的路径`)
|
console.log(` 改法:把它挪到那条之前注册,或换一个不同形的路径`)
|
||||||
}
|
}
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
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 表一致。
|
||||||
|
*
|
||||||
|
* 这几个列不是缓存、是真值的副本:判题落库时由 `judge/run.ts` 的 persistResult 手工
|
||||||
|
* 加减,谁都没在事后核对过。已知的漂移来源是**重判**——`routes/submission.ts` 的
|
||||||
|
* rejudge 把 result 打回 PENDING 就重新入队,**不回退任何计数**,于是 persistResult
|
||||||
|
* 再加一次:重判一条题目的 submission_number 就永久多一。删提交、直接改库同理。
|
||||||
|
*
|
||||||
|
* 管的六个列:
|
||||||
|
* 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_count(0010 之后
|
||||||
|
* user_achievement 随成就级联,漂不了)、题单进度与奖章(判题这一路自己记账,见 services/problemset.ts)。
|
||||||
|
*
|
||||||
|
* 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate:
|
||||||
|
*
|
||||||
|
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api recount
|
||||||
|
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api recount --apply
|
||||||
|
*
|
||||||
|
* ⚠️ **--apply 要挑没人做题的时候跑。** 差异是在事务外算的,写的是绝对值:算完到写完
|
||||||
|
* 之间要是有一条判完了,它那一笔加法会被覆盖掉。落库后的复核会把这种情况报成「仍有
|
||||||
|
* N 处差异」并以 1 退出,不会静默 —— 见到了重跑一次即可,但别在上课高峰按。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 判完的提交才计数。PENDING / JUDGING 是在途状态,persistResult 还没给它们记过账 */
|
||||||
|
const UNJUDGED = [JudgeStatus.PENDING, JudgeStatus.JUDGING]
|
||||||
|
|
||||||
|
type ProblemExpected = {
|
||||||
|
submissionNumber: number
|
||||||
|
acceptedNumber: number
|
||||||
|
statisticInfo: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 题目侧的期望值。**比赛提交也算**——persistResult 更新 problem 这一段没有区分
|
||||||
|
* contestId,只有 user_profile 那一段才分。
|
||||||
|
*/
|
||||||
|
async function expectedProblems() {
|
||||||
|
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]})
|
||||||
|
group by problem_id, result
|
||||||
|
`)
|
||||||
|
const expected = new Map<number, ProblemExpected>()
|
||||||
|
for (const row of rows) {
|
||||||
|
const current = expected.get(row.problem_id) ?? {
|
||||||
|
submissionNumber: 0,
|
||||||
|
acceptedNumber: 0,
|
||||||
|
statisticInfo: {},
|
||||||
|
}
|
||||||
|
current.submissionNumber += row.n
|
||||||
|
if (isAccepted(row.result)) current.acceptedNumber += row.n
|
||||||
|
current.statisticInfo[String(row.result)] = row.n
|
||||||
|
expected.set(row.problem_id, current)
|
||||||
|
}
|
||||||
|
return expected
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileExpected = {
|
||||||
|
submissionNumber: number
|
||||||
|
acceptedNumber: number
|
||||||
|
status: Record<string, Record<string, { status: number; _id: string }>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户侧的期望值。三条口径都照抄 persistResult:
|
||||||
|
*
|
||||||
|
* - submission_number:只数**非比赛**的判完提交。
|
||||||
|
* - accepted_number:只数非比赛、**去重到题**的首次通过(`acceptedNow && !wasAccepted`
|
||||||
|
* 等价于「这道题此前没通过过」,累计下来就是 AC 的不同题目数)。
|
||||||
|
* - acm_problems_status:`{ problems / contest_problems: { 题号: { status, _id } } }`。
|
||||||
|
* 通过过就恒为 ACCEPTED(persistResult 里 `wasAccepted` 之后不再改写);
|
||||||
|
* 从没通过过则取**最后一次**判完的结果。
|
||||||
|
*
|
||||||
|
* ⚠️ 「最后一次」这里按 create_time 排,而 persistResult 是按**判完的先后**写的。
|
||||||
|
* 两者在重判乱序时可能不同 —— 一条早提交的被重判、比晚提交的更晚判完,真值是那条早的,
|
||||||
|
* 本工具会算成那条晚的。这种情况只影响「从没 AC 过的题」显示成哪种失败,不影响任何计数,
|
||||||
|
* 所以按 create_time 算,不额外记判完时间。
|
||||||
|
*/
|
||||||
|
async function expectedProfiles() {
|
||||||
|
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
|
||||||
|
from submission
|
||||||
|
where result not in (${UNJUDGED[0]}, ${UNJUDGED[1]}) and contest_id is null
|
||||||
|
group by user_id
|
||||||
|
`)
|
||||||
|
const perProblem = await db.execute<{
|
||||||
|
user_id: number
|
||||||
|
is_public: boolean
|
||||||
|
problem_id: number
|
||||||
|
display_id: string
|
||||||
|
ever_accepted: boolean
|
||||||
|
last_result: number
|
||||||
|
}>(sql`
|
||||||
|
select s.user_id,
|
||||||
|
(s.contest_id is null) as is_public,
|
||||||
|
s.problem_id,
|
||||||
|
p._id as display_id,
|
||||||
|
bool_or(s.result in (${JudgeStatus.ACCEPTED}, ${JudgeStatus.AST_CHECK_FAILED})) as ever_accepted,
|
||||||
|
(array_agg(s.result order by s.create_time desc, s.id desc))[1] as last_result
|
||||||
|
from submission s
|
||||||
|
join problem p on p.id = s.problem_id
|
||||||
|
where s.result not in (${UNJUDGED[0]}, ${UNJUDGED[1]})
|
||||||
|
group by s.user_id, (s.contest_id is null), s.problem_id, p._id
|
||||||
|
`)
|
||||||
|
|
||||||
|
const expected = new Map<number, ProfileExpected>()
|
||||||
|
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
|
||||||
|
current.acceptedNumber = row.accepted
|
||||||
|
expected.set(row.user_id, current)
|
||||||
|
}
|
||||||
|
for (const row of perProblem) {
|
||||||
|
const current = expected.get(row.user_id) ?? blank()
|
||||||
|
const bucket = row.is_public ? "problems" : "contest_problems"
|
||||||
|
current.status[bucket] ??= {}
|
||||||
|
current.status[bucket]![String(row.problem_id)] = {
|
||||||
|
status: row.ever_accepted ? JudgeStatus.ACCEPTED : row.last_result,
|
||||||
|
_id: row.display_id,
|
||||||
|
}
|
||||||
|
expected.set(row.user_id, current)
|
||||||
|
}
|
||||||
|
return expected
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 稳定序列化,用来比对 jsonb —— 键序不同不该被当成差异 */
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stable(v)}`).join(",")}}`
|
||||||
|
}
|
||||||
|
return JSON.stringify(value) ?? "null"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只算差异,不写库。预演和落库后的复核共用它 —— 两边口径必须是同一份代码 */
|
||||||
|
async function computePlan(): Promise<Plan> {
|
||||||
|
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({
|
||||||
|
id: schema.userProfile.id,
|
||||||
|
userId: schema.userProfile.userId,
|
||||||
|
submissionNumber: schema.userProfile.submissionNumber,
|
||||||
|
acceptedNumber: schema.userProfile.acceptedNumber,
|
||||||
|
acmProblemsStatus: schema.userProfile.acmProblemsStatus,
|
||||||
|
})
|
||||||
|
.from(schema.userProfile),
|
||||||
|
expectedProblems(),
|
||||||
|
expectedProfiles(),
|
||||||
|
])
|
||||||
|
|
||||||
|
const plan: Plan = {
|
||||||
|
diffs: [],
|
||||||
|
problemFixes: [],
|
||||||
|
profileFixes: [],
|
||||||
|
unlockedCountFixes: [],
|
||||||
|
metaGrants: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const problem of problems) {
|
||||||
|
const want = expectedProblem.get(problem.id) ?? {
|
||||||
|
submissionNumber: 0,
|
||||||
|
acceptedNumber: 0,
|
||||||
|
statisticInfo: {},
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (problem.acceptedNumber !== 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 (rows.length) {
|
||||||
|
plan.diffs.push(...rows)
|
||||||
|
plan.problemFixes.push({ id: problem.id, value: want })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const want = expectedProfile.get(profile.userId) ?? {
|
||||||
|
submissionNumber: 0,
|
||||||
|
acceptedNumber: 0,
|
||||||
|
status: {},
|
||||||
|
}
|
||||||
|
// acm_problems_status 里除了 problems / contest_problems 之外的键原样保留 ——
|
||||||
|
// persistResult 只写这两个桶,别的键是从哪来的没人说得清,重算不该顺手抹掉。
|
||||||
|
const existing = objectValue(profile.acmProblemsStatus)
|
||||||
|
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
|
||||||
|
|
||||||
|
const label = `用户 ${profile.userId}`
|
||||||
|
const rows: Diff[] = []
|
||||||
|
if (profile.submissionNumber !== 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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} 题(含比赛桶重建)`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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} 条):`,
|
||||||
|
)
|
||||||
|
for (const diff of plan.diffs.slice(0, 40)) {
|
||||||
|
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} 处`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退出码:0 = 一致或预演正常,1 = 落库后复核仍有差异 */
|
||||||
|
export async function recount(options: { apply: boolean }) {
|
||||||
|
const plan = await computePlan()
|
||||||
|
if (plan.diffs.length === 0) {
|
||||||
|
console.log("计数列与 submission / user_achievement 一致,没有要订正的。")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
report(plan)
|
||||||
|
|
||||||
|
if (!options.apply) {
|
||||||
|
console.log("\n以上为预演,没有写库。确认无误后加 --apply 落库。")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
for (const fix of plan.problemFixes) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
for (const fix of plan.profileFixes) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 先改计数、再补发: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} 条,复核中……`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 复核跑的是同一份 computePlan。这里还剩差异说明口径本身有问题(不是数据脏),
|
||||||
|
// 必须让部署脚本看见非零退出码,而不是打一行字了事。
|
||||||
|
const after = await computePlan()
|
||||||
|
if (after.diffs.length === 0) {
|
||||||
|
console.log("复核通过:计数列与 submission / user_achievement 一致")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
console.error(`复核未通过,仍有 ${after.diffs.length} 处差异:`)
|
||||||
|
report(after)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ import { db, schema } from "../db"
|
|||||||
* raw_password,其中一个还是超管。对着生产库跑一次就是把超管密码改掉,
|
* raw_password,其中一个还是超管。对着生产库跑一次就是把超管密码改掉,
|
||||||
* 所以这里按 DATABASE_URL 的主机名拦一道,需要绕过时显式设 OJ2_SEED_FORCE=true。
|
* 所以这里按 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 = (() => {
|
const host = (() => {
|
||||||
try {
|
try {
|
||||||
return new URL(url).hostname
|
return new URL(url).hostname
|
||||||
@@ -61,9 +63,7 @@ async function seed(account: SeedAccount) {
|
|||||||
createTime: now,
|
createTime: now,
|
||||||
adminType: account.adminType,
|
adminType: account.adminType,
|
||||||
problemPermission: account.problemPermission,
|
problemPermission: account.problemPermission,
|
||||||
openApi: false,
|
|
||||||
isDisabled: false,
|
isDisabled: false,
|
||||||
sessionKeys: [],
|
|
||||||
})
|
})
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: schema.user.username,
|
target: schema.user.username,
|
||||||
@@ -95,7 +95,9 @@ 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:")
|
console.log("Seeded development logins:")
|
||||||
|
|||||||
@@ -14,24 +14,85 @@ export interface AchievementMetric {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||||
{ key: "accepted_count", name: "AC 题目数", helpText: "去重后通过的题目数量(不含比赛)" },
|
{
|
||||||
{ key: "mid_ac_count", name: "中等题 AC 数", helpText: "去重后通过的中等难度题目数(不含比赛)" },
|
key: "accepted_count",
|
||||||
{ key: "hard_ac_count", name: "困难题 AC 数", helpText: "去重后通过的困难题目数(不含比赛)" },
|
name: "AC 题目数",
|
||||||
{ key: "submission_count", name: "提交总数", helpText: "提交次数(不含比赛)" },
|
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: "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: "languages_used", name: "使用语言数", helpText: "用过多少种编程语言" },
|
||||||
{ key: "contest_joined", name: "参赛场次", helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)" },
|
{
|
||||||
|
key: "contest_joined",
|
||||||
|
name: "参赛场次",
|
||||||
|
helpText: "参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)",
|
||||||
|
},
|
||||||
{ key: "badge_count", name: "题单奖章数", helpText: "获得的题单奖章数量" },
|
{ key: "badge_count", name: "题单奖章数", helpText: "获得的题单奖章数量" },
|
||||||
{ key: "problemset_completed", name: "完成题单数", helpText: "完成的题单数量" },
|
{
|
||||||
{ key: "first_try_ac_count", name: "一发入魂次数", helpText: "首次提交即通过的次数" },
|
key: "problemset_completed",
|
||||||
{ key: "midnight_submissions", name: "凌晨提交次数", helpText: "0:00–5:00 之间的提交次数" },
|
name: "完成题单数",
|
||||||
{ key: "early_bird_submissions", name: "早起提交次数", helpText: "5:00–7:00 之间的提交次数" },
|
helpText: "完成的题单数量",
|
||||||
{ key: "compile_error_count", name: "编译错误次数", helpText: "累计编译错误的次数" },
|
},
|
||||||
{ key: "max_wa_before_ac", name: "屡败屡战", helpText: "单题失败最多多少次后终于通过" },
|
{
|
||||||
{ key: "max_ac_in_one_day", name: "单日最多 AC", helpText: "一天之内最多通过多少题" },
|
key: "first_try_ac_count",
|
||||||
{ key: "max_code_lines", name: "最长代码行数", helpText: "提交过的最长代码有多少行" },
|
name: "一发入魂次数",
|
||||||
{ key: "achievement_unlocked_count", name: "已解锁成就数", helpText: "已解锁的成就数量(不含白金档)", meta: true },
|
helpText: "首次提交即通过的次数",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "midnight_submissions",
|
||||||
|
name: "凌晨提交次数",
|
||||||
|
helpText: "0:00–5:00 之间的提交次数",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "early_bird_submissions",
|
||||||
|
name: "早起提交次数",
|
||||||
|
helpText: "5:00–7: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]))
|
const BY_KEY = new Map(ACHIEVEMENT_METRICS.map((item) => [item.key, item]))
|
||||||
@@ -43,7 +104,3 @@ export function findMetric(key: string) {
|
|||||||
export function metricName(key: string) {
|
export function metricName(key: string) {
|
||||||
return BY_KEY.get(key)?.name ?? key
|
return BY_KEY.get(key)?.name ?? key
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 稀有度四档。乱填的值会让成就汇总接口的分档统计对不上:野值算进总数却不出现在任何一档 */
|
|
||||||
export const RARITIES = ["bronze", "silver", "gold", "platinum"] as const
|
|
||||||
export const OPERATORS = ["gte", "lte"] as const
|
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
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 { db, schema } from "../db"
|
||||||
import { publishAchievementNotification } from "../events"
|
import { publishAchievementNotification } from "../events"
|
||||||
|
import { calendarDay, dayNumber, localHour } from "../time"
|
||||||
import { findMetric } from "./achievement-metrics"
|
import { findMetric } from "./achievement-metrics"
|
||||||
import { isAccepted, JudgeStatus } from "../judge/status"
|
import { isAccepted, JudgeStatus } from "../judge/status"
|
||||||
import { objectValue } from "../routes/helpers"
|
import { objectValue } from "../routes/helpers"
|
||||||
@@ -11,154 +23,276 @@ function numberMetric(metrics: Record<string, unknown>, key: string) {
|
|||||||
return typeof value === "number" ? value : 0
|
return typeof value === "number" ? value : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function localDate(value: string) {
|
async function unlockAchievements(
|
||||||
const date = new Date(value)
|
userId: number,
|
||||||
const year = date.getFullYear()
|
metrics: Record<string, unknown>,
|
||||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
onlyMeta = false,
|
||||||
const day = String(date.getDate()).padStart(2, "0")
|
) {
|
||||||
return `${year}-${month}-${day}`
|
const unlocked = await db
|
||||||
}
|
.select({ id: schema.userAchievement.achievementId })
|
||||||
|
.from(schema.userAchievement)
|
||||||
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))
|
.where(eq(schema.userAchievement.userId, userId))
|
||||||
const filters = [eq(schema.achievement.visible, true)]
|
const filters = [eq(schema.achievement.visible, true)]
|
||||||
if (unlocked.length) filters.push(notInArray(schema.achievement.id, unlocked.map((row) => row.id)))
|
if (unlocked.length)
|
||||||
if (onlyMeta) filters.push(eq(schema.achievement.metric, "achievement_unlocked_count"))
|
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"))
|
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 hits = candidates.filter((achievement) => {
|
||||||
const value = metrics[achievement.metric]
|
const value = metrics[achievement.metric]
|
||||||
if (typeof value !== "number") return false
|
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 []
|
if (hits.length === 0) return []
|
||||||
// 命中的成就一次插完,冲突忽略后 returning 回来的就是「这次真新解锁的」。
|
// 命中的成就一次插完,冲突忽略后 returning 回来的就是「这次真新解锁的」。
|
||||||
// 一个用户对同一个成就只会解锁一次,所以每个成就都恰好 +1,一条 UPDATE 就够。
|
// 一个用户对同一个成就只会解锁一次,所以每个成就都恰好 +1,一条 UPDATE 就够。
|
||||||
const inserted = await db.insert(schema.userAchievement).values(hits.map((achievement) => ({
|
const inserted = await db
|
||||||
userId,
|
.insert(schema.userAchievement)
|
||||||
achievementId: achievement.id,
|
.values(
|
||||||
unlockTime: new Date().toISOString(),
|
hits.map((achievement) => ({
|
||||||
backfilled: false,
|
userId,
|
||||||
notified: false,
|
achievementId: achievement.id,
|
||||||
}))).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] })
|
unlockTime: new Date().toISOString(),
|
||||||
|
backfilled: false,
|
||||||
|
notified: false,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing({
|
||||||
|
target: [
|
||||||
|
schema.userAchievement.achievementId,
|
||||||
|
schema.userAchievement.userId,
|
||||||
|
],
|
||||||
|
})
|
||||||
.returning({ achievementId: schema.userAchievement.achievementId })
|
.returning({ achievementId: schema.userAchievement.achievementId })
|
||||||
if (inserted.length === 0) return []
|
if (inserted.length === 0) return []
|
||||||
const insertedIds = new Set(inserted.map((row) => row.achievementId))
|
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]))
|
.where(inArray(schema.achievement.id, [...insertedIds]))
|
||||||
return hits.filter((achievement) => insertedIds.has(achievement.id))
|
return hits.filter((achievement) => insertedIds.has(achievement.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAchievementsForSubmission(submissionId: string) {
|
export async function updateAchievementsForSubmission(submissionId: string) {
|
||||||
const [row] = await db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
|
const [row] = await db
|
||||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
.select({ submission: schema.submission, problem: schema.problem })
|
||||||
.where(eq(schema.submission.id, submissionId)).limit(1)
|
.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 []
|
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
|
||||||
eq(schema.submission.userId, row.submission.userId),
|
.select({ result: schema.submission.result })
|
||||||
eq(schema.submission.problemId, row.submission.problemId),
|
.from(schema.submission)
|
||||||
isNull(schema.submission.contestId),
|
.where(
|
||||||
ne(schema.submission.id, row.submission.id),
|
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 priorAccepted = priorRows.some((item) => isAccepted(item.result))
|
||||||
const accepted = isAccepted(row.submission.result)
|
const accepted = isAccepted(row.submission.result)
|
||||||
const firstAc = accepted && !priorAccepted
|
const firstAc = accepted && !priorAccepted
|
||||||
const firstTry = accepted && priorRows.length === 0
|
const firstTry = accepted && priorRows.length === 0
|
||||||
const date = localDate(row.submission.createTime)
|
const date = calendarDay(row.submission.createTime)
|
||||||
const hour = new Date(row.submission.createTime).getHours()
|
const hour = localHour(row.submission.createTime)
|
||||||
|
|
||||||
const metrics = await db.transaction(async (tx) => {
|
const metrics = await db.transaction(async (tx) => {
|
||||||
await tx.insert(schema.userStat).values({
|
await tx
|
||||||
userId: row.submission.userId,
|
.insert(schema.userStat)
|
||||||
metrics: {},
|
.values({
|
||||||
updateTime: new Date().toISOString(),
|
userId: row.submission.userId,
|
||||||
}).onConflictDoNothing({ target: schema.userStat.userId })
|
metrics: {},
|
||||||
const [stat] = await tx.select().from(schema.userStat).where(eq(schema.userStat.userId, row.submission.userId)).for("update")
|
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")
|
||||||
if (!stat) throw new Error("User achievement stat could not be created")
|
if (!stat) throw new Error("User achievement stat could not be created")
|
||||||
const value = objectValue(stat.metrics)
|
const value = objectValue(stat.metrics)
|
||||||
value.submission_count = numberMetric(value, "submission_count") + 1
|
value.submission_count = numberMetric(value, "submission_count") + 1
|
||||||
if (firstAc) {
|
if (firstAc) {
|
||||||
value.accepted_count = numberMetric(value, "accepted_count") + 1
|
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 === "Mid")
|
||||||
if (row.problem.difficulty === "High") value.hard_ac_count = numberMetric(value, "hard_ac_count") + 1
|
value.mid_ac_count = numberMetric(value, "mid_ac_count") + 1
|
||||||
if (firstTry) value.first_try_ac_count = numberMetric(value, "first_try_ac_count") + 1
|
if (row.problem.difficulty === "High")
|
||||||
value.max_wa_before_ac = Math.max(numberMetric(value, "max_wa_before_ac"), priorRows.length)
|
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)
|
const perDay = objectValue(value._ac_per_day)
|
||||||
perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1
|
perDay[date] = (typeof perDay[date] === "number" ? perDay[date] : 0) + 1
|
||||||
value._ac_per_day = perDay
|
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)
|
if (!activeDates.includes(date)) activeDates.push(date)
|
||||||
value._active_dates = activeDates
|
value._active_dates = activeDates
|
||||||
value.active_days = activeDates.length
|
value.active_days = activeDates.length
|
||||||
if (accepted) {
|
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) {
|
if (last !== date) {
|
||||||
const current = last && (Date.parse(`${date}T00:00:00`) - Date.parse(`${last}T00:00:00`)) / 86_400_000 === 1
|
// 差一天要按日历日算,不能用 Date 相减:夏令时地区相邻两天差 23/25 小时,
|
||||||
? numberMetric(value, "_current_ac_streak") + 1
|
// 除 86400000 得到的不是 1,`=== 1` 会静默把连续打卡判成断掉。
|
||||||
: 1
|
const current =
|
||||||
|
last && dayNumber(date) - dayNumber(last) === 1
|
||||||
|
? numberMetric(value, "_current_ac_streak") + 1
|
||||||
|
: 1
|
||||||
value._last_ac_date = date
|
value._last_ac_date = date
|
||||||
value._current_ac_streak = current
|
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") : []
|
const languages = Array.isArray(value._languages)
|
||||||
if (!languages.includes(row.submission.language)) languages.push(row.submission.language)
|
? 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 = languages
|
||||||
value.languages_used = languages.length
|
value.languages_used = languages.length
|
||||||
if (hour < 5) value.midnight_submissions = numberMetric(value, "midnight_submissions") + 1
|
if (hour < 5)
|
||||||
else if (hour < 7) value.early_bird_submissions = numberMetric(value, "early_bird_submissions") + 1
|
value.midnight_submissions =
|
||||||
if (row.submission.result === JudgeStatus.COMPILE_ERROR) value.compile_error_count = numberMetric(value, "compile_error_count") + 1
|
numberMetric(value, "midnight_submissions") + 1
|
||||||
value.max_code_lines = Math.max(numberMetric(value, "max_code_lines"), row.submission.code.split(/\r?\n/).length)
|
else if (hour < 7)
|
||||||
await tx.update(schema.userStat).set({ metrics: value, updateTime: new Date().toISOString() }).where(eq(schema.userStat.id, stat.id))
|
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
|
return value
|
||||||
})
|
})
|
||||||
|
|
||||||
const first = await unlockAchievements(row.submission.userId, metrics)
|
const first = await unlockAchievements(row.submission.userId, metrics)
|
||||||
if (!first.length) return []
|
if (!first.length) return []
|
||||||
const [meta] = await db.select({ value: count() }).from(schema.userAchievement)
|
const [meta] = await db
|
||||||
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
.select({ value: count() })
|
||||||
.where(and(eq(schema.userAchievement.userId, row.submission.userId), ne(schema.achievement.rarity, "platinum")))
|
.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
|
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))
|
await db
|
||||||
return [...first, ...(await unlockAchievements(row.submission.userId, metrics, true))]
|
.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) {
|
export async function updateAchievementsForProblemSet(userId: number) {
|
||||||
const [[badgeRow], [completedRow]] = await Promise.all([
|
const [[badgeRow], [completedRow]] = await Promise.all([
|
||||||
db.select({ value: count() }).from(schema.userBadge).where(eq(schema.userBadge.userId, userId)),
|
db
|
||||||
db.select({ value: count() }).from(schema.problemsetProgress).where(and(
|
.select({ value: count() })
|
||||||
eq(schema.problemsetProgress.userId, userId),
|
.from(schema.userBadge)
|
||||||
eq(schema.problemsetProgress.isCompleted, true),
|
.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) => {
|
const metrics = await db.transaction(async (tx) => {
|
||||||
await tx.insert(schema.userStat).values({
|
await tx
|
||||||
userId,
|
.insert(schema.userStat)
|
||||||
metrics: {},
|
.values({
|
||||||
updateTime: new Date().toISOString(),
|
userId,
|
||||||
}).onConflictDoNothing({ target: schema.userStat.userId })
|
metrics: {},
|
||||||
const [stat] = await tx.select().from(schema.userStat)
|
updateTime: new Date().toISOString(),
|
||||||
.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")
|
if (!stat) throw new Error("User achievement stat could not be created")
|
||||||
const value = objectValue(stat.metrics)
|
const value = objectValue(stat.metrics)
|
||||||
value.badge_count = badgeRow?.value ?? 0
|
value.badge_count = badgeRow?.value ?? 0
|
||||||
value.problemset_completed = completedRow?.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))
|
.where(eq(schema.userStat.id, stat.id))
|
||||||
return value
|
return value
|
||||||
})
|
})
|
||||||
|
|
||||||
const first = await unlockAchievements(userId, metrics)
|
const first = await unlockAchievements(userId, metrics)
|
||||||
if (!first.length) return []
|
if (!first.length) return []
|
||||||
const [meta] = await db.select({ value: count() }).from(schema.userAchievement)
|
const [meta] = await db
|
||||||
.innerJoin(schema.achievement, eq(schema.userAchievement.achievementId, schema.achievement.id))
|
.select({ value: count() })
|
||||||
.where(and(eq(schema.userAchievement.userId, userId), ne(schema.achievement.rarity, "platinum")))
|
.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
|
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))
|
.where(eq(schema.userStat.userId, userId))
|
||||||
return [...first, ...(await unlockAchievements(userId, metrics, true))]
|
return [...first, ...(await unlockAchievements(userId, metrics, true))]
|
||||||
}
|
}
|
||||||
@@ -175,22 +309,39 @@ const USER_ACHIEVEMENT_INSERT_CHUNK = 1000
|
|||||||
* 而不显示日期,否则一次补发会给几百人盖同一个时间戳,把「最近获得」板块冲垮。
|
* 而不显示日期,否则一次补发会给几百人盖同一个时间戳,把「最近获得」板块冲垮。
|
||||||
*/
|
*/
|
||||||
export async function rescanAchievement(achievementId: number) {
|
export async function rescanAchievement(achievementId: number) {
|
||||||
const [achievement] = await db.select().from(schema.achievement)
|
const [achievement] = await db
|
||||||
.where(and(eq(schema.achievement.id, achievementId), eq(schema.achievement.visible, true))).limit(1)
|
.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 }
|
if (!achievement) return { scanned: 0, unlocked: 0 }
|
||||||
|
|
||||||
const metric = findMetric(achievement.metric)
|
const metric = findMetric(achievement.metric)
|
||||||
if (!metric) return { scanned: 0, unlocked: 0 }
|
if (!metric) return { scanned: 0, unlocked: 0 }
|
||||||
|
|
||||||
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值)
|
// contest_joined 不由判题结算维护,扫之前先把它刷新一遍,否则永远读到旧值(或没有值)
|
||||||
if (achievement.metric === "contest_joined") await refreshContestJoinedForAll()
|
if (achievement.metric === "contest_joined")
|
||||||
|
await refreshContestJoinedForAll()
|
||||||
|
|
||||||
const already = new Set(
|
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)
|
.from(schema.userStat)
|
||||||
const eligible = stats.filter((stat) => {
|
const eligible = stats.filter((stat) => {
|
||||||
if (already.has(stat.userId)) return false
|
if (already.has(stat.userId)) return false
|
||||||
@@ -206,36 +357,116 @@ export async function rescanAchievement(achievementId: number) {
|
|||||||
// 计数改成一次 +N,通知照旧逐人推(那是 Redis,不是数据库)。
|
// 计数改成一次 +N,通知照旧逐人推(那是 Redis,不是数据库)。
|
||||||
const unlockTime = new Date().toISOString()
|
const unlockTime = new Date().toISOString()
|
||||||
const unlockedUserIds: number[] = []
|
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 chunk = eligible.slice(start, start + USER_ACHIEVEMENT_INSERT_CHUNK)
|
||||||
const inserted = await db.insert(schema.userAchievement).values(chunk.map((stat) => ({
|
const inserted = await db
|
||||||
userId: stat.userId,
|
.insert(schema.userAchievement)
|
||||||
achievementId: achievement.id,
|
.values(
|
||||||
unlockTime,
|
chunk.map((stat) => ({
|
||||||
backfilled: true,
|
userId: stat.userId,
|
||||||
notified: false,
|
achievementId: achievement.id,
|
||||||
}))).onConflictDoNothing({ target: [schema.userAchievement.achievementId, schema.userAchievement.userId] })
|
unlockTime,
|
||||||
|
backfilled: true,
|
||||||
|
notified: false,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing({
|
||||||
|
target: [
|
||||||
|
schema.userAchievement.achievementId,
|
||||||
|
schema.userAchievement.userId,
|
||||||
|
],
|
||||||
|
})
|
||||||
.returning({ userId: schema.userAchievement.userId })
|
.returning({ userId: schema.userAchievement.userId })
|
||||||
unlockedUserIds.push(...inserted.map((row) => row.userId))
|
unlockedUserIds.push(...inserted.map((row) => row.userId))
|
||||||
}
|
}
|
||||||
if (unlockedUserIds.length) {
|
if (unlockedUserIds.length) {
|
||||||
await db.update(schema.achievement)
|
await db
|
||||||
.set({ unlockCount: sql`${schema.achievement.unlockCount} + ${unlockedUserIds.length}` })
|
.update(schema.achievement)
|
||||||
|
.set({
|
||||||
|
unlockCount: sql`${schema.achievement.unlockCount} + ${unlockedUserIds.length}`,
|
||||||
|
})
|
||||||
.where(eq(schema.achievement.id, achievement.id))
|
.where(eq(schema.achievement.id, achievement.id))
|
||||||
for (const userId of unlockedUserIds) {
|
for (const userId of unlockedUserIds) {
|
||||||
await publishAchievementNotification(userId, [{
|
await publishAchievementNotification(userId, [
|
||||||
id: achievement.id,
|
{
|
||||||
name: achievement.name,
|
id: achievement.id,
|
||||||
description: achievement.description,
|
name: achievement.name,
|
||||||
icon: achievement.icon,
|
description: achievement.description,
|
||||||
rarity: achievement.rarity,
|
icon: achievement.icon,
|
||||||
kind: "achievement",
|
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 }
|
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 个参数一行 */
|
/** 同上,3 个参数一行 */
|
||||||
const STAT_UPSERT_CHUNK = 1000
|
const STAT_UPSERT_CHUNK = 1000
|
||||||
@@ -257,19 +488,25 @@ const STAT_UPSERT_CHUNK = 1000
|
|||||||
*/
|
*/
|
||||||
async function refreshContestJoinedForAll() {
|
async function refreshContestJoinedForAll() {
|
||||||
const rows = await db
|
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)
|
.from(schema.submission)
|
||||||
.where(isNotNull(schema.submission.contestId))
|
.where(isNotNull(schema.submission.contestId))
|
||||||
.groupBy(schema.submission.userId)
|
.groupBy(schema.submission.userId)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
for (let start = 0; start < rows.length; start += STAT_UPSERT_CHUNK) {
|
for (let start = 0; start < rows.length; start += STAT_UPSERT_CHUNK) {
|
||||||
const chunk = rows.slice(start, start + STAT_UPSERT_CHUNK)
|
const chunk = rows.slice(start, start + STAT_UPSERT_CHUNK)
|
||||||
await db.insert(schema.userStat)
|
await db
|
||||||
.values(chunk.map((row) => ({
|
.insert(schema.userStat)
|
||||||
userId: row.userId,
|
.values(
|
||||||
metrics: { contest_joined: row.value },
|
chunk.map((row) => ({
|
||||||
updateTime: now,
|
userId: row.userId,
|
||||||
})))
|
metrics: { contest_joined: row.value },
|
||||||
|
updateTime: now,
|
||||||
|
})),
|
||||||
|
)
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: schema.userStat.userId,
|
target: schema.userStat.userId,
|
||||||
set: {
|
set: {
|
||||||
|
|||||||
+89
-25
@@ -5,13 +5,15 @@ interface ChatMessage {
|
|||||||
content: string
|
content: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestBody(messages: ChatMessage[], stream: boolean) {
|
function requestBody(messages: ChatMessage[], stream: boolean, json = false) {
|
||||||
return {
|
return {
|
||||||
model: config.aiModel,
|
model: config.aiModel,
|
||||||
messages,
|
messages,
|
||||||
stream,
|
stream,
|
||||||
temperature: 0,
|
temperature: 0,
|
||||||
thinking: { type: "disabled" },
|
thinking: { type: "disabled" },
|
||||||
|
// DeepSeek 的 JSON 模式:保证回的是合法 JSON,但 prompt 里得出现「json」字样
|
||||||
|
...(json ? { response_format: { type: "json_object" } } : {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,47 +24,99 @@ function requestBody(messages: ChatMessage[], stream: boolean) {
|
|||||||
*/
|
*/
|
||||||
const COMPLETE_TIMEOUT_MS = 60_000
|
const COMPLETE_TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
export async function completeChat(system: string, user: string) {
|
export async function completeChat(
|
||||||
|
system: string,
|
||||||
|
user: string,
|
||||||
|
options: { json?: boolean; timeoutMs?: number } = {},
|
||||||
|
) {
|
||||||
if (!config.aiKey) throw new Error("缺少 AI_KEY")
|
if (!config.aiKey) throw new Error("缺少 AI_KEY")
|
||||||
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
|
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
signal: AbortSignal.timeout(COMPLETE_TIMEOUT_MS),
|
signal: AbortSignal.timeout(options.timeoutMs ?? COMPLETE_TIMEOUT_MS),
|
||||||
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
|
headers: {
|
||||||
body: JSON.stringify(requestBody([
|
"content-type": "application/json",
|
||||||
{ role: "system", content: system },
|
authorization: `Bearer ${config.aiKey}`,
|
||||||
{ role: "user", content: user },
|
},
|
||||||
], false)),
|
body: JSON.stringify(
|
||||||
|
requestBody(
|
||||||
|
[
|
||||||
|
{ role: "system", content: system },
|
||||||
|
{ role: "user", content: user },
|
||||||
|
],
|
||||||
|
false,
|
||||||
|
options.json,
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
if (!response.ok) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
|
if (!response.ok)
|
||||||
const payload = await response.json() as { choices?: Array<{ message?: { content?: string } }> }
|
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() ?? ""
|
return payload.choices?.[0]?.message?.content?.trim() ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StreamChatHooks {
|
||||||
|
/**
|
||||||
|
* 生成完整结束后调,拿到的是全文。**返回的对象会并进 `done` 事件**,
|
||||||
|
* 用来把落库之后才有的东西(比如 ai_hint 的 id)交给前端。
|
||||||
|
*/
|
||||||
|
onComplete?: (value: string) => Promise<Record<string, unknown> | void>
|
||||||
|
/**
|
||||||
|
* 生成失败时调(没配 AI_KEY、provider 报错、流中途断掉)。只用来留痕,
|
||||||
|
* 抛出的异常会被吞掉 —— 记录失败不该再搅乱这条流本身的收尾。
|
||||||
|
*/
|
||||||
|
onError?: (message: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
export function streamChat(
|
export function streamChat(
|
||||||
system: string,
|
system: string,
|
||||||
user: string,
|
user: string,
|
||||||
onComplete?: (value: string) => Promise<void>,
|
hooks: StreamChatHooks = {},
|
||||||
) {
|
) {
|
||||||
const encoder = new TextEncoder()
|
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>({
|
const body = new ReadableStream<Uint8Array>({
|
||||||
async start(controller) {
|
async start(controller) {
|
||||||
const send = (value: string) => controller.enqueue(encoder.encode(value))
|
const send = (value: string) => controller.enqueue(encoder.encode(value))
|
||||||
if (!config.aiKey) {
|
if (!config.aiKey) {
|
||||||
send(`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`)
|
await reportError("缺少 AI_KEY")
|
||||||
|
send(
|
||||||
|
`data: ${JSON.stringify({ type: "error", message: "缺少 AI_KEY" })}\n\n`,
|
||||||
|
)
|
||||||
send("event: end\n\n")
|
send("event: end\n\n")
|
||||||
controller.close()
|
controller.close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch(new URL("/chat/completions", config.aiBaseUrl), {
|
const response = await fetch(
|
||||||
method: "POST",
|
new URL("/chat/completions", config.aiBaseUrl),
|
||||||
headers: { "content-type": "application/json", authorization: `Bearer ${config.aiKey}` },
|
{
|
||||||
body: JSON.stringify(requestBody([
|
method: "POST",
|
||||||
{ role: "system", content: system },
|
headers: {
|
||||||
{ role: "user", content: user },
|
"content-type": "application/json",
|
||||||
], true)),
|
authorization: `Bearer ${config.aiKey}`,
|
||||||
})
|
},
|
||||||
if (!response.ok || !response.body) throw new Error(`AI provider returned HTTP ${response.status}: ${await response.text()}`)
|
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()}`,
|
||||||
|
)
|
||||||
send("event: start\n\n")
|
send("event: start\n\n")
|
||||||
const reader = response.body.getReader()
|
const reader = response.body.getReader()
|
||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
@@ -79,7 +133,12 @@ export function streamChat(
|
|||||||
const data = line.slice(5).trim()
|
const data = line.slice(5).trim()
|
||||||
if (data === "[DONE]") continue
|
if (data === "[DONE]") continue
|
||||||
try {
|
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 choice = item.choices?.[0]
|
||||||
const content = choice?.delta?.content
|
const content = choice?.delta?.content
|
||||||
if (content) {
|
if (content) {
|
||||||
@@ -93,10 +152,15 @@ export function streamChat(
|
|||||||
if (done) break
|
if (done) break
|
||||||
}
|
}
|
||||||
const full = chunks.join("").trim()
|
const full = chunks.join("").trim()
|
||||||
if (onComplete) await onComplete(full)
|
const extra = hooks.onComplete
|
||||||
send(`data: ${JSON.stringify({ type: "done" })}\n\n`)
|
? await hooks.onComplete(full)
|
||||||
|
: undefined
|
||||||
|
send(`data: ${JSON.stringify({ ...extra, type: "done" })}\n\n`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
send(`data: ${JSON.stringify({ type: "error", message: error instanceof Error ? error.message : String(error) })}\n\n`)
|
const message = error instanceof Error ? error.message : String(error)
|
||||||
|
// 先留痕再回前端:客户端已经断开的话下面这个 send 自己也会抛
|
||||||
|
await reportError(message)
|
||||||
|
send(`data: ${JSON.stringify({ type: "error", message })}\n\n`)
|
||||||
} finally {
|
} finally {
|
||||||
send("event: end\n\n")
|
send("event: end\n\n")
|
||||||
controller.close()
|
controller.close()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
|
|
||||||
import { and, eq } from "drizzle-orm"
|
import { eq } from "drizzle-orm"
|
||||||
import type { Context, MiddlewareHandler } from "hono"
|
import type { Context, MiddlewareHandler } from "hono"
|
||||||
|
|
||||||
import type { AppEnv } from "../auth/middleware"
|
import type { AppEnv } from "../auth/middleware"
|
||||||
@@ -29,29 +29,63 @@ export function contestStatus(contest: ContestRow) {
|
|||||||
return "0" as const
|
return "0" as const
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isContestAdmin(user: AuthUser | null | undefined, contest: ContestRow) {
|
export function isContestAdmin(
|
||||||
return Boolean(user && (user.id === contest.createdById || user.adminType === "Super Admin"))
|
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)
|
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 false
|
||||||
if (candidate === expected) return true
|
if (candidate === expected) return true
|
||||||
const parts = candidate.split("#")
|
const parts = candidate.split("#")
|
||||||
if (parts.length !== 2) return false
|
if (parts.length !== 2) return false
|
||||||
const [signature, expiresAt] = parts
|
const [signature, expiresAt] = parts
|
||||||
if (!signature || !expiresAt || !/^\d+$/.test(expiresAt)) return false
|
if (!signature || !expiresAt || !/^\d+$/.test(expiresAt)) return false
|
||||||
const expectedSignature = createHash("sha256").update(`${expected}${expiresAt}`).digest("hex").slice(0, 8)
|
const expectedSignature = createHash("sha256")
|
||||||
return signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
|
.update(`${expected}${expiresAt}`)
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 8)
|
||||||
|
return (
|
||||||
|
signature === expectedSignature && Date.now() < Number(expiresAt) * 1000
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findVisibleContest(id: number) {
|
/**
|
||||||
const [contest] = await db.select().from(schema.contest)
|
* 取一场「这个人看得见」的比赛:公开(visible)的谁都取得到,隐藏的只有比赛管理员
|
||||||
.where(and(eq(schema.contest.id, id), eq(schema.contest.visible, true))).limit(1)
|
* (出题人本人 / 超管)取得到,对其余人一律当作不存在。
|
||||||
return contest ?? null
|
*
|
||||||
|
* 原来这里一律卡 visible,于是老师赛后把比赛收起来之后,核查页的「查看代码」必然 404:
|
||||||
|
* 那个页面自己**故意不卡** visible(赛后核查恰恰发生在比赛收起来之后,见
|
||||||
|
* admin/contest.ts 的说明),它调的比赛提交列表却卡着,两边对不上。
|
||||||
|
*
|
||||||
|
* 放宽的只有出题人自己的视角,学生看隐藏比赛照旧是 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)
|
||||||
|
if (!contest) return null
|
||||||
|
return contest.visible || isContestAdmin(user, contest) ? contest : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 泛型而不是写死 Context<AppEnv>:requireContestAccess 传进来的是 Context<ContestEnv>,
|
// 泛型而不是写死 Context<AppEnv>:requireContestAccess 传进来的是 Context<ContestEnv>,
|
||||||
@@ -62,16 +96,25 @@ export async function canAccessContest<E extends AppEnv>(
|
|||||||
checkType: "details" | "problems" | "ranks" | "submissions",
|
checkType: "details" | "problems" | "ranks" | "submissions",
|
||||||
) {
|
) {
|
||||||
const user = c.get("user")
|
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 (isContestAdmin(user, contest)) return { ok: true as const }
|
||||||
if (contest.password) {
|
if (contest.password) {
|
||||||
const stored = await getContestPassword(c, contest.id)
|
const stored = await getContestPassword(c, contest.id)
|
||||||
if (!checkContestPassword(stored, contest.password)) {
|
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") {
|
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 }
|
return { ok: true as const }
|
||||||
}
|
}
|
||||||
@@ -93,35 +136,22 @@ export function requireContestAccess(
|
|||||||
): MiddlewareHandler<ContestEnv> {
|
): MiddlewareHandler<ContestEnv> {
|
||||||
return async (c, next) => {
|
return async (c, next) => {
|
||||||
const id = Number(c.req.param(paramName))
|
const id = Number(c.req.param(paramName))
|
||||||
const contest = Number.isInteger(id) && id > 0 ? await findVisibleContest(id) : null
|
const contest =
|
||||||
if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist")
|
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)
|
const access = await canAccessContest(c, contest, checkType)
|
||||||
if (!access.ok) {
|
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)
|
c.set("contest", contest)
|
||||||
await next()
|
await next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ipv4Number(value: string) {
|
|
||||||
const parts = value.split(".").map(Number)
|
|
||||||
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return null
|
|
||||||
return parts.reduce((result, part) => (result * 256 + part) >>> 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ipAllowed(ip: string | null, ranges: unknown) {
|
|
||||||
if (!Array.isArray(ranges) || ranges.length === 0) return true
|
|
||||||
if (!ip) return false
|
|
||||||
const target = ipv4Number(ip.replace(/^::ffff:/, ""))
|
|
||||||
if (target === null) return false
|
|
||||||
return ranges.some((raw) => {
|
|
||||||
const value = typeof raw === "string" ? raw : raw && typeof raw === "object" ? String((raw as { value?: unknown }).value ?? "") : ""
|
|
||||||
const [address, prefixText = "32"] = value.split("/")
|
|
||||||
const network = ipv4Number(address ?? "")
|
|
||||||
const prefix = Number(prefixText)
|
|
||||||
if (network === null || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false
|
|
||||||
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0
|
|
||||||
return (target & mask) === (network & mask)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user