Compare commits

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

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

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

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

## 校验责任挪回写入侧

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

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

## 文档

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

## 验证

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 05:45:15 -06:00
xuyueandClaude Opus 5 b9a80d62bc docs(CLAUDE.md): 合并顶部叠着的更正块,订正前端验证方式,补上契约收紧的边界
## 顶部

两层引用块套着一段「2026-09-10 更正」,说的是同一件事的两个版本(7 张表已删 →
其实还剩一张)。合成一段现状:旧栈不可逆下线、唯一退路是备份恢复、漏网那张
django_migrations 由 0014 补删。考古过程留在迁移文件的注释里,这里不重复。27 行 → 14 行。

## 常用检查

`cd apps/web && bun run build` 后面那句「vite 不做类型检查,构建即验证」是错的:
vite 确实不做类型检查,但**构建也不是验证**。补上 `bun run type-check`,并写明两条
会静默通过的假路子 —— `vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个
tsconfig 是 files: [] + references 的壳,0.2 秒跑完就是信号)、`vite build` 不看类型。
后端也改成 `bun run --filter '@oj2/api' typecheck`(脚本本来就有)。

## 新增「契约收紧要挑地方」

前一个 commit 的教训值得留在这儿:契约 schema 后端也在读路径上 parse,收紧字段
等于给历史数据加闸,对不上要么 500(exerciseSchema)、要么静默塌成 {}(info,
9163/124192 条)。JSONB 原文的形状真相在写入侧,闸就设在那里;要收紧先拿生产备份
跑全量,重点看空值不是键集合。AST 规则的 astRulesError() 本来就是同一个道理。

## apps/web/CLAUDE.md

Commands 段还是 ojnext 时代的 npm start / npm fmt,全部换成 bun 并补上 type-check
的坑。Module Pattern 写的 `views/` 这一层实际不存在(页面组件直接放模块根下),
api.ts 也不按模块分(学生端 oj/api.ts、后台 admin/api.ts、跨端 shared/api.ts)。

工作区根目录的 CLAUDE.md / AGENTS.md 同样折叠了那段更正、订正了类型检查命令
(那两份不在 git 里)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 04:57:34 -06:00
xuyueandClaude Opus 5 7d15e6aeaa refactor(契约): 判题产物退回不校验,练一练的形状闸挪到写入侧,运行时闸门收回三处
前四轮把契约当成运行时闸门铺开,复盘下来三块里只有一块是赚的:类型收拢成一份
(语言联合、Problem/Message/ContestRank 的重复派生)留着;另外两块退回来。

## 判题产物:读出侧不再校验

judgeCaseResultSchema 按采样键集收紧的结果,用根目录那份生产备份全量跑了一遍:
124192 条提交里 9163 条对不上,**RE 8480/8480、TLE 338/338+26、MLE 1/1 全中**,
另有 270 条 WA、47 条 AC。原因不是键集合,是空值和 SQL 链路:

- 沙箱在非正常退出的测试点上写 `output_md5: null`,契约写的是 z.string();
- SQL 判题(judge/sql/engine.ts 的 CaseResult)根本没有 `output` 键;
- SQL 通过的测试点 `error_message` 是 null,契约写的是 z.string().optional()。

更糟的是失败方式:`info` 是 `union([完整形状, z.object({})])`,对不上的一律落进
第二支被剥成 `{}` 且 parse 成功 —— 管理员详情页的测试点表格**静默消失**,无日志。

JSONB 的形状真相在写入侧(判题机),读出侧再校验一遍只会在两边分叉时丢数据。
所以 `info` 回到 z.unknown(),形状改用 JudgeInfo / JudgeCaseResult 两个 TS 类型
描述(按判题机实际写的形状,不是采样出来的),取值处由 submissionCaseResults()
做唯一需要的运行时判断:有没有 data 数组。statisticInfo 换成 looseObject ——
所有键可选、不剥未知键,对任何对象都不会失败,它的作用是给类型不是当闸门。

## 练一练:形状闸从读路径挪到写路径

exerciseSchema 的 superRefine 挂在读路径上,而这个 schema 后端也在 parse
(routes/content.ts),等于一行脏数据就能让整条学生练习列表 500。同时写入侧的
exerciseDataError **一次都没查过 question**,两边严紧度不一致,脏数据进得来出不去。

exerciseDataByType 保留,改由 exerciseDataError 在写入前查,错误信息按字段翻成
中文给老师看;读路径回到不校验。

## 运行时闸门收回三处

contract() 从 41 个端点收回到题目详情 / 提交详情 / 用户资料 —— 原本就写了
.parse() 的那三条。留着的理由是「别抛错」(原来 parse 抛 ZodError 会白屏、
后面的 as 又让校验白做),不是校验:前后端同仓、共享同一份 schema,字段漂移
tsc 已经抓了。闸门本身也瘦掉了没人读的 window.__OJ2_CONTRACT_DRIFT__ 那套簿记。

## 验证

- 生产备份全量:124192 条提交过 submissionDetailSchema / submissionListItemSchema
  零失败,其中 112144 条能拿到测试点明细(另外 12048 条本来就是 data:null);
  151 道练习读路径 151/151、写入闸 151/151(老师改旧题不会被新闸挡);
- 反向验证写入闸:缺题干的排序题被拒并给出「题干的格式不对」;
- 本地实跑:种一条生产形状的 RE 提交(output_md5: null),管理员详情接口原样
  返回 info.data(改之前是 {});库里塞一行没有 options 的 mcq,学生端练习列表
  照常返回两条而不是 500;
- vue-tsc / tsc -p apps/api 均 exit 0,vite build 通过,check:routes 无遮蔽。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
2026-09-10 04:53:28 -06:00
43 changed files with 921 additions and 1166 deletions
+56 -22
View File
@@ -4,28 +4,14 @@ OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScrip
上一代在 `../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_session` 等),且已在生产库执行完毕。所以「停新栈起旧栈」「把 NPM 上游
> `django_migrations`)。**这条迁移已在生产库执行完毕** > 改回 8080」都已失效,**唯一退路是从数据库备份恢复** —— 切换手册里的「回滚保证」
> `docker exec oj-api oj2-api migrate` 回「没有待执行的迁移」) > 那节只剩历史价值
> >
> 所以旧栈现在**起不来**了:「停新栈起旧栈」「把 NPM 上游改回 8080」都已失效 > 生产库上 0002 有一张没删干净(0 行的 `django_migrations`,来源已无法复原)
> 唯一退路是从数据库备份恢复。切换手册里的「回滚保证」那节只剩历史价值 > 由 `0014_drop_django_migrations` 补删,前因后果写在那个迁移文件的注释里
> >
> 「改 schema 要考虑回滚」这条约束随之解除,schema 归 OJ2 独占,
> 走 drizzle migration 正常演进即可。
>
> **2026-09-10 更正:上面「7 张框架表已删」在生产库上并不成立。** 实测生产库
> 29 张 public 表 = `schema.ts` 的 28 张 + 一张 **0 行的 `django_migrations`**0002 里
> 另外 6 张(`auth_group*` / `auth_permission` / `django_content_type` /
> `django_dramatiq_task` / `django_session`)确实都不在了,只有它复活/残留了下来。
> 原因已无法从库里复原(0002 的记账行在,说明它当年执行过;`DROP TABLE IF EXISTS`
> 也不会静默跳过后续语句),多半是事后有人手工建过它、或从旧 dump 单独恢复过。
>
> 处置见 `0014_drop_django_migrations`:全仓零读写、表为空,直接删掉,用
> `IF EXISTS` 让「空库自举」(0002 已删过)与「老生产库」(还留着)两条路径收敛到
> 同一结构。**旧栈起不来这个结论不变** —— 它缺的是 `django_session` 等表,不是这张。
> **旧仓库仍然零改动**,没有例外——包括修 bug、包括不影响外部接口的内部小修。 > **旧仓库仍然零改动**,没有例外——包括修 bug、包括不影响外部接口的内部小修。
> 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、 > 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、
> 是否重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策 > 是否重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策
@@ -63,12 +49,19 @@ 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 不做类型检查,构建即验证) cd apps/web && bun run type-check # 前端类型检查
cd apps/web && bun run build # 前端构建
``` ```
⚠️ **前端类型检查只能走 `bun run type-check` 这个脚本。** 两条看起来等价的路子
都会**静默通过**`vue-tsc --noEmit -p tsconfig.json` 检查 0 个文件(那个
tsconfig 是 `files: []` + references 的壳,真正的配置在 `tsconfig.app.json`),
`vite build` 根本不做类型检查。改完 .vue / .ts 别拿构建当验证。
**不要写测试** —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。 **不要写测试** —— 沿用上一代的项目约定。验证靠实跑:起服务、打接口、看结果。
本机 Docker 全套都能起,实跑的成本比想象中低。
## 几件必须知道的事 ## 几件必须知道的事
@@ -113,6 +106,47 @@ dev 直接起不来。
这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是 这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是
这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。 这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。
### 出参不 `parse`,用 `satisfies`
**后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。**
出参是后端自己刚拼出来的字面量,TS 已经在编译期校验过;再 `parse` 一遍拿不到任何新
信息,唯一可能失败的输入是**库里的历史数据**,而失败的代价是 500。这一层原来有 136 处,
已经全部撤掉,撤的时候当场炸出两个一直存在的线上 500:
- `adminProblemSchema.lastUpdateTime` 写的是 `z.string()`,但 `problem.last_update_time`
是全库唯一可空的列(961 道题里 470 道是 NULL)——**后台打开任何一道没编辑过的老题都是 500**;
- `embeddedSubmissionSchema``submissionDetailSchema` 继承了 `problemDisplayId` 却没
omit,而路由只填了同义的 `problem`——**凡是收到过站内信的人,消息页都打不开**(列表为空
时才碰巧不炸,所以一直没人报)。
两个都是「读出侧校验」自己造出来的故障,不是它拦住的故障。历史上还有两次同类:
`exerciseSchema` 按题型收紧后一行脏数据让整条练习列表 500;`info` 写成
`union([完整形状, z.object({})])` 后对不上的一律落进空对象那支且 parse **成功**
管理员详情页的测试点表格静默消失(全量核出 9163/124192 条中招,RE 8480/8480 全中——
沙箱在非正常退出的测试点上写 `output_md5: null`,而契约写的是 `z.string()`)。
**闸设在写入侧,一共三处形态:**
1. **入参 `safeParse`**(58 处,全部保留)—— 请求体进来的那一刻校验,对不上回 400。
2. **`db/schema.ts``.$type<>()`** —— 枚举型的列(`submission.result` / `.language`
`problem.difficulty` / `.languages``achievement.rarity``exercise.type`…)和几个
形状确定的 JSONB`problem.template` / `.astRules` / `.sqlConfig` / `.sqlDisplay`
`acm_contest_rank.submission_info`)直接在列上收窄,只影响 TS、不产生任何 SQL。
这些断言**逐列拿根目录那份生产备份核过**(12.4 万条提交的 `result` 全在 `-2..6,10`
961 道题的 `languages` 全是合法数组、10050 条榜单条目形状全对)。
加这类断言前先照样核一遍,别凭直觉。
3. **语义校验函数** —— `astRulesError()``services/exercise.ts``exerciseDataError`
**JSONB 原文(`submission.info` / `statistic_info` / `exercise.data`)仍然一律放行**
读出侧不收窄:它们的形状真相在判题机那边。
query 里的筛选值要和收窄过的列比较时走 `routes/helpers.ts``asFilterValue()` ——
那是纯类型交接,**不加校验**:在那儿拦一道会把「筛出空列表」变成「筛条件被忽略、
返回全部」。前端那侧(`utils/contract.ts` 为什么只挂三处)见 `apps/web/CLAUDE.md`
唯一还留着 `parse` 的地方是 `judge/events.ts``parseSubmissionEvent` ——
那是从 Redis 收回来的报文,真边界,且失败返回 `null` 而不是 500。
### AST 代码规则有两张表,必须同增同减 ### AST 代码规则有两张表,必须同增同减
契约的 `AST_NODE_TARGETS_BY_LANGUAGE`(target → 中文名)决定后台下拉能选什么, 契约的 `AST_NODE_TARGETS_BY_LANGUAGE`(target → 中文名)决定后台下拉能选什么,
+50 -20
View File
@@ -15,6 +15,17 @@
// 详见 CLAUDE.md「改 schema 走 drizzle migration」): // 详见 CLAUDE.md「改 schema 走 drizzle migration」):
// * bigint identity 的 maxValue 用字符串,不能写成 JS number 字面量(会丢精度)。 // * bigint identity 的 maxValue 用字符串,不能写成 JS number 字面量(会丢精度)。
// * 索引不写 `.desc()`,生成 SQL 时方向会被丢掉。 // * 索引不写 `.desc()`,生成 SQL 时方向会被丢掉。
// * `.$type<...>()` 的收窄(见下)—— `pull` 只会吐出 text/integer/jsonb,重新 pull
// 会把这些断言全抹掉,之后出参的 `satisfies` 会当场编译不过(这是好事,别拿
// `as` 糊过去,把断言补回来)。
//
// 2026-09-10:出参不再 `xxxSchema.parse()` 而是 `satisfies`(原来 136 处),
// 收窄的责任因此挪到了列上:枚举型的列和几个形状确定的 JSONB 都挂了 `.$type<>()`。
// `$type` 只是 TS 层的断言、不产生任何 SQL,所以它成立与否得靠数据说话 ——
// 下面每一处都拿根目录那份生产备份逐列核过(12.4 万条提交的 result 全在 -2..6,10、
// 961 道题的 languages 全是合法数组、10050 条榜单条目形状全对,无一例外)。
// **再给别的列加 $type 之前,照样先核一遍全量数据。** 兑现这些断言的是写入侧的
// `safeParse`,不是读出侧。前因后果见 CLAUDE.md「出参不 `parse`,用 `satisfies`」。
// //
// 关于 10 张表的 bigint idproblemset*、achievement、user_achievement、user_stat、 // 关于 10 张表的 bigint idproblemset*、achievement、user_achievement、user_stat、
// user_badge、ai_analysis):这是历史巧合不是设计——这些 app 的 0001_initial 生成时 // user_badge、ai_analysis):这是历史巧合不是设计——这些 app 的 0001_initial 生成时
@@ -22,7 +33,26 @@
// problem、contest、submission)都是 int4。现存最大 id 一万出头,确实都用不上 bigint, // problem、contest、submission)都是 int4。现存最大 id 一万出头,确实都用不上 bigint,
// 但 2026-08-26 评估后决定**不改**:省 4 字节/行毫无意义,ALTER TYPE 要重写整表并拿 // 但 2026-08-26 评估后决定**不改**:省 4 字节/行毫无意义,ALTER TYPE 要重写整表并拿
// ACCESS EXCLUSIVE 锁,而且其中 6 处 id 被外键绑着得连坐。别再提这件事了。 // ACCESS EXCLUSIVE 锁,而且其中 6 处 id 被外键绑着得连坐。别再提这件事了。
import type { AdminType, ProblemPermission } from "@oj2/contract" import type {
AchievementOperator,
AchievementRarity,
AdminType,
AstRules,
BadgeConditionType,
ContestSubmissionInfo,
ExerciseType,
FlowchartStatus,
JudgeStatus,
ProblemDifficulty,
ProblemLanguage,
ProblemPermission,
ProblemSetDifficulty,
ProblemSetStatus,
ReactionKey,
SqlConfig,
SqlDisplay,
TutorialType,
} from "@oj2/contract"
import { pgTable, index, foreignKey, primaryKey, bigint, text, jsonb, timestamp, integer, boolean, serial, doublePrecision, varchar, unique, uniqueIndex } from "drizzle-orm/pg-core" import { pgTable, index, foreignKey, primaryKey, bigint, text, jsonb, timestamp, integer, boolean, serial, doublePrecision, varchar, unique, uniqueIndex } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm" import { sql } from "drizzle-orm"
@@ -75,10 +105,10 @@ export const achievement = pgTable("achievement", {
name: text().notNull(), name: text().notNull(),
description: text().notNull(), description: text().notNull(),
icon: text().notNull(), icon: text().notNull(),
rarity: text().notNull(), rarity: text().notNull().$type<AchievementRarity>(),
hidden: boolean().default(false).notNull(), hidden: boolean().default(false).notNull(),
metric: text().notNull(), metric: text().notNull(),
operator: text().notNull(), operator: text().notNull().$type<AchievementOperator>(),
threshold: integer().notNull(), threshold: integer().notNull(),
visible: boolean().default(true).notNull(), visible: boolean().default(true).notNull(),
unlockCount: integer("unlock_count").default(0).notNull(), unlockCount: integer("unlock_count").default(0).notNull(),
@@ -111,7 +141,7 @@ export const flowchartSubmission = pgTable("flowchart_submission", {
id: text().primaryKey().notNull(), id: text().primaryKey().notNull(),
mermaidCode: text("mermaid_code").notNull(), mermaidCode: text("mermaid_code").notNull(),
flowchartData: jsonb("flowchart_data").notNull(), flowchartData: jsonb("flowchart_data").notNull(),
status: integer().notNull(), status: integer().notNull().$type<FlowchartStatus>(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
aiScore: doublePrecision("ai_score"), aiScore: doublePrecision("ai_score"),
aiGrade: varchar("ai_grade", { length: 10 }), aiGrade: varchar("ai_grade", { length: 10 }),
@@ -189,7 +219,7 @@ export const judgeServer = pgTable("judge_server", {
export const exercise = pgTable("exercise", { export const exercise = pgTable("exercise", {
id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "exercise_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }), id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "exercise_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
type: varchar({ length: 16 }).notNull(), type: varchar({ length: 16 }).notNull().$type<ExerciseType>(),
data: jsonb().notNull(), data: jsonb().notNull(),
order: integer().notNull(), order: integer().notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).notNull(),
@@ -219,8 +249,8 @@ export const problemset = pgTable("problemset", {
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }).notNull(), lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }).notNull(),
visible: boolean().notNull(), visible: boolean().notNull(),
difficulty: text().notNull(), difficulty: text().notNull().$type<ProblemSetDifficulty>(),
status: text().notNull(), status: text().notNull().$type<ProblemSetStatus>(),
createdById: integer("created_by_id").notNull(), createdById: integer("created_by_id").notNull(),
endTime: timestamp("end_time", { withTimezone: true, mode: 'string' }), endTime: timestamp("end_time", { withTimezone: true, mode: 'string' }),
}, (table) => [ }, (table) => [
@@ -324,7 +354,7 @@ export const problemsetSubmission = pgTable("problemset_submission", {
export const reaction = pgTable("reaction", { export const reaction = pgTable("reaction", {
id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "reaction_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }), id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "reaction_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
type: varchar({ length: 20 }).notNull(), type: varchar({ length: 20 }).notNull().$type<ReactionKey>(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
problemId: integer("problem_id").notNull(), problemId: integer("problem_id").notNull(),
userId: integer("user_id").notNull(), userId: integer("user_id").notNull(),
@@ -354,14 +384,14 @@ export const problem = pgTable("problem", {
testCaseId: text("test_case_id").notNull(), testCaseId: text("test_case_id").notNull(),
testCaseScore: jsonb("test_case_score").notNull(), testCaseScore: jsonb("test_case_score").notNull(),
hint: text(), hint: text(),
languages: jsonb().notNull(), languages: jsonb().notNull().$type<ProblemLanguage[]>(),
template: jsonb().notNull(), template: jsonb().notNull().$type<Record<string, string>>(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }), lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }),
timeLimit: integer("time_limit").notNull(), timeLimit: integer("time_limit").notNull(),
memoryLimit: integer("memory_limit").notNull(), memoryLimit: integer("memory_limit").notNull(),
visible: boolean().default(true).notNull(), visible: boolean().default(true).notNull(),
difficulty: text().notNull(), difficulty: text().notNull().$type<ProblemDifficulty>(),
source: text(), source: text(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations // You can use { mode: "bigint" } if numbers are exceeding js number limitations
submissionNumber: bigint("submission_number", { mode: "number" }).default(0).notNull(), submissionNumber: bigint("submission_number", { mode: "number" }).default(0).notNull(),
@@ -386,9 +416,9 @@ export const problem = pgTable("problem", {
flowchartHint: text("flowchart_hint"), flowchartHint: text("flowchart_hint"),
mermaidCode: text("mermaid_code"), mermaidCode: text("mermaid_code"),
showFlowchart: boolean("show_flowchart").default(false).notNull(), showFlowchart: boolean("show_flowchart").default(false).notNull(),
astRules: jsonb("ast_rules"), astRules: jsonb("ast_rules").$type<AstRules>(),
sqlConfig: jsonb("sql_config"), sqlConfig: jsonb("sql_config").$type<SqlConfig>(),
sqlDisplay: jsonb("sql_display"), sqlDisplay: jsonb("sql_display").$type<SqlDisplay>(),
}, (table) => [ }, (table) => [
index("problem_contest_visible_idx").using("btree", table.contestId.asc().nullsLast().op("bool_ops"), table.visible.asc().nullsLast().op("int4_ops")), index("problem_contest_visible_idx").using("btree", table.contestId.asc().nullsLast().op("bool_ops"), table.visible.asc().nullsLast().op("int4_ops")),
index("problem_created_by_id_cb362143").using("btree", table.createdById.asc().nullsLast().op("int4_ops")), index("problem_created_by_id_cb362143").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
@@ -439,9 +469,9 @@ export const submission = pgTable("submission", {
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
userId: integer("user_id").notNull(), userId: integer("user_id").notNull(),
code: text().notNull(), code: text().notNull(),
result: integer().default(6).notNull(), result: integer().default(6).notNull().$type<JudgeStatus>(),
info: jsonb().default({}).notNull(), info: jsonb().default({}).notNull(),
language: text().notNull(), language: text().notNull().$type<ProblemLanguage>(),
/** /**
* 已停用,见 problem.share_submission 的说明。历史上 12.3 万条提交里有 40 条 * 已停用,见 problem.share_submission 的说明。历史上 12.3 万条提交里有 40 条
* 为真(2022 年 39 条、2023 年 1 条),入口在更早的那版前端上,ojnext 和 OJ2 * 为真(2022 年 39 条、2023 年 1 条),入口在更早的那版前端上,ojnext 和 OJ2
@@ -560,7 +590,7 @@ export const tutorial = pgTable("tutorial", {
order: integer().notNull(), order: integer().notNull(),
createdById: integer("created_by_id").notNull(), createdById: integer("created_by_id").notNull(),
code: text(), code: text(),
type: varchar({ length: 10 }).notNull(), type: varchar({ length: 10 }).notNull().$type<TutorialType>(),
}, (table) => [ }, (table) => [
index("tutorial_created_by_id_07973cab").using("btree", table.createdById.asc().nullsLast().op("int4_ops")), index("tutorial_created_by_id_07973cab").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
foreignKey({ foreignKey({
@@ -635,7 +665,7 @@ export const userBadge = pgTable("user_badge", {
export const userProfile = pgTable("user_profile", { export const userProfile = pgTable("user_profile", {
id: serial().primaryKey().notNull(), id: serial().primaryKey().notNull(),
acmProblemsStatus: jsonb("acm_problems_status").default({}).notNull(), acmProblemsStatus: jsonb("acm_problems_status").default({}).notNull().$type<Record<string, unknown>>(),
avatar: text().notNull(), avatar: text().notNull(),
mood: text(), mood: text(),
acceptedNumber: integer("accepted_number").default(0).notNull(), acceptedNumber: integer("accepted_number").default(0).notNull(),
@@ -656,7 +686,7 @@ export const acmContestRank = pgTable("acm_contest_rank", {
submissionNumber: integer("submission_number").default(0).notNull(), submissionNumber: integer("submission_number").default(0).notNull(),
acceptedNumber: integer("accepted_number").default(0).notNull(), acceptedNumber: integer("accepted_number").default(0).notNull(),
totalTime: integer("total_time").default(0).notNull(), totalTime: integer("total_time").default(0).notNull(),
submissionInfo: jsonb("submission_info").default({}).notNull(), submissionInfo: jsonb("submission_info").default({}).notNull().$type<Record<string, ContestSubmissionInfo>>(),
contestId: integer("contest_id").notNull(), contestId: integer("contest_id").notNull(),
userId: integer("user_id").notNull(), userId: integer("user_id").notNull(),
}, (table) => [ }, (table) => [
@@ -705,7 +735,7 @@ export const problemsetBadge = pgTable("problemset_badge", {
name: text().notNull(), name: text().notNull(),
description: text().notNull(), description: text().notNull(),
icon: text().notNull(), icon: text().notNull(),
conditionType: text("condition_type").notNull(), conditionType: text("condition_type").notNull().$type<BadgeConditionType>(),
conditionValue: integer("condition_value").notNull(), conditionValue: integer("condition_value").notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations // You can use { mode: "bigint" } if numbers are exceeding js number limitations
problemsetId: bigint("problemset_id", { mode: "number" }).notNull(), problemsetId: bigint("problemset_id", { mode: "number" }).notNull(),
+2 -2
View File
@@ -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"
@@ -72,7 +72,7 @@ export function userEventTopic(userId: number) {
} }
export async function publishFlowchartUpdate(userId: number, data: FlowchartUpdate) { export async function publishFlowchartUpdate(userId: number, data: FlowchartUpdate) {
await redis.publish(userEventChannel, JSON.stringify({ userId, data: flowchartUpdateSchema.parse(data) })) await redis.publish(userEventChannel, JSON.stringify({ userId, data }))
} }
export async function publishAchievementNotification( export async function publishAchievementNotification(
+5 -5
View File
@@ -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"
@@ -71,7 +71,7 @@ export async function evaluateFlowchart(
processingTime: (performance.now() - started) / 1000, processingTime: (performance.now() - started) / 1000,
evaluationTime: new Date().toISOString(), evaluationTime: new Date().toISOString(),
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id)) }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({ 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 +79,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 的地址、内部报错就这么进了浏览器。真实原因留在服务端日志里,
@@ -91,10 +91,10 @@ export async function evaluateFlowchart(
// 就算成功,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.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({ 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
} }
} }
+2 -8
View File
@@ -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))
} }
+6 -9
View File
@@ -1,6 +1,6 @@
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"
@@ -248,15 +248,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:
+5 -1
View File
@@ -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"
@@ -175,7 +177,9 @@ export function runSqlCase(job: Extract<SqlJob, { kind: "judge" }>) {
} }
export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") { export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") {
return runJob<{ tables: unknown[]; expected: unknown }>( // 子进程产出的形状由 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 },
) )
+2 -2
View File
@@ -48,14 +48,14 @@ export function judgeStatusName(result: number) {
* 它们从分母里摘掉 —— 否则全班同时交卷的那几秒,分母涨了分子没涨,正确率凭空掉一截。 * 它们从分母里摘掉 —— 否则全班同时交卷的那几秒,分母涨了分子没涨,正确率凭空掉一截。
* 人数口径不受影响:交了但还在判的学生仍然算「交过」,不该被点名成「没做」。 * 人数口径不受影响:交了但还在判的学生仍然算「交过」,不该被点名成「没做」。
*/ */
export const UNJUDGED_RESULTS: number[] = [JudgeStatus.PENDING, JudgeStatus.JUDGING] export const UNJUDGED_RESULTS: JudgeStatusValue[] = [JudgeStatus.PENDING, JudgeStatus.JUDGING]
/** /**
* **不**计入「这道题失败了几次」的状态。除了通过(含 AST_CHECK_FAILED,那也是答案对了) * **不**计入「这道题失败了几次」的状态。除了通过(含 AST_CHECK_FAILED,那也是答案对了)
* 和还没判完的两个,还排掉 SYSTEM_ERROR —— 判题机自己崩了不是学生的问题, * 和还没判完的两个,还排掉 SYSTEM_ERROR —— 判题机自己崩了不是学生的问题,
* 不该推着 AI 提示的解锁进度往前走。 * 不该推着 AI 提示的解锁进度往前走。
*/ */
export const NON_FAILURE_RESULTS: number[] = [ export const NON_FAILURE_RESULTS: JudgeStatusValue[] = [
JudgeStatus.ACCEPTED, JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED, JudgeStatus.AST_CHECK_FAILED,
JudgeStatus.PENDING, JudgeStatus.PENDING,
+16 -15
View File
@@ -2,15 +2,16 @@ import { randomBytes } from "node:crypto"
import { extname, resolve } from "node:path" import { extname, resolve } from "node:path"
import { import {
STUDENT_ROLES,
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,
} from "@oj2/contract" } from "@oj2/contract"
import { import {
and, and,
@@ -144,7 +145,7 @@ accountRoutes.get("/users/:id/metrics", async (c) => {
.from(schema.submission) .from(schema.submission)
.where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId))) .where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId)))
if (!row?.total || !row.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交") if (!row?.total || !row.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交")
return success(c, metricsSchema.parse({ now: new Date().toISOString(), first: row.first, latest: row.latest })) return success(c, { now: new Date().toISOString(), first: row.first, latest: row.latest } satisfies Metrics)
}) })
/** /**
@@ -199,25 +200,25 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null, isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null,
]) ])
return success(c, userRankSchema.parse({ return success(c, {
results: rows.map((row) => serializeRankRow(row, online)), results: rows.map((row) => serializeRankRow(row, online)),
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE), total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
me, me,
})) } satisfies UserRank)
}) })
function serializeRankRow({ profile, user }: { function serializeRankRow({ profile, user }: {
profile: typeof schema.userProfile.$inferSelect profile: typeof schema.userProfile.$inferSelect
user: typeof schema.user.$inferSelect user: typeof schema.user.$inferSelect
}, online: Set<number> | null = null) { }, online: Set<number> | null = null) {
return rankProfileSchema.parse({ 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, isOnline: online ? online.has(user.id) : null,
}) } satisfies RankProfile
} }
/** /**
@@ -251,10 +252,10 @@ async function myLeaderboardRank(userId: number | undefined) {
), ),
))) )))
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) => {
@@ -279,7 +280,7 @@ accountRoutes.get("/rankings/activity", async (c) => {
)) ))
.groupBy(schema.submission.userId, schema.user.username) .groupBy(schema.submission.userId, schema.user.username)
.orderBy(desc(countDistinct(schema.submission.problemId))).limit(10) .orderBy(desc(countDistinct(schema.submission.problemId))).limit(10)
return success(c, rows.map((row) => activityRankItemSchema.parse({ username: row.username, count: row.value }))) return success(c, rows.map((row) => ({ username: row.username, count: row.value } satisfies ActivityRankItem)))
}) })
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => { accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
@@ -303,7 +304,7 @@ accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
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)
}) })
/** /**
+11 -11
View File
@@ -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"
@@ -27,13 +27,13 @@ async function resolveUser(requested: string | undefined, currentId: number) {
} }
function pendingData(row: { achievement: typeof schema.achievement.$inferSelect }) { function pendingData(row: { achievement: typeof schema.achievement.$inferSelect }) {
return pendingAchievementSchema.parse({ 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) => {
@@ -52,7 +52,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,
@@ -67,9 +67,9 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
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) => {
@@ -85,7 +85,7 @@ achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
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,
@@ -97,7 +97,7 @@ achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
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) => {
+18 -15
View File
@@ -1,16 +1,16 @@
import { import {
STUDENT_ROLES,
adminTypeSchema, adminTypeSchema,
adminUserListSchema,
adminUserRankSchema,
adminUserSchema,
deleteUsersRequestSchema, deleteUsersRequestSchema,
importUsersRequestSchema, importUsersRequestSchema,
rankProfileSchema, STUDENT_ROLES,
resetPasswordResponseSchema,
updateUserRequestSchema, updateUserRequestSchema,
type AdminType, type AdminType,
type AdminUser,
type AdminUserList,
type AdminUserRank,
type ProblemPermission, type ProblemPermission,
type RankProfile,
type ResetPasswordResponse,
} from "@oj2/contract" } from "@oj2/contract"
import { randomInt } from "node:crypto" import { randomInt } from "node:crypto"
import { z } from "zod" import { z } from "zod"
@@ -66,7 +66,7 @@ function serialize(row: {
user: typeof schema.user.$inferSelect user: typeof schema.user.$inferSelect
realName: string | null realName: string | null
}, isOnline: boolean) { }, isOnline: boolean) {
return adminUserSchema.parse({ 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,
@@ -79,7 +79,7 @@ function serialize(row: {
isOnline, 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) {
@@ -122,16 +122,19 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
.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(({ profile, user }) => ({
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,
})), // 这张榜不下发在线状态(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) => {
@@ -181,10 +184,10 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => {
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where)
.orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset), .orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset),
]) ])
return success(c, adminUserListSchema.parse({ return success(c, {
results: rows.map((row) => serialize(row, online.has(row.user.id))), 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) => {
@@ -447,5 +450,5 @@ adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c
}).where(eq(schema.user.id, id)) }).where(eq(schema.user.id, id))
// 旧密码登出来的会话立刻作废,理由同 PUT /users/:id // 旧密码登出来的会话立刻作废,理由同 PUT /users/:id
await revokeUserSessions(id, "session-ended") await revokeUserSessions(id, "session-ended")
return success(c, resetPasswordResponseSchema.parse({ password })) return success(c, { password } satisfies ResetPasswordResponse)
}) })
+5 -5
View File
@@ -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"
@@ -17,7 +17,7 @@ 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,12 +32,12 @@ 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)
+13 -13
View File
@@ -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"
@@ -22,13 +22,13 @@ function excerpt(analysis: string | null) {
} }
function listItem(row: { id: number; username: string; createTime: string; analysis: string; isPinned: boolean }) { function listItem(row: { id: number; username: string; createTime: string; analysis: string; isPinned: boolean }) {
return adminAiReportListItemSchema.parse({ 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 = {
@@ -53,10 +53,10 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
.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 })
@@ -68,10 +68,10 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where) .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where)
.orderBy(desc(schema.aiAnalysis.createTime)).limit(limit).offset(offset), .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) => {
@@ -86,7 +86,7 @@ adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
.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) => {
@@ -104,5 +104,5 @@ adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
} }
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)
}) })
+6 -6
View File
@@ -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,7 +29,7 @@ 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) {
@@ -55,11 +55,11 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
.limit(limit) .limit(limit)
.offset(offset), .offset(offset),
]) ])
return success(c, adminAnnouncementListSchema.parse({ return success(c, {
// 列表 schema omit 掉了 contentZod 会 strip 掉多出来的键,这里不必手工再挑一遍 // 列表 schema omit 掉了 contentZod 会 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) => {
+21 -21
View File
@@ -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"
@@ -57,7 +57,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,7 +66,7 @@ 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) => {
@@ -96,14 +96,14 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
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.select().from(schema.judgeServer).orderBy(desc(schema.judgeServer.lastHeartbeat))
return success(c, judgeServerListSchema.parse({ return success(c, {
// 后台要显示 token 才能拿去配判题机。这个接口是超管专属的 // 后台要显示 token 才能拿去配判题机。这个接口是超管专属的
token: config.judgeServerToken, token: config.judgeServerToken,
servers: rows.map((row) => judgeServerSchema.parse({ servers: rows.map((row) => ({
...row, ...row,
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal", 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) => {
@@ -146,7 +146,7 @@ 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(ids.map(async (id) => {
const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null) const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null)
return orphanTestCaseSchema.parse({ id, createTime: info ? info.mtimeMs / 1000 : 0 }) return { id, createTime: info ? info.mtimeMs / 1000 : 0 } satisfies OrphanTestCase
})) }))
return success(c, rows) return success(c, rows)
}) })
@@ -180,12 +180,12 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
.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) => {
@@ -218,16 +218,16 @@ 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,13 +236,13 @@ 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() {
+12 -13
View File
@@ -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"
@@ -31,7 +31,7 @@ async function serialize(row: {
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,
@@ -45,7 +45,7 @@ async function serialize(row: {
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) {
@@ -84,10 +84,10 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where).orderBy(desc(schema.contest.createTime)).limit(limit).offset(offset), .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) => {
@@ -266,8 +266,7 @@ adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) =>
const results = [] const results = []
for (const rank of ranks) { for (const rank of ranks) {
if (rank.acceptedNumber <= 0) continue if (rank.acceptedNumber <= 0) continue
for (const [problemId, raw] of Object.entries(objectValue(rank.submissionInfo))) { for (const [problemId, info] of Object.entries(rank.submissionInfo)) {
const info = objectValue(raw)
if (info.is_ac !== true) continue if (info.is_ac !== true) continue
results.push({ results.push({
id: rank.id, id: rank.id,
@@ -285,7 +284,7 @@ adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) =>
} }
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的 // 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
results.sort((left, right) => right._acTime - left._acTime) results.sort((left, right) => right._acTime - left._acTime)
return success(c, results.map(({ _acTime, ...item }) => acmHelperItemSchema.parse(item))) return success(c, results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[])
}) })
adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => { adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => {
@@ -306,9 +305,9 @@ adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) =>
)).limit(1) )).limit(1)
if (!rank) return failure(c, 404, "rank-not-found", "Rank id does not exist") if (!rank) return failure(c, 404, "rank-not-found", "Rank id does not exist")
const info = objectValue(rank.submissionInfo) const info = rank.submissionInfo
const entry = objectValue(info[parsed.data.problemId]) const entry = info[parsed.data.problemId]
if (!info[parsed.data.problemId]) { if (!entry) {
return failure(c, 404, "problem-not-in-rank", "Problem id does not exist") return failure(c, 404, "problem-not-in-rank", "Problem id does not exist")
} }
entry.checked = parsed.data.checked entry.checked = parsed.data.checked
+20 -20
View File
@@ -1,13 +1,13 @@
import { import {
STUDENT_ROLES, STUDENT_ROLES,
TUTORIAL_READ_SECONDS, TUTORIAL_READ_SECONDS,
learnExerciseAttemptSchema, type LearnExerciseAttempt,
learnExerciseProgressListSchema, type LearnExerciseProgress,
learnExerciseProgressSchema, type LearnExerciseProgressList,
learnStudentProgressListSchema, type LearnStudentProgress,
learnStudentProgressSchema, type LearnStudentProgressList,
learnTutorialProgressListSchema, type LearnTutorialProgress,
learnTutorialProgressSchema, type LearnTutorialProgressList,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm" import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -120,16 +120,16 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
.where(inArray(schema.exercise.tutorialId, tutorialIds)) .where(inArray(schema.exercise.tutorialId, tutorialIds))
: [{ value: 0 }] : [{ value: 0 }]
return success(c, learnStudentProgressListSchema.parse({ return success(c, {
tutorialCount: tutorialIds.length, tutorialCount: tutorialIds.length,
exerciseCount: exerciseCountRow?.value ?? 0, exerciseCount: exerciseCountRow?.value ?? 0,
results: rows.map((row) => learnStudentProgressSchema.parse({ results: rows.map((row) => ({
...row, ...row,
exerciseTried: attempts.get(row.userId)?.tried ?? 0, exerciseTried: attempts.get(row.userId)?.tried ?? 0,
exerciseSolved: attempts.get(row.userId)?.solved ?? 0, exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0, exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
})), } satisfies LearnStudentProgress)),
})) } satisfies LearnStudentProgressList)
}) })
adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => { adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => {
@@ -166,13 +166,13 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order) .groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order)) .orderBy(asc(schema.tutorial.order))
return success(c, learnTutorialProgressListSchema.parse({ return success(c, {
studentCount, studentCount,
results: rows.map(({ readSeconds, ...row }) => learnTutorialProgressSchema.parse({ results: rows.map(({ readSeconds, ...row }) => ({
...row, ...row,
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0, avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
})), } satisfies LearnTutorialProgress)),
})) } satisfies LearnTutorialProgressList)
}) })
/** /**
@@ -216,13 +216,13 @@ adminLearnRoutes.get("/learn-analytics/exercises", requireTeacher, async (c) =>
.groupBy(schema.exercise.id, schema.tutorial.id, schema.tutorial.title, schema.tutorial.order) .groupBy(schema.exercise.id, schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order)) .orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
return success(c, learnExerciseProgressListSchema.parse({ return success(c, {
studentCount: studentCountRow?.value ?? 0, studentCount: studentCountRow?.value ?? 0,
results: rows.map((row) => learnExerciseProgressSchema.parse({ results: rows.map((row) => ({
...row, ...row,
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1), avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
})), } satisfies LearnExerciseProgress)),
})) } satisfies LearnExerciseProgressList)
}) })
/** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */ /** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */
@@ -249,5 +249,5 @@ adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher,
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的 // 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
.orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts)) .orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts))
return success(c, rows.map((row) => learnExerciseAttemptSchema.parse(row))) return success(c, rows satisfies LearnExerciseAttempt[])
}) })
+32 -31
View File
@@ -1,18 +1,19 @@
import { import {
addContestProblemRequestSchema, addContestProblemRequestSchema,
adminProblemListItemSchema,
adminProblemListSchema,
adminProblemSchema,
createProblemRequestSchema, createProblemRequestSchema,
makeProblemPublicRequestSchema,
updateProblemRequestSchema,
generateSqlTestCaseRequestSchema, generateSqlTestCaseRequestSchema,
generateSqlTestCaseResponseSchema, makeProblemPublicRequestSchema,
sqlPreviewRequestSchema, sqlPreviewRequestSchema,
sqlTestCaseScriptSchema, updateProblemRequestSchema,
uploadTestCaseResponseSchema, type AdminProblem,
type AdminProblemList,
type AdminProblemListItem,
type AstRules, type AstRules,
type GenerateSqlTestCaseResponse,
type SqlConfig, type SqlConfig,
type SqlDisplay,
type SqlTestCaseScript,
type UploadTestCaseResponse,
} from "@oj2/contract" } from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm" import { and, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -30,7 +31,7 @@ import { config } from "../../config"
import { readFile } from "node:fs/promises" import { readFile } from "node:fs/promises"
import { resolve } from "node:path" import { resolve } from "node:path"
import { getTopReactions } from "../../services/reaction" import { getTopReactions } from "../../services/reaction"
import { objectValue, queryInteger, sampleUser, stringArray } from "../helpers" import { objectValue, queryInteger, sampleUser } from "../helpers"
export const adminProblemRoutes = new Hono<AppEnv>() export const adminProblemRoutes = new Hono<AppEnv>()
@@ -130,7 +131,7 @@ async function serialize(row: ProblemRow) {
.where(eq(schema.user.id, row.createdById)).limit(1), .where(eq(schema.user.id, row.createdById)).limit(1),
tagNames(row.id), tagNames(row.id),
]) ])
return adminProblemSchema.parse({ return {
id: row.id, id: row.id,
_id: row.displayId, _id: row.displayId,
title: row.title, title: row.title,
@@ -141,8 +142,8 @@ async function serialize(row: ProblemRow) {
testCaseId: row.testCaseId, testCaseId: row.testCaseId,
testCaseScore: Array.isArray(row.testCaseScore) ? row.testCaseScore : [], testCaseScore: Array.isArray(row.testCaseScore) ? row.testCaseScore : [],
hint: row.hint, hint: row.hint,
languages: stringArray(row.languages), languages: row.languages,
template: objectValue(row.template), template: row.template,
createTime: row.createTime, createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime, lastUpdateTime: row.lastUpdateTime,
timeLimit: row.timeLimit, timeLimit: row.timeLimit,
@@ -164,9 +165,9 @@ async function serialize(row: ProblemRow) {
astRules: row.astRules, astRules: row.astRules,
answers: Array.isArray(row.answers) ? row.answers : [], answers: Array.isArray(row.answers) ? row.answers : [],
prompt: row.prompt, prompt: row.prompt,
sqlConfig: row.sqlConfig ? objectValue(row.sqlConfig) : null, sqlConfig: row.sqlConfig,
sqlDisplay: row.sqlDisplay ? objectValue(row.sqlDisplay) : null, sqlDisplay: row.sqlDisplay,
}) } satisfies AdminProblem
} }
/** 公共校验,对齐旧 `ProblemBase.common_checks` */ /** 公共校验,对齐旧 `ProblemBase.common_checks` */
@@ -205,7 +206,7 @@ async function generateSqlDisplay(
testCaseId: string, testCaseId: string,
answers: Record<string, unknown>[], answers: Record<string, unknown>[],
sqlConfig: SqlConfig, sqlConfig: SqlConfig,
): Promise<{ error: string } | { display: unknown }> { ): Promise<{ error: string } | { display: SqlDisplay }> {
const info = await readInfo(testCaseId) const info = await readInfo(testCaseId)
if (!info) return { error: "测试点信息读取失败,请重新上传测试点" } if (!info) return { error: "测试点信息读取失败,请重新上传测试点" }
if (!info.sql) return { error: "测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包" } if (!info.sql) return { error: "测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包" }
@@ -294,9 +295,9 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
// 只有公开题列表下发最高票评价,比赛题列表不下发 —— 与旧后端一致 // 只有公开题列表下发最高票评价,比赛题列表不下发 —— 与旧后端一致
const problemIds = rows.map(({ problem }) => problem.id) const problemIds = rows.map(({ problem }) => problem.id)
const [topReactions, tags] = await Promise.all([getTopReactions(problemIds), tagNamesFor(problemIds)]) const [topReactions, tags] = await Promise.all([getTopReactions(problemIds), tagNamesFor(problemIds)])
return success(c, adminProblemListSchema.parse({ return success(c, {
results: rows.map(({ problem, user: creator, realName }) => results: rows.map(({ problem, user: creator, realName }) =>
adminProblemListItemSchema.parse({ ({
id: problem.id, id: problem.id,
_id: problem.displayId, _id: problem.displayId,
title: problem.title, title: problem.title,
@@ -309,9 +310,9 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
allowFlowchart: problem.allowFlowchart, allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart, showFlowchart: problem.showFlowchart,
topReaction: topReactions.get(problem.id) ?? null, topReaction: topReactions.get(problem.id) ?? null,
})), } satisfies AdminProblemListItem)),
total: totalRow[0]?.value ?? 0, total: totalRow[0]?.value ?? 0,
})) } satisfies AdminProblemList)
}) })
adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => { adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => {
@@ -331,7 +332,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
} }
const checked = commonChecks(parsed.data) const checked = commonChecks(parsed.data)
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error) if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
let sqlDisplay: unknown = null let sqlDisplay: SqlDisplay | null = null
if (checked.sql) { if (checked.sql) {
const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!) const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!)
if ("error" in built) return failure(c, 400, "invalid-problem", built.error) if ("error" in built) return failure(c, 400, "invalid-problem", built.error)
@@ -388,7 +389,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
if (duplicate) return failure(c, 409, "display-id-exists", "Display ID already exists") if (duplicate) return failure(c, 409, "display-id-exists", "Display ID already exists")
// SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上 // SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上
let sqlDisplay: unknown = null let sqlDisplay: SqlDisplay | null = null
if (checked.sql) { if (checked.sql) {
const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!) const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!)
if ("error" in built) return failure(c, 400, "invalid-problem", built.error) if ("error" in built) return failure(c, 400, "invalid-problem", built.error)
@@ -467,9 +468,9 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
.where(where).orderBy(desc(schema.problem.createTime)).limit(limit).offset(offset), .where(where).orderBy(desc(schema.problem.createTime)).limit(limit).offset(offset),
]) ])
const tags = await tagNamesFor(rows.map(({ problem }) => problem.id)) const tags = await tagNamesFor(rows.map(({ problem }) => problem.id))
return success(c, adminProblemListSchema.parse({ return success(c, {
results: rows.map(({ problem, user: creator, realName }) => results: rows.map(({ problem, user: creator, realName }) =>
adminProblemListItemSchema.parse({ ({
id: problem.id, id: problem.id,
_id: problem.displayId, _id: problem.displayId,
title: problem.title, title: problem.title,
@@ -482,9 +483,9 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
allowFlowchart: problem.allowFlowchart, allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart, showFlowchart: problem.showFlowchart,
topReaction: null, topReaction: null,
})), } satisfies AdminProblemListItem)),
total: totalRow[0]?.value ?? 0, total: totalRow[0]?.value ?? 0,
})) } satisfies AdminProblemList)
}) })
adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermission, async (c) => { adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermission, async (c) => {
@@ -500,7 +501,7 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio
} }
const checked = commonChecks(parsed.data) const checked = commonChecks(parsed.data)
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error) if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
let sqlDisplay: unknown = null let sqlDisplay: SqlDisplay | null = null
if (checked.sql) { if (checked.sql) {
const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!) const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!)
if ("error" in built) return failure(c, 400, "invalid-problem", built.error) if ("error" in built) return failure(c, 400, "invalid-problem", built.error)
@@ -652,10 +653,10 @@ adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
const sql = ["1", "true", "True"].includes(String(form?.get("sql") ?? "")) const sql = ["1", "true", "True"].includes(String(form?.get("sql") ?? ""))
try { try {
const result = await processTestCaseZip(new Uint8Array(await file.arrayBuffer()), { sql }) const result = await processTestCaseZip(new Uint8Array(await file.arrayBuffer()), { sql })
return success(c, uploadTestCaseResponseSchema.parse({ return success(c, {
id: result.testCaseId, id: result.testCaseId,
info: result.info, info: result.info,
}), 201) } satisfies UploadTestCaseResponse, 201)
} catch (error) { } catch (error) {
if (error instanceof TestCaseError) return failure(c, 400, "invalid-test-case", error.message) if (error instanceof TestCaseError) return failure(c, 400, "invalid-test-case", error.message)
console.error("Failed to process test case zip", error) console.error("Failed to process test case zip", error)
@@ -701,7 +702,7 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as
if (!info.sql) return failure(c, 409, "not-sql-test-case", "该题的测试点不是 SQL 类型") if (!info.sql) return failure(c, 409, "not-sql-test-case", "该题的测试点不是 SQL 类型")
try { try {
const scripts = await readSqlScripts(problem.testCaseId) const scripts = await readSqlScripts(problem.testCaseId)
return success(c, scripts.map((script) => sqlTestCaseScriptSchema.parse(script))) return success(c, scripts satisfies SqlTestCaseScript[])
} catch (error) { } catch (error) {
console.error("Failed to read SQL test case scripts", error) console.error("Failed to read SQL test case scripts", error)
return failure(c, 500, "test-case-error", "测试点脚本读取失败") return failure(c, 500, "test-case-error", "测试点脚本读取失败")
@@ -735,7 +736,7 @@ SELECT 语句,或增删改题的 UPDATE/DELETE/INSERT 语句)和题型。
请只返回 SQL 脚本本身,连 \`\`\` 都不需要,不要任何解释文字。`, 请只返回 SQL 脚本本身,连 \`\`\` 都不需要,不要任何解释文字。`,
`题型:${parsed.data.mode}\n标准答案:\n${parsed.data.refSql}`, `题型:${parsed.data.mode}\n标准答案:\n${parsed.data.refSql}`,
) )
return success(c, generateSqlTestCaseResponseSchema.parse({ sql })) return success(c, { sql } satisfies GenerateSqlTestCaseResponse)
} catch (error) { } catch (error) {
console.error("SQL test case generation failed", error) console.error("SQL test case generation failed", error)
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试") return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")
+18 -18
View File
@@ -1,16 +1,16 @@
import { import {
addProblemToSetRequestSchema, addProblemToSetRequestSchema,
adminProblemSetBadgeSchema,
adminProblemSetListSchema,
adminProblemSetProblemSchema,
adminProblemSetProgressSchema,
adminProblemSetSchema,
createProblemSetBadgeRequestSchema, createProblemSetBadgeRequestSchema,
createProblemSetRequestSchema, createProblemSetRequestSchema,
updateProblemInSetRequestSchema, updateProblemInSetRequestSchema,
updateProblemSetBadgeRequestSchema, updateProblemSetBadgeRequestSchema,
updateProblemSetRequestSchema, updateProblemSetRequestSchema,
updateProblemSetStatusRequestSchema, updateProblemSetStatusRequestSchema,
type AdminProblemSet,
type AdminProblemSetBadge,
type AdminProblemSetList,
type AdminProblemSetProblem,
type AdminProblemSetProgress,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm" import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -20,7 +20,7 @@ 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 { recalculateBadge, resyncProgress } from "../../services/problemset" import { recalculateBadge, resyncProgress } from "../../services/problemset"
import { queryInteger, sampleUser } from "../helpers" import { asFilterValue, queryInteger, sampleUser } from "../helpers"
export const adminProblemSetRoutes = new Hono<AppEnv>() export const adminProblemSetRoutes = new Hono<AppEnv>()
@@ -65,7 +65,7 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
const creatorById = new Map(creators.map((item) => [item.id, item])) const creatorById = new Map(creators.map((item) => [item.id, item]))
return rows.map((row) => { return rows.map((row) => {
const creator = creatorById.get(row.createdById) const creator = creatorById.get(row.createdById)
return adminProblemSetSchema.parse({ return {
id: row.id, id: row.id,
title: row.title, title: row.title,
description: row.description, description: row.description,
@@ -78,7 +78,7 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
lastUpdateTime: row.lastUpdateTime, lastUpdateTime: row.lastUpdateTime,
problemsCount: problemsBySet.get(row.id) ?? 0, problemsCount: problemsBySet.get(row.id) ?? 0,
participantCount: participantsBySet.get(row.id) ?? 0, participantCount: participantsBySet.get(row.id) ?? 0,
}) } satisfies AdminProblemSet
}) })
} }
@@ -102,8 +102,8 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
ilike(schema.problemset.description, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`),
)!) )!)
} }
if (difficulty) filters.push(eq(schema.problemset.difficulty, difficulty)) if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (status) filters.push(eq(schema.problemset.status, status)) if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
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([
@@ -111,10 +111,10 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
db.select().from(schema.problemset).where(where) db.select().from(schema.problemset).where(where)
.orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset), .orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset),
]) ])
return success(c, adminProblemSetListSchema.parse({ return success(c, {
results: await serializeMany(rows), results: await serializeMany(rows),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
})) } satisfies AdminProblemSetList)
}) })
adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => { adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => {
@@ -194,7 +194,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id)) .innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
.where(eq(schema.problemsetProblem.problemsetId, row.id)) .where(eq(schema.problemsetProblem.problemsetId, row.id))
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)) .orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
return success(c, rows.map(({ item, problem }) => adminProblemSetProblemSchema.parse({ return success(c, rows.map(({ item, problem }) => ({
id: item.id, id: item.id,
problemsetId: item.problemsetId, problemsetId: item.problemsetId,
problemId: item.problemId, problemId: item.problemId,
@@ -205,7 +205,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c
isRequired: item.isRequired, isRequired: item.isRequired,
score: item.score, score: item.score,
hint: item.hint, hint: item.hint,
}))) } satisfies AdminProblemSetProblem)))
}) })
adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => { adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => {
@@ -289,7 +289,7 @@ async function badgesWithCount(badges: BadgeRow[]) {
.from(schema.userBadge).where(inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id))) .from(schema.userBadge).where(inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id)))
.groupBy(schema.userBadge.badgeId) .groupBy(schema.userBadge.badgeId)
const countByBadge = new Map(earned.map((item) => [item.badgeId, item.value])) const countByBadge = new Map(earned.map((item) => [item.badgeId, item.value]))
return badges.map((badge) => adminProblemSetBadgeSchema.parse({ return badges.map((badge) => ({
id: badge.id, id: badge.id,
problemsetId: badge.problemsetId, problemsetId: badge.problemsetId,
name: badge.name, name: badge.name,
@@ -298,7 +298,7 @@ async function badgesWithCount(badges: BadgeRow[]) {
conditionType: badge.conditionType, conditionType: badge.conditionType,
conditionValue: badge.conditionValue, conditionValue: badge.conditionValue,
earnedCount: countByBadge.get(badge.id) ?? 0, earnedCount: countByBadge.get(badge.id) ?? 0,
})) } satisfies AdminProblemSetBadge))
} }
adminProblemSetRoutes.get("/problem-sets/:id/badges", requireTeacher, async (c) => { adminProblemSetRoutes.get("/problem-sets/:id/badges", requireTeacher, async (c) => {
@@ -375,7 +375,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c
.where(eq(schema.problemsetProgress.problemsetId, row.id)) .where(eq(schema.problemsetProgress.problemsetId, row.id))
.orderBy(desc(schema.problemsetProgress.joinTime)) .orderBy(desc(schema.problemsetProgress.joinTime))
return success(c, rows.map(({ progress, username, realName }) => return success(c, rows.map(({ progress, username, realName }) =>
adminProblemSetProgressSchema.parse({ ({
id: progress.id, id: progress.id,
userId: progress.userId, userId: progress.userId,
username, username,
@@ -388,7 +388,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c
completedProblemsCount: progress.completedProblemsCount, completedProblemsCount: progress.completedProblemsCount,
totalProblemsCount: progress.totalProblemsCount, totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore, totalScore: progress.totalScore,
}))) } satisfies AdminProblemSetProgress)))
}) })
adminProblemSetRoutes.delete("/problem-sets/:id/progress/:userId", requireTeacher, async (c) => { adminProblemSetRoutes.delete("/problem-sets/:id/progress/:userId", requireTeacher, async (c) => {
+17 -17
View File
@@ -1,13 +1,13 @@
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"
@@ -46,7 +46,7 @@ adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
// 后台标签管理要看到 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(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name))
return success(c, rows.map((row) => adminTagSchema.parse(row))) return success(c, rows satisfies AdminTag[])
}) })
adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => { adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
@@ -63,7 +63,7 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
if (!target) { if (!target) {
await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id)) await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id))
return success(c, renameTagResponseSchema.parse({ merged: false, id, name, affectedCount: 0 })) return success(c, { merged: false, id, name, affectedCount: 0 } satisfies RenameTagResponse)
} }
// 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除 // 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除
@@ -86,9 +86,9 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
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("/problem-tags/:id", requireProblemPermission, async (c) => {
@@ -152,10 +152,10 @@ adminTagRoutes.post("/problems/batch-tag", requireProblemPermission, async (c) =
if (rows.length) await tx.insert(schema.problemTags).values(rows) 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)
}) })
// ---------------------------------------------------------------- 题目可见性 // ---------------------------------------------------------------- 题目可见性
@@ -209,14 +209,14 @@ adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
.having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`) .having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`)
.orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`)) .orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`))
.limit(40) .limit(40)
return success(c, rows.map((row) => stuckProblemSchema.parse({ return success(c, rows.map((row) => ({
problemId: row.displayId, problemId: row.displayId,
problemTitle: row.title, problemTitle: row.title,
total: row.total, total: row.total,
failed: row.failed, failed: row.failed,
failedUsers: row.failedUsers, failedUsers: row.failedUsers,
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0, 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) => {
@@ -263,7 +263,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
@@ -274,7 +274,7 @@ 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)
}) })
@@ -292,7 +292,7 @@ adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) =
请只返回 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", "生成失败,请稍后再试")
+9 -9
View File
@@ -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"
@@ -24,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,
@@ -35,7 +35,7 @@ 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) {
@@ -57,10 +57,10 @@ adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
.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/codeZod 会 strip // 分组返回,形状对齐旧 TutorialAdminAPI.get;列表 schema omit 掉了 content/codeZod 会 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) => {
@@ -126,12 +126,12 @@ adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
// ---------------------------------------------------------------- 练习 // ---------------------------------------------------------------- 练习
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= 查询参数,
+28 -27
View File
@@ -1,16 +1,17 @@
import { import {
HINT_MIN_FAILURES,
aiAnalysisRecordSchema,
aiAnalysisRequestSchema, aiAnalysisRequestSchema,
aiDetailSchema,
aiHintRequestSchema, aiHintRequestSchema,
classAnalysisRequestSchema, classAnalysisRequestSchema,
classPkAnalysisRequestSchema, classPkAnalysisRequestSchema,
durationDataSchema, HINT_MIN_FAILURES,
heatmapItemSchema, type AiAnalysisRecord,
loginSummarySchema, type AiDetail,
solvedListSchema, type DurationData,
solvedProblemSchema, type Grade,
type HeatmapItem,
type LoginSummary,
type SolvedList,
type SolvedProblem,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, count, countDistinct, eq, gte, inArray, isNull, lte, min, sql } from "drizzle-orm" import { and, asc, count, countDistinct, eq, gte, inArray, isNull, lte, min, sql } from "drizzle-orm"
import { Hono, type Context } from "hono" import { Hono, type Context } from "hono"
@@ -19,7 +20,7 @@ import { requireAuth, type AppEnv } from "../auth/middleware"
import { getPreviousLogin, type AuthUser } from "../auth/session" import { getPreviousLogin, type AuthUser } from "../auth/session"
import { config } from "../config" import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { JudgeStatus, judgeStatusName } from "../judge/status" import { JudgeStatus, judgeStatusName, type JudgeStatusValue } from "../judge/status"
import { failure, success } from "../http" import { failure, success } from "../http"
import { completeChat, streamChat } from "../services/ai" import { completeChat, streamChat } from "../services/ai"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
@@ -27,7 +28,7 @@ import { countFailedSubmissions, isTeacherOrAbove, objectValue, queryInteger, ro
export const aiRoutes = new Hono<AppEnv>() export const aiRoutes = new Hono<AppEnv>()
const accepted = [0, 10] const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
const difficultyNames: Record<string, string> = { Low: "简单", Mid: "中等", High: "困难" } const difficultyNames: Record<string, string> = { Low: "简单", Mid: "中等", High: "困难" }
/** /**
@@ -61,15 +62,15 @@ const calendarDay = new Intl.DateTimeFormat("en-CA", {
timeZone: CALENDAR_TZ, year: "numeric", month: "2-digit", day: "2-digit", timeZone: CALENDAR_TZ, year: "numeric", month: "2-digit", day: "2-digit",
}) })
function grade(rank: number | null, count: number, reference = count) { function grade(rank: number | null, count: number, reference = count): Grade {
if (!rank || count <= 0) return "C" if (!rank || count <= 0) return "C"
const percentile = (rank - 1) / count * 100 const percentile = (rank - 1) / count * 100
let value = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C" let value: Grade = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value
return value return value
} }
function averageGrade(grades: string[]) { function averageGrade(grades: Grade[]): Grade {
const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 } const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 }
const values = grades.flatMap((item) => weights[item] ?? []) const values = grades.flatMap((item) => weights[item] ?? [])
if (!values.length) return "" if (!values.length) return ""
@@ -149,12 +150,12 @@ async function buildSolved(user: AuthUser, start: string, end: string, firstAc:
const period = ranks(periodRows, item.problemId) const period = ranks(periodRows, item.problemId)
const rank = all.findIndex((row) => row.userId === user.id) + 1 || null const rank = all.findIndex((row) => row.userId === user.id) + 1 || null
const periodRank = period.findIndex((row) => row.userId === user.id) + 1 || null const periodRank = period.findIndex((row) => row.userId === user.id) + 1 || null
return solvedProblemSchema.parse({ return {
problem: { title: problem.problem.title, displayId: problem.problem.displayId, contestTitle: problem.contestTitle ?? "", contestId: problem.problem.contestId }, problem: { title: problem.problem.title, displayId: problem.problem.displayId, contestTitle: problem.contestTitle ?? "", contestId: problem.problem.contestId },
acTime: item.first, rank, acCount: all.length, grade: grade(periodRank, period.length, all.length), periodRank, periodAcCount: period.length, acTime: item.first, rank, acCount: all.length, grade: grade(periodRank, period.length, all.length), periodRank, periodAcCount: period.length,
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等", difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
attempts: attemptsByProblem.get(item.problemId) ?? 1, attempts: attemptsByProblem.get(item.problemId) ?? 1,
}) } satisfies SolvedProblem
}).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime)) }).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
return { solved, problems, scopeIds } return { solved, problems, scopeIds }
} }
@@ -169,7 +170,7 @@ async function listSolved(user: AuthUser, start: string, end: string, limit: num
)), )),
]) ])
const { solved } = await buildSolved(user, start, end, firstAc) const { solved } = await buildSolved(user, start, end, firstAc)
return solvedListSchema.parse({ results: solved, total: totalRows[0]?.value ?? 0 }) return { results: solved, total: totalRows[0]?.value ?? 0 } satisfies SolvedList
} }
async function buildDetail(user: AuthUser, start: string, end: string) { async function buildDetail(user: AuthUser, start: string, end: string) {
@@ -195,7 +196,7 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
eq(schema.submission.userId, user.id), eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, start), lte(schema.submission.createTime, end), gte(schema.submission.createTime, start), lte(schema.submission.createTime, end),
)) ))
const settledFail = (result: number) => const settledFail = (result: JudgeStatusValue) =>
!accepted.includes(result) && result !== JudgeStatus.PENDING && result !== JudgeStatus.JUDGING !accepted.includes(result) && result !== JudgeStatus.PENDING && result !== JudgeStatus.JUDGING
const errorCounts = new Map<number, number>() const errorCounts = new Map<number, number>()
for (const row of submissions) { for (const row of submissions) {
@@ -207,10 +208,10 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
.sort((a, b) => b.count - a.count || a.result - b.result) .sort((a, b) => b.count - a.count || a.result - b.result)
const firstAc = await firstAcQuery(user, start, end) const firstAc = await firstAcQuery(user, start, end)
const problemIds = firstAc.map((item) => item.problemId) const problemIds = firstAc.map((item) => item.problemId)
if (!problemIds.length) return aiDetailSchema.parse({ if (!problemIds.length) return {
user: user.username, className: user.className, start, end, solvedCount: 0, attempts: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0, user: user.username, className: user.className, start, end, solvedCount: 0, attempts: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0,
activity, errors, rankScope: "global", activity, errors, rankScope: "global",
}) } satisfies AiDetail
const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all([ const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all([
buildSolved(user, start, end, firstAc), buildSolved(user, start, end, firstAc),
db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags) db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags)
@@ -244,13 +245,13 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
avgScore: rounded(scores.length ? scores.reduce((sum, value) => sum + value, 0) / scores.length : 0, 0), avgScore: rounded(scores.length ? scores.reduce((sum, value) => sum + value, 0) / scores.length : 0, 0),
} }
}).sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime)) }).sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime))
return aiDetailSchema.parse({ return {
user: user.username, className: user.className, start, end, flowcharts, user: user.username, className: user.className, start, end, flowcharts,
solvedCount: solved.length, attempts: solved.map((item) => item.attempts), solvedCount: solved.length, attempts: solved.map((item) => item.attempts),
grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty, grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty,
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size, contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
activity, errors, rankScope: scopeIds ? "class" : "global", activity, errors, rankScope: scopeIds ? "class" : "global",
}) } satisfies AiDetail
} }
aiRoutes.get("/ai/detail", requireAuth, async (c) => { aiRoutes.get("/ai/detail", requireAuth, async (c) => {
@@ -357,7 +358,7 @@ async function buildDuration(user: AuthUser, endText: string, duration: string)
const inRange = rows.filter((row) => row.time >= from && row.time <= to) const inRange = rows.filter((row) => row.time >= from && row.time <= to)
const acceptedRows = inRange.filter((row) => accepted.includes(row.result)) const acceptedRows = inRange.filter((row) => accepted.includes(row.result))
const solved = [...new Set(acceptedRows.map((row) => row.problemId))] const solved = [...new Set(acceptedRows.map((row) => row.problemId))]
return durationDataSchema.parse({ return {
unit: config.unit, unit: config.unit,
index: config.count - 1 - index, index: config.count - 1 - index,
start: bucket.start.toISOString(), start: bucket.start.toISOString(),
@@ -366,7 +367,7 @@ async function buildDuration(user: AuthUser, endText: string, duration: string)
problemCount: solved.length, problemCount: solved.length,
acceptedCount: acceptedRows.length, acceptedCount: acceptedRows.length,
submissionCount: inRange.length, submissionCount: inRange.length,
}) } satisfies DurationData
}) })
} }
@@ -407,7 +408,7 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
const day = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + offset) const day = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + offset)
value += counts.get(dateKey(day)) ?? 0 value += counts.get(dateKey(day)) ?? 0
} }
return heatmapItemSchema.parse({ timestamp: monday.getTime(), value }) return { timestamp: monday.getTime(), value } satisfies HeatmapItem
})) }))
}) })
@@ -442,7 +443,7 @@ aiRoutes.get("/ai/login-summary", requireAuth, async (c) => {
analysisError = error instanceof Error ? error.message : String(error) analysisError = error instanceof Error ? error.message : String(error)
} }
} }
return success(c, loginSummarySchema.parse({ summary, analysis, analysisError })) return success(c, { summary, analysis, analysisError } satisfies LoginSummary)
}) })
aiRoutes.get("/ai/pinned", requireAuth, async (c) => { aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
@@ -450,10 +451,10 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
.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.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true))).limit(1) .where(and(eq(schema.aiAnalysis.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true))).limit(1)
if (!row) return success(c, null) if (!row) return success(c, null)
return success(c, aiAnalysisRecordSchema.parse({ return success(c, {
id: row.analysis.id, provider: row.analysis.provider, model: row.analysis.model, data: objectValue(row.analysis.data), id: row.analysis.id, provider: row.analysis.provider, model: row.analysis.model, data: objectValue(row.analysis.data),
analysis: row.analysis.analysis, createTime: row.analysis.createTime, isPinned: row.analysis.isPinned, username: row.username, analysis: row.analysis.analysis, createTime: row.analysis.createTime, isPinned: row.analysis.isPinned, username: row.username,
})) } satisfies AiAnalysisRecord)
}) })
aiRoutes.post("/ai/analysis", requireAuth, async (c) => { aiRoutes.post("/ai/analysis", requireAuth, async (c) => {
+17 -20
View File
@@ -1,10 +1,10 @@
import { import {
STUDENT_ROLES,
classComparisonRequestSchema, classComparisonRequestSchema,
classComparisonResponseSchema, STUDENT_ROLES,
classComparisonSchema, type ClassComparison,
classRankItemSchema, type ClassComparisonResponse,
classUserRankSchema, type ClassRankItem,
type ClassUserRank,
} from "@oj2/contract" } from "@oj2/contract"
import { and, eq, gte, inArray, like, lte, sql } from "drizzle-orm" import { and, eq, gte, inArray, like, lte, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -94,7 +94,7 @@ classroomRoutes.get("/rankings/classes", async (c) => {
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0, acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0,
} }
}).sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission) }).sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission)
return success(c, result.map((item, index) => classRankItemSchema.parse({ ...item, rank: index + 1 }))) 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) => {
@@ -121,7 +121,7 @@ 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) => {
@@ -166,7 +166,7 @@ classroomRoutes.post("/classes/comparison", async (c) => {
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,
@@ -197,21 +197,18 @@ classroomRoutes.post("/classes/comparison", async (c) => {
} }
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((a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc)
return success(c, classComparisonResponseSchema.parse({ return success(c, { comparisons, hasTimeRange } satisfies ClassComparisonResponse)
comparisons: comparisons.map((item) => classComparisonSchema.parse(item)),
hasTimeRange,
}))
}) })
+40 -37
View File
@@ -1,20 +1,22 @@
import { import {
announcementListItemSchema,
announcementListSchema,
announcementSchema,
createMessageRequestSchema, createMessageRequestSchema,
exerciseSchema,
messageListSchema,
messageSchema,
reactionKeySchema,
reactionStateSchema,
setReactionRequestSchema,
embeddedSubmissionSchema, embeddedSubmissionSchema,
exerciseAttemptRequestSchema, exerciseAttemptRequestSchema,
reactionKeySchema,
setReactionRequestSchema,
tutorialProgressPingSchema, tutorialProgressPingSchema,
tutorialProgressSchema, type Announcement,
tutorialSchema, type AnnouncementList,
tutorialSummarySchema, 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, sql } from "drizzle-orm" import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -38,8 +40,8 @@ contentRoutes.get("/announcements", async (c) => {
.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(({ announcement, user, realName }) => ({
id: announcement.id, id: announcement.id,
title: announcement.title, title: announcement.title,
tag: announcement.tag, tag: announcement.tag,
@@ -47,9 +49,9 @@ contentRoutes.get("/announcements", async (c) => {
createdBy: sampleUser(user, realName), createdBy: sampleUser(user, realName),
createTime: announcement.createTime, createTime: announcement.createTime,
lastUpdateTime: announcement.lastUpdateTime, 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) => {
@@ -59,7 +61,7 @@ contentRoutes.get("/announcements/:id", async (c) => {
.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(and(eq(schema.announcement.id, id), eq(schema.announcement.visible, true))).limit(1)
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist") if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
return success(c, announcementSchema.parse({ 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,
@@ -68,7 +70,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) => {
@@ -84,13 +86,13 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.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), .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(({ message, sender, realName, submission, displayId }) => ({
id: message.id, id: message.id,
sender: sampleUser(sender, realName), sender: sampleUser(sender, realName),
createTime: message.createTime, createTime: message.createTime,
message: message.message, message: message.message,
submission: embeddedSubmissionSchema.parse({ submission: {
id: submission.id, id: submission.id,
createTime: submission.createTime, createTime: submission.createTime,
userId: submission.userId, userId: submission.userId,
@@ -104,10 +106,10 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号> // 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
problem: displayId, problem: displayId,
showLink: true, showLink: true,
}), } satisfies EmbeddedSubmission,
})), } satisfies Message)),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
})) } satisfies MessageList)
}) })
/** /**
@@ -140,15 +142,16 @@ 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.select({ type: schema.reaction.type }).from(schema.reaction)
.where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1) .where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1)
if (!mine) return reactionStateSchema.parse({ mine: null, counts: null }) if (!mine) return { mine: null, counts: null } satisfies ReactionState
const rows = await db.select({ type: schema.reaction.type, value: count() }).from(schema.reaction) const rows = await db.select({ type: schema.reaction.type, value: count() }).from(schema.reaction)
.where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type) .where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type)
const counts = Object.fromEntries(reactionKeySchema.options.map((key) => [key, 0])) // fromEntries 推不出这个键集,但 options 就是 ReactionKey 的全集,断言是成立的。
for (const row of rows) { // row.type 不必再 safeParsereaction.type 列上挂着 $type<ReactionKey>()
const key = reactionKeySchema.safeParse(row.type) const counts = Object.fromEntries(
if (key.success) counts[key.data] = row.value reactionKeySchema.options.map((key) => [key, 0]),
} ) as ReactionCounts
return reactionStateSchema.parse({ mine: mine.type, counts }) 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) => {
@@ -183,7 +186,7 @@ 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.select({ id: schema.tutorial.id, title: schema.tutorial.title }).from(schema.tutorial)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order)) .where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order))
return success(c, rows.map((row) => tutorialSummarySchema.parse(row))) return success(c, rows satisfies TutorialSummary[])
}) })
contentRoutes.get("/tutorials/:id", async (c) => { contentRoutes.get("/tutorials/:id", async (c) => {
@@ -193,7 +196,7 @@ contentRoutes.get("/tutorials/:id", async (c) => {
.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))).limit(1)
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, tutorialSchema.parse({ 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,
@@ -204,7 +207,7 @@ 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)
}) })
// ---------------------------------------------------------------- 自学留痕 // ---------------------------------------------------------------- 自学留痕
@@ -252,7 +255,7 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
]) ])
const exercises = new Map(exerciseRows.map((row) => [row.tutorialId, row])) const exercises = new Map(exerciseRows.map((row) => [row.tutorialId, row]))
return success(c, rows.map((row) => tutorialProgressSchema.parse({ return success(c, rows.map((row) => ({
tutorialId: row.tutorialId, tutorialId: row.tutorialId,
viewCount: row.viewCount ?? 0, viewCount: row.viewCount ?? 0,
totalSeconds: row.totalSeconds ?? 0, totalSeconds: row.totalSeconds ?? 0,
@@ -260,7 +263,7 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
lastViewedAt: row.lastViewedAt, lastViewedAt: row.lastViewedAt,
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0, exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0, exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
}))) } satisfies TutorialProgress)))
}) })
/** /**
@@ -369,5 +372,5 @@ contentRoutes.get("/tutorials/:id/exercises", async (c) => {
.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))).limit(1)
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") 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)) 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) => exerciseSchema.parse({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order }))) return success(c, rows.map((row) => ({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order } satisfies Exercise)))
}) })
+26 -26
View File
@@ -1,13 +1,13 @@
import { import {
STUDENT_ROLES,
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"
@@ -27,7 +27,7 @@ import {
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>()
@@ -47,7 +47,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,
@@ -60,7 +60,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) => {
@@ -82,13 +82,13 @@ contestRoutes.get("/contests", async (c) => {
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) => serializeContest(
row, row,
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null), byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
)), )),
total: totalRow[0]?.value ?? 0, total: totalRow[0]?.value ?? 0,
})) } satisfies ContestList)
}) })
// optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」—— // optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」——
@@ -120,7 +120,7 @@ contestRoutes.get("/contests/:id/access", requireAuth, async (c) => {
const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 })) const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 }))
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist") 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)
}) })
/** /**
@@ -165,7 +165,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("
const tags = await contestProblemTags(rows.map((row) => row.problem.id)) const tags = await contestProblemTags(rows.map((row) => row.problem.id))
const allowed = contestDetailsAllowed(c.get("user"), contest) const allowed = contestDetailsAllowed(c.get("user"), contest)
const statuses = await contestProblemStatuses(c.get("user")?.id) const statuses = await contestProblemStatuses(c.get("user")?.id)
return success(c, rows.map(({ problem, user, realName }) => problemListItemSchema.parse({ return success(c, rows.map(({ problem, user, realName }) => ({
id: problem.id, id: problem.id,
_id: problem.displayId, _id: problem.displayId,
title: problem.title, title: problem.title,
@@ -179,7 +179,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("
showFlowchart: problem.showFlowchart, showFlowchart: problem.showFlowchart,
hasAstRules: problem.astRules !== null, hasAstRules: problem.astRules !== null,
myStatus: myStatusOf(statuses, problem.id), myStatus: myStatusOf(statuses, problem.id),
}))) } satisfies ProblemListItem)))
}) })
contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => { contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => {
@@ -192,7 +192,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
const tags = await contestProblemTags([row.problem.id]) const tags = await contestProblemTags([row.problem.id])
const allowed = contestDetailsAllowed(c.get("user"), contest) const allowed = contestDetailsAllowed(c.get("user"), contest)
const statuses = await contestProblemStatuses(c.get("user")?.id) const statuses = await contestProblemStatuses(c.get("user")?.id)
return success(c, problemDetailSchema.parse({ return success(c, {
id: row.problem.id, id: row.problem.id,
_id: row.problem.displayId, _id: row.problem.displayId,
title: row.problem.title, title: row.problem.title,
@@ -201,7 +201,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
outputDescription: row.problem.outputDescription, outputDescription: row.problem.outputDescription,
samples: Array.isArray(row.problem.samples) ? row.problem.samples : [], samples: Array.isArray(row.problem.samples) ? row.problem.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,
@@ -224,11 +224,11 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode, mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
flowchartData: row.problem.allowFlowchart ? null : objectValue(row.problem.flowchartData), flowchartData: row.problem.allowFlowchart ? null : objectValue(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint, flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null, sqlConfig: row.problem.sqlConfig,
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null, sqlDisplay: row.problem.sqlDisplay,
// 代码要求:只给渲染好的文案,规则原文不下发给学生 // 代码要求:只给渲染好的文案,规则原文不下发给学生
astRequirements: astRequirements(row.problem.astRules), astRequirements: astRequirements(row.problem.astRules),
})) } satisfies ProblemDetail)
}) })
contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => { contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => {
@@ -247,8 +247,8 @@ contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("rank
.orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime), asc(schema.acmContestRank.id)).limit(limit).offset(offset), .orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime), asc(schema.acmContestRank.id)).limit(limit).offset(offset),
]) ])
const admin = isContestAdmin(c.get("user"), contest) const admin = isContestAdmin(c.get("user"), contest)
return success(c, contestRankSchema.parse({ return success(c, {
results: rows.map(({ rank, user, realName }) => contestRankItemSchema.parse({ results: rows.map(({ rank, user, realName }) => ({
id: rank.id, id: rank.id,
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84 // 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)` // `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
@@ -256,9 +256,9 @@ contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("rank
submissionNumber: rank.submissionNumber, submissionNumber: rank.submissionNumber,
acceptedNumber: rank.acceptedNumber, acceptedNumber: rank.acceptedNumber,
totalTime: rank.totalTime, totalTime: rank.totalTime,
submissionInfo: objectValue(rank.submissionInfo), submissionInfo: rank.submissionInfo,
contestId: rank.contestId, contestId: rank.contestId,
})), } satisfies ContestRankItem)),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
})) } satisfies ContestRank)
}) })
+21 -21
View File
@@ -2,13 +2,13 @@ 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, ilike, isNull, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -45,7 +45,7 @@ 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,7 +62,7 @@ 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) => {
@@ -106,7 +106,7 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, id)) await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, id))
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable") return 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)
}) })
flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
@@ -121,7 +121,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门, // submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。 // 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) { if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
return success(c, flowchartListSchema.parse({ results: [], total: 0 })) return success(c, { results: [], total: 0 } satisfies FlowchartList)
} }
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`) 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)) if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) filters.push(eq(schema.flowchartSubmission.userId, user.id))
@@ -136,8 +136,8 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)).where(where) .innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)).where(where)
.orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset), .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(({ flowchart, username, problem }) => ({
id: flowchart.id, id: flowchart.id,
username, username,
problem: problem.displayId, problem: problem.displayId,
@@ -151,9 +151,9 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
processingTime: flowchart.processingTime, processingTime: flowchart.processingTime,
evaluationTime: flowchart.evaluationTime, evaluationTime: flowchart.evaluationTime,
showLink: canView(user, flowchart, problem), 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
@@ -242,7 +242,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
realName: stripClassPrefix(row.username, row.className), realName: stripClassPrefix(row.username, row.className),
})), })),
} }
if (rows.length === 0) return success(c, flowchartStatisticsSchema.parse(empty)) if (rows.length === 0) return success(c, empty satisfies FlowchartStatistics)
const gradeDistribution: Record<string, number> = {} const gradeDistribution: Record<string, number> = {}
const criteriaTotals = new Map<string, { sum: number; count: number; max: number }>() const criteriaTotals = new Map<string, { sum: number; count: number; max: number }>()
@@ -289,7 +289,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
criteriaAverages[key] = { avg: rounded(bucket.sum / bucket.count, 1), max: bucket.max } criteriaAverages[key] = { avg: rounded(bucket.sum / bucket.count, 1), max: bucket.max }
} }
return success(c, flowchartStatisticsSchema.parse({ return success(c, {
totalCount: rows.length, totalCount: rows.length,
// 分母是有分数的条数,不是总条数 —— 对齐 Django 的 Avg(),它跳过 NULL // 分母是有分数的条数,不是总条数 —— 对齐 Django 的 Avg(),它跳过 NULL
avgScore: scoreCount ? rounded(scoreSum / scoreCount, 1) : 0, avgScore: scoreCount ? rounded(scoreSum / scoreCount, 1) : 0,
@@ -304,7 +304,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
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) => {
@@ -351,7 +351,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id)) await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable") 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("/problems/:id/flowchart/current", requireAuth, async (c) => {
@@ -359,7 +359,7 @@ flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) =>
const rows = await db.select({ score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade }) 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))) .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)) .orderBy(desc(schema.flowchartSubmission.createTime))
return success(c, flowchartCurrentSchema.parse({ count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" })) return success(c, { count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" } satisfies FlowchartCurrent)
}) })
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => { flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => {
@@ -371,5 +371,5 @@ flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) =>
.orderBy(asc(schema.flowchartSubmission.createTime)) .orderBy(asc(schema.flowchartSubmission.createTime))
const selected = page === 0 ? rows.at(-1) : rows[page - 1] 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") if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range")
return success(c, flowchartDetailSchema.parse({ submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length })) return success(c, { submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length } satisfies FlowchartDetail)
}) })
+14 -14
View File
@@ -1,9 +1,4 @@
import { import { ADMIN_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
ADMIN_ROLES,
TEACHER_ROLES,
sampleUserSchema,
type SampleUser,
} from "@oj2/contract"
import { and, count, eq, notInArray } from "drizzle-orm" import { and, count, eq, notInArray } from "drizzle-orm"
@@ -26,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
} }
/** /**
@@ -49,18 +44,23 @@ 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,
+15 -26
View File
@@ -1,11 +1,4 @@
import { import type { ProblemAuthor, ProblemDetail, ProblemList, ProblemListItem, Tag, YearlyAc } from "@oj2/contract"
problemAuthorSchema,
problemDetailSchema,
problemListItemSchema,
problemListSchema,
tagSchema,
yearlyAcSchema,
} from "@oj2/contract"
import { import {
and, and,
asc, asc,
@@ -28,7 +21,7 @@ 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 { countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers" import { asFilterValue, countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers"
export const problemRoutes = new Hono<AppEnv>() export const problemRoutes = new Hono<AppEnv>()
@@ -38,10 +31,6 @@ function objectValue(value: unknown): 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))) {
@@ -76,7 +65,7 @@ function listItem(
statuses: Record<string, unknown>, 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,
@@ -90,7 +79,7 @@ function listItem(
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) => {
@@ -103,7 +92,7 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
const tag = c.req.query("tag")?.trim() const tag = c.req.query("tag")?.trim()
if (author) filters.push(eq(schema.user.username, author)) if (author) filters.push(eq(schema.user.username, author))
if (keyword) filters.push(or(ilike(schema.problem.title, `%${keyword}%`), ilike(schema.problem.displayId, `%${keyword}%`))!) if (keyword) filters.push(or(ilike(schema.problem.title, `%${keyword}%`), ilike(schema.problem.displayId, `%${keyword}%`))!)
if (difficulty) filters.push(eq(schema.problem.difficulty, difficulty)) if (difficulty) filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty)))
if (tag) { if (tag) {
filters.push(inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags) 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)) .innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
@@ -140,10 +129,10 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
getProblemTags(rows.map((row) => row.problem.id)), getProblemTags(rows.map((row) => row.problem.id)),
getProblemStatuses(c.get("user")?.id), getProblemStatuses(c.get("user")?.id),
]) ])
return success(c, problemListSchema.parse({ return success(c, {
results: rows.map((row) => listItem(row, tags, statuses)), results: rows.map((row) => listItem(row, tags, statuses)),
total: totalRow?.value ?? 0, total: totalRow?.value ?? 0,
})) } satisfies ProblemList)
}) })
problemRoutes.get("/problem-tags", async (c) => { problemRoutes.get("/problem-tags", async (c) => {
@@ -161,7 +150,7 @@ problemRoutes.get("/problem-tags", async (c) => {
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined) .where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
.groupBy(schema.problemTag.id, schema.problemTag.name).having(sql`count(${schema.problemTags.problemId}) > 0`) .groupBy(schema.problemTag.id, schema.problemTag.name).having(sql`count(${schema.problemTags.problemId}) > 0`)
.orderBy(asc(schema.problemTag.name)) .orderBy(asc(schema.problemTag.name))
return success(c, rows.map((row) => tagSchema.parse(row))) return success(c, rows satisfies Tag[])
}) })
problemRoutes.get("/problems/random", async (c) => { problemRoutes.get("/problems/random", async (c) => {
@@ -177,7 +166,7 @@ problemRoutes.get("/problem-authors", async (c) => {
.from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
.where(and(isNull(schema.problem.contestId), eq(schema.user.isDisabled, false), showAll ? undefined : eq(schema.problem.visible, true))) .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))) .groupBy(schema.user.username).orderBy(desc(count(schema.problem.id)))
return success(c, rows.map((row) => problemAuthorSchema.parse(row))) return success(c, rows satisfies ProblemAuthor[])
}) })
problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => { problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => {
@@ -232,7 +221,7 @@ problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => {
accepted: sql<number>`count(*) filter (where ${schema.submission.result} in (0, 10))::int`, 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]))) }).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) .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 }))) 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) => {
@@ -283,7 +272,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
} }
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,
@@ -292,7 +281,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
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,
@@ -316,11 +305,11 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
? null ? null
: objectValue(row.problem.flowchartData), : objectValue(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint, flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null, sqlConfig: row.problem.sqlConfig,
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null, sqlDisplay: row.problem.sqlDisplay,
// 代码要求:只给渲染好的文案,规则原文不下发给学生 // 代码要求:只给渲染好的文案,规则原文不下发给学生
astRequirements: astRequirements(row.problem.astRules), astRequirements: astRequirements(row.problem.astRules),
}) } satisfies ProblemDetail
return success(c, data) return success(c, data)
}) })
+25 -25
View File
@@ -1,13 +1,13 @@
import { import {
problemSetBadgeSchema,
problemSetListSchema,
problemSetProblemSchema,
problemSetProgressListSchema,
problemSetProgressSchema,
problemSetSchema,
updateProblemSetProgressRequestSchema,
joinProblemSetRequestSchema, joinProblemSetRequestSchema,
userBadgeSchema, updateProblemSetProgressRequestSchema,
type ProblemSet,
type ProblemSetBadge,
type ProblemSetList,
type ProblemSetProblem,
type ProblemSetProgress,
type ProblemSetProgressList,
type UserBadge,
} from "@oj2/contract" } from "@oj2/contract"
import { import {
and, and,
@@ -32,7 +32,7 @@ import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status" import { JudgeStatus } from "../judge/status"
import { updateAchievementsForProblemSet } from "../services/achievements" import { updateAchievementsForProblemSet } from "../services/achievements"
import { computeProgress, eligibleForBadge } from "../services/problemset" import { computeProgress, eligibleForBadge } from "../services/problemset"
import { objectValue, queryInteger, sampleUser } from "./helpers" import { asFilterValue, objectValue, queryInteger, sampleUser } from "./helpers"
export const problemsetRoutes = new Hono<AppEnv>() export const problemsetRoutes = new Hono<AppEnv>()
@@ -65,7 +65,7 @@ async function problemSetCreators(ids: number[]) {
} }
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) { function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) {
return problemSetBadgeSchema.parse({ return {
id: badge.id, id: badge.id,
problemsetId: badge.problemsetId, problemsetId: badge.problemsetId,
name: badge.name, name: badge.name,
@@ -74,7 +74,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
} }
/** /**
@@ -113,7 +113,7 @@ async function serializeProblemSets(
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,
@@ -128,7 +128,7 @@ async function serializeProblemSets(
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
}) })
} }
@@ -140,17 +140,17 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => {
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) filters.push(or(ilike(schema.problemset.title, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`))!)
if (difficulty) filters.push(eq(schema.problemset.difficulty, difficulty)) if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (status) filters.push(eq(schema.problemset.status, status)) 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) => {
@@ -189,7 +189,7 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
.where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, c.get("user")!.id))).limit(1) .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, problemId, displayId, title, difficulty }) => problemSetProblemSchema.parse({ return success(c, rows.map(({ link, problemId, displayId, title, difficulty }) => ({
id: link.id, id: link.id,
problemsetId: link.problemsetId, problemsetId: link.problemsetId,
problem: { id: problemId, _id: displayId, title, difficulty }, problem: { id: problemId, _id: displayId, title, difficulty },
@@ -198,7 +198,7 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
score: link.score, score: link.score,
hint: link.hint, hint: link.hint,
isCompleted: String(problemId) in completed, isCompleted: String(problemId) in completed,
}))) } satisfies ProblemSetProblem)))
}) })
async function recomputeProgress( async function recomputeProgress(
@@ -344,13 +344,13 @@ problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => {
.from(schema.userBadge).innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id)) .from(schema.userBadge).innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id))
.innerJoin(schema.problemset, eq(schema.problemsetBadge.problemsetId, schema.problemset.id)) .innerJoin(schema.problemset, eq(schema.problemsetBadge.problemsetId, schema.problemset.id))
.where(eq(schema.userBadge.userId, target.id)).orderBy(desc(schema.userBadge.earnedTime)) .where(eq(schema.userBadge.userId, target.id)).orderBy(desc(schema.userBadge.earnedTime))
return success(c, rows.map(({ userBadge, badge, problemSet }) => userBadgeSchema.parse({ return success(c, rows.map(({ userBadge, badge, problemSet }) => ({
id: userBadge.id, id: userBadge.id,
userId: userBadge.userId, userId: userBadge.userId,
badge: badgeData(badge), badge: badgeData(badge),
earnedTime: userBadge.earnedTime, earnedTime: userBadge.earnedTime,
problemset: { id: problemSet.id, title: problemSet.title }, 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) => {
@@ -399,7 +399,7 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)), .orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)),
]) ])
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem])) const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem]))
const results = rows.map(({ progress, user: progressUser, realName }) => problemSetProgressSchema.parse({ const results = rows.map(({ progress, user: progressUser, realName }) => ({
id: progress.id, id: progress.id,
problemsetId: progress.problemsetId, problemsetId: progress.problemsetId,
user: sampleUser(progressUser, realName), user: sampleUser(progressUser, realName),
@@ -411,12 +411,12 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c
totalProblemsCount: progress.totalProblemsCount, totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore, totalScore: progress.totalScore,
completedProblems: Object.keys(objectValue(progress.progressDetail)).flatMap((key) => problemMap.get(key) ?? []), completedProblems: Object.keys(objectValue(progress.progressDetail)).flatMap((key) => problemMap.get(key) ?? []),
})) } satisfies ProblemSetProgress))
const stats = statsRows[0] const stats = statsRows[0]
return success(c, problemSetProgressListSchema.parse({ return success(c, {
results, results,
total: stats?.total ?? 0, total: stats?.total ?? 0,
statistics: { total: stats?.total ?? 0, completed: stats?.completed ?? 0, avgProgress: Number(stats?.avgProgress ?? 0) }, statistics: { total: stats?.total ?? 0, completed: stats?.completed ?? 0, avgProgress: Number(stats?.avgProgress ?? 0) },
problems: problemRows, problems: problemRows,
})) } satisfies ProblemSetProgressList)
}) })
+6 -11
View File
@@ -1,4 +1,4 @@
import { onlineCountSchema, 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"
@@ -14,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,
@@ -23,7 +23,7 @@ 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)
}) })
/** /**
@@ -31,7 +31,7 @@ siteRoutes.get("/site", async (c) => {
* 线 /rankings/users * 线 /rankings/users
*/ */
siteRoutes.get("/site/online", async (c) => { siteRoutes.get("/site/online", async (c) => {
return success(c, onlineCountSchema.parse({ count: await onlineCount() })) return success(c, { count: await onlineCount() } satisfies OnlineCount)
}) })
// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里) // 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里)
@@ -47,11 +47,6 @@ 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
@@ -78,10 +73,10 @@ 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 = fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]!
return success(c, quoteSchema.parse(item)) return success(c, item satisfies Quote)
} }
}) })
+34 -44
View File
@@ -2,14 +2,14 @@ import { randomBytes } from "node:crypto"
import { import {
createSubmissionRequestSchema, createSubmissionRequestSchema,
createSubmissionResponseSchema,
formatCodeRequestSchema, formatCodeRequestSchema,
formatCodeResponseSchema, type CreateSubmissionResponse,
submissionDetailSchema, type FormatCodeResponse,
submissionListItemSchema, type SubmissionDetail,
submissionListSchema, type SubmissionList,
submissionStatisticsItemsSchema, type SubmissionListItem,
submissionStatisticsSchema, type SubmissionStatistics,
type SubmissionStatisticsItems,
} from "@oj2/contract" } from "@oj2/contract"
import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm" import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -23,7 +23,7 @@ import {
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, UNJUDGED_RESULTS } from "../judge/status" import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
import { judgeQueue } from "../queue" import { judgeQueue } from "../queue"
import { import {
canAccessContest, canAccessContest,
@@ -36,22 +36,10 @@ import {
import { CodeFormatError, formatCode } from "../services/format-code" import { CodeFormatError, formatCode } from "../services/format-code"
import { getBooleanOption } from "../services/options" import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
import { import { asFilterValue, isAdminRole, queryInteger, rounded, stripClassPrefix, todayStart } from "./helpers"
isAdminRole,
queryInteger,
rounded,
stripClassPrefix,
todayStart,
} from "./helpers"
export const submissionRoutes = new Hono<ContestEnv>() export const submissionRoutes = new Hono<ContestEnv>()
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: []
}
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>)
@@ -100,7 +88,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
.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")
if (!stringArray(problem.languages).includes(parsed.data.language)) { if (!problem.languages.includes(parsed.data.language)) {
return failure( return failure(
c, c,
400, 400,
@@ -162,7 +150,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
return success( return success(
c, c,
createSubmissionResponseSchema.parse({ submissionId }), { submissionId } satisfies CreateSubmissionResponse,
201, 201,
) )
}) })
@@ -258,9 +246,11 @@ const FAILURE_MESSAGE_LIMIT = 400
* statistic_info * statistic_info
*/ */
async function lastFailureByUser(where: SQL | undefined, userIds: number[]) { async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
// result 手写成 JudgeStatusValue:这条裸 SQL 读的就是 submission.result 那一列,
// 口径要和列上的 $type 一致
const byUser = new Map< const byUser = new Map<
number, number,
{ id: string; problem: string; result: number; error: string | null } { id: string; problem: string; result: JudgeStatusValue; error: string | null }
>() >()
if (!userIds.length) return byUser if (!userIds.length) return byUser
@@ -269,7 +259,7 @@ async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
user_id: number user_id: number
id: string id: string
problem: string problem: string
result: number result: JudgeStatusValue
error: string | null error: string | null
}>(sql` }>(sql`
select user_id, id, problem, result, error from ( select user_id, id, problem, result, error from (
@@ -606,7 +596,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
return success( return success(
c, c,
submissionStatisticsSchema.parse({ {
submissionCount, submissionCount,
acceptedCount, acceptedCount,
judgingCount, judgingCount,
@@ -615,7 +605,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
data, data,
dataUnaccepted, dataUnaccepted,
dataAttempted, dataAttempted,
}), } satisfies SubmissionStatistics,
) )
}) })
@@ -661,10 +651,10 @@ submissionRoutes.get("/submissions/statistics/items", requireTeacher, async (c)
const truncated = rows.length > STATISTICS_ITEMS_LIMIT const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success( return success(
c, c,
submissionStatisticsItemsSchema.parse({ {
items: rows.slice(0, STATISTICS_ITEMS_LIMIT), items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
truncated, truncated,
}), } satisfies SubmissionStatisticsItems,
) )
}) })
@@ -696,7 +686,7 @@ submissionRoutes.post("/code/format", requireAuth, async (c) => {
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid format payload") if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid format payload")
try { try {
const code = await formatCode(parsed.data.code, parsed.data.language) const code = await formatCode(parsed.data.code, parsed.data.language)
return success(c, formatCodeResponseSchema.parse({ code })) return success(c, { code } satisfies FormatCodeResponse)
} catch (error) { } catch (error) {
if (error instanceof CodeFormatError) { if (error instanceof CodeFormatError) {
return failure(c, error.kind === "syntax" ? 400 : 500, error.kind === "syntax" ? "format-error" : "format-tool-error", error.message) return failure(c, error.kind === "syntax" ? 400 : 500, error.kind === "syntax" ? "format-error" : "format-tool-error", error.message)
@@ -831,7 +821,7 @@ async function submissionDetail(id: string, user: AuthUser) {
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与 // submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。 // SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
const full = isAdminRole(user) const full = isAdminRole(user)
return submissionDetailSchema.parse({ return {
id: row.submission.id, id: row.submission.id,
createTime: row.submission.createTime, createTime: row.submission.createTime,
userId: row.submission.userId, userId: row.submission.userId,
@@ -847,7 +837,7 @@ async function submissionDetail(id: string, user: AuthUser) {
// problem 表本来就 join 了,不额外查库 // problem 表本来就 join 了,不额外查库
problemDisplayId: row.problem.displayId, problemDisplayId: row.problem.displayId,
showLink: true, showLink: true,
}) } satisfies SubmissionDetail
} }
/** /**
@@ -926,7 +916,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
// 「非管理员即受限」,不能写成「是普通用户才受限」—— // 「非管理员即受限」,不能写成「是普通用户才受限」——
// 后者对匿名用户(user 为 null)会短路,匿名反而能看到全部提交,权限大于登录学生。 // 后者对匿名用户(user 为 null)会短路,匿名反而能看到全部提交,权限大于登录学生。
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) { if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
return success(c, submissionListSchema.parse({ results: [], total: 0 })) return success(c, { results: [], total: 0 } satisfies SubmissionList)
} }
const filters = [isNull(schema.submission.contestId)] const filters = [isNull(schema.submission.contestId)]
const displayId = c.req.query("problemId")?.trim() const displayId = c.req.query("problemId")?.trim()
@@ -936,8 +926,8 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`) if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id)) if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
else if (username) filters.push(usernameFilter(username)) else if (username) filters.push(usernameFilter(username))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, Number(result))) if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, asFilterValue(Number(result))))
if (language) filters.push(eq(schema.submission.language, language)) if (language) filters.push(eq(schema.submission.language, asFilterValue(language)))
if (c.req.query("today") === "1") filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`) if (c.req.query("today") === "1") filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`)
const where = and(...filters) const where = and(...filters)
// count 不 join problemproblem 只有按题号筛选时才出现在 where 里,无条件 join 会让 // count 不 join problemproblem 只有按题号筛选时才出现在 where 里,无条件 join 会让
@@ -960,8 +950,8 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
// 来源题单的标题。一页里不同题单最多几个,按主键查一次就够 // 来源题单的标题。一页里不同题单最多几个,按主键查一次就够
problemsetTitleMap(rows.map((row) => row.submission.problemsetId)), problemsetTitleMap(rows.map((row) => row.submission.problemsetId)),
]) ])
return success(c, submissionListSchema.parse({ return success(c, {
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({ results: rows.map(({ submission, problem }) => ({
id: submission.id, id: submission.id,
problem: problem.displayId, problem: problem.displayId,
problemTitle: problem.title, problemTitle: problem.title,
@@ -976,9 +966,9 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
problemSet: submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId) problemSet: submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId)
? { id: submission.problemsetId, title: problemsetTitles.get(submission.problemsetId)! } ? { id: submission.problemsetId, title: problemsetTitles.get(submission.problemsetId)! }
: null, : null,
})), } satisfies SubmissionListItem)),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
})) } satisfies SubmissionList)
}) })
submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireContestAccess("submissions", "contestId"), async (c) => { submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireContestAccess("submissions", "contestId"), async (c) => {
@@ -993,7 +983,7 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`) if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id)) if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
else if (username) filters.push(usernameFilter(username)) else if (username) filters.push(usernameFilter(username))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, Number(result))) if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, asFilterValue(Number(result))))
if (contestStatus(contest) !== "1") filters.push(sql`${schema.submission.createTime} >= ${contest.startTime}`) if (contestStatus(contest) !== "1") filters.push(sql`${schema.submission.createTime} >= ${contest.startTime}`)
const where = and(...filters) const where = and(...filters)
// count 不 join problemproblem 只有按题号筛选时才出现在 where 里,无条件 join 会让 // count 不 join problemproblem 只有按题号筛选时才出现在 where 里,无条件 join 会让
@@ -1013,8 +1003,8 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
// `isNull(problem.contestId)`admin/problemset.ts:232)——而这条列表只出比赛提交, // `isNull(problem.contestId)`admin/problemset.ts:232)——而这条列表只出比赛提交,
// 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。 // 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。
// 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。 // 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。
return success(c, submissionListSchema.parse({ return success(c, {
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({ results: rows.map(({ submission, problem }) => ({
id: submission.id, id: submission.id,
problem: problem.displayId, problem: problem.displayId,
problemTitle: problem.title, problemTitle: problem.title,
@@ -1028,9 +1018,9 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
// 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了 // 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段 // isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
problemSet: null, problemSet: null,
})), } satisfies SubmissionListItem)),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
})) } satisfies SubmissionList)
}) })
submissionRoutes.get("/submissions/:id", requireAuth, async (c) => { submissionRoutes.get("/submissions/:id", requireAuth, async (c) => {
+32 -5
View File
@@ -1,11 +1,14 @@
import type { ExerciseType } from "@oj2/contract" import { exerciseDataByType, type ExerciseType } from "@oj2/contract"
/** /**
* `data` * `data` **** `data`
* `z.record(z.string(), z.unknown())` zod
* **** schema
* astRulesError
* *
* `data` `z.record(z.string(), z.unknown())` * `exerciseDataByType`
* zod **** schema *
* astRulesError * ** `question`**
* *
* ExerciseManager buildData() * ExerciseManager buildData()
* fill mcq `{{空位}}` * fill mcq `{{空位}}`
@@ -17,6 +20,16 @@ export function exerciseDataError(
type: ExerciseType, type: ExerciseType,
data: Record<string, unknown>, data: Record<string, unknown>,
): string | null { ): string | null {
const shape = exerciseDataByType[type]
if (!shape) return `未知的题型 ${type}`
const parsed = shape.safeParse(data)
if (!parsed.success) {
// 老师看到的是「题干必须是文字」这种话,不是 zod 的英文 issue
const issue = parsed.error.issues[0]!
// 只取第一段:数组项的 path 是 ["options", 0],老师要看的是「选项」
const field = String(issue.path[0] ?? "内容")
return `${FIELD_LABELS[field] ?? field}的格式不对(${issue.message}`
}
switch (type) { switch (type) {
case "mcq": { case "mcq": {
const options = strings(data.options) const options = strings(data.options)
@@ -67,6 +80,20 @@ export function exerciseDataError(
} }
} }
/** zod 报的是键名,老师看的得是人话 */
const FIELD_LABELS: Record<string, string> = {
question: "题干",
options: "选项",
answer: "答案",
lines: "代码行",
code: "代码",
left: "左列",
right: "右列",
buckets: "分组",
items: "项目",
explanation: "解析",
}
function strings(value: unknown): string[] { function strings(value: unknown): string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string") return Array.isArray(value) && value.every((item) => typeof item === "string")
? (value as string[]) ? (value as string[])
+5 -5
View File
@@ -1,4 +1,4 @@
import { sessionUserSchema, userProfileSchema } from "@oj2/contract" import type { SessionUser, UserProfile } from "@oj2/contract"
import { and, eq } from "drizzle-orm" import { and, eq } from "drizzle-orm"
import { db, schema } from "../db" import { db, schema } from "../db"
@@ -12,9 +12,9 @@ export async function getUserProfileById(userId: number, showRealName: boolean)
.limit(1) .limit(1)
if (!row) return null if (!row) return null
return userProfileSchema.parse({ return {
id: row.profile.id, id: row.profile.id,
user: sessionUserSchema.parse({ user: {
id: row.user.id, id: row.user.id,
username: row.user.username, username: row.user.username,
email: row.user.email, email: row.user.email,
@@ -24,12 +24,12 @@ export async function getUserProfileById(userId: number, showRealName: boolean)
lastLogin: row.user.lastLogin, lastLogin: row.user.lastLogin,
isDisabled: row.user.isDisabled, isDisabled: row.user.isDisabled,
className: row.user.className, className: row.user.className,
}), } satisfies SessionUser,
realName: showRealName ? row.profile.realName : null, realName: showRealName ? row.profile.realName : null,
acmProblemsStatus: row.profile.acmProblemsStatus, acmProblemsStatus: row.profile.acmProblemsStatus,
avatar: row.profile.avatar, avatar: row.profile.avatar,
mood: row.profile.mood, mood: row.profile.mood,
acceptedNumber: row.profile.acceptedNumber, acceptedNumber: row.profile.acceptedNumber,
submissionNumber: row.profile.submissionNumber, submissionNumber: row.profile.submissionNumber,
}) } satisfies UserProfile
} }
+5 -5
View File
@@ -1,4 +1,4 @@
import { flowchartUpdateSchema, submissionUpdateSchema } from "@oj2/contract" import { submissionUpdateSchema, type FlowchartUpdate } from "@oj2/contract"
import { and, eq } from "drizzle-orm" import { and, eq } from "drizzle-orm"
import { touchSession } from "./auth/session" import { touchSession } from "./auth/session"
@@ -345,11 +345,11 @@ async function handleMessage(
return return
} }
const replay = flowchart.status === 2 const replay = flowchart.status === 2
? { type: "flowchart_evaluation_completed", submissionId: flowchart.id, score: flowchart.score ?? undefined, grade: flowchart.grade ?? undefined } ? { type: "flowchart_evaluation_completed" as const, submissionId: flowchart.id, score: flowchart.score ?? undefined, grade: flowchart.grade ?? undefined }
: flowchart.status === 3 : flowchart.status === 3
? { type: "flowchart_evaluation_failed", submissionId: flowchart.id } ? { type: "flowchart_evaluation_failed" as const, submissionId: flowchart.id }
: { type: "flowchart_evaluation_update", submissionId: flowchart.id } : { type: "flowchart_evaluation_update" as const, submissionId: flowchart.id }
ws.send(JSON.stringify(flowchartUpdateSchema.parse(replay))) ws.send(JSON.stringify(replay satisfies FlowchartUpdate))
return return
} }
+45 -19
View File
@@ -13,15 +13,21 @@ ViteRolldown 内核)、Naive UI、Pinia、Vue Router。
## Commands ## Commands
前端一般不单独起,`OJ2/` 根目录 `bun run dev` 会把 api + worker + web 一起拉起来。
只跑前端或要验证时:
```bash ```bash
npm start # Start dev server on port 5173 bun run dev # 只起前端 dev server5173),后端得另外起
npm run build # Production build bun run type-check # 类型检查。改完 .vue / .ts 必须跑这个
npm run build:staging # Staging build bun run build # 生产构建
npm run build:test # Test build bun run fmt # Prettier
npm fmt # Format with Prettier
``` ```
No test suite is configured. Linting is via Prettier only. ⚠️ **验证只认 `bun run type-check`。** `vue-tsc --noEmit -p tsconfig.json` 会**静默
通过**——那个 tsconfig 是 `files: []` + references 的壳,真正的配置在
`tsconfig.app.json`0.2 秒跑完就是没在检查的信号);`vite build` 也不做类型检查。
不写测试(沿用项目约定),验证靠实跑。lint 只有 Prettier。
## Architecture ## Architecture
@@ -41,9 +47,12 @@ src/
### Module Pattern ### Module Pattern
Each feature module (under `oj/` or `admin/`) typically has: Each feature module (under `oj/` or `admin/`) typically has:
- `views/` — page-level Vue components - 页面组件直接放模块根下(`problem/list.vue``problem/detail.vue`),**没有 `views/` 这一层**
- `components/` — feature-specific components - `components/` — feature-specific components
- `api.ts` — API calls specific to the feature - `composables/` / `utils/` — 模块自己的组合式函数与纯函数(按需,不是每个模块都有)
API 调用不按模块分:学生端全在 `oj/api.ts`、后台全在 `admin/api.ts`
跨端的(登录、资料、标签、验证码)在 `shared/api.ts`
Shared logic lives in `shared/`: Shared logic lives in `shared/`:
- `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `screenMode` (problem split-screen layout), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room) - `store/` — Pinia stores: `user` (auth/roles), `config` (site-wide settings), `authModal` (login/signup form state), `screenMode` (problem split-screen layout), `loginSummary` (AI activity summary), `collab` (help-request queue + collab room)
@@ -83,23 +92,40 @@ through the dev server (see `vite.config.ts`).
### Contract guard (`utils/contract.ts`) ### Contract guard (`utils/contract.ts`)
`@oj2/contract` 的 zod schema 是**前后端唯一的形状来源**,`utils/types.ts` 只做 `@oj2/contract` 的 zod schema 是**前后端唯一的形状来源**,`utils/types.ts` 只做
`z.infer` 派生与少量前端专有的收窄(都写了理由)。读接口应当走守卫: `z.infer` 派生与少量前端专有的收窄(都写了理由)。
运行时闸门**只挂三处**:题目详情、提交详情、`shared/api.ts` 的用户资料 ——
原本就写了 `.parse()` 的那三条。留着它们的理由是**别抛错**,不是校验:
```ts ```ts
const endpoint = `problems/${encodeURIComponent(id)}` // 原来是 problemDetailSchema.parse(v) as Problem —— `as` 让校验白做,
return contract("GET /problems/:id", problemDetailSchema, await api.get<unknown>(endpoint)) // 而 parse 抛错会让整个题目页白屏
return contract("GET /problems/:id", problemDetailSchema, value)
``` ```
**失败策略是「记日志 + 放行原始数据」,不抛错。** 形状对不上时:控制台打一条带 失败时记一条控制台日志再**放行原始数据**,页面照常渲染。
端点和字段路径的记录、去重后记进 `window.__OJ2_CONTRACT_DRIFT__`、然后**返回原始
数据让页面继续渲染**。面向学生的生产站点,少一个字段的代价远小于白屏。
排查线上分歧就是打开控制台敲 `window.__OJ2_CONTRACT_DRIFT__`;某条路径长期为空之后, **不要把它铺到更多端点上。** 试过一次(41 个),收益是 41 次 safeParse 加一条
那条路径可以升级成硬失败(直接 `schema.parse`),在那之前不要改。 没人读的 console.error:前后端同仓、共享同一份 schema,「后端改字段前端不知道」
`tsc` 已经抓了。
改动 schema 时要记住**同一个 schema 后端也在 `parse`**(如 ### 什么该收紧,什么不该
`submissionDetailSchema.parse` 在路由里),所以收紧一个字段前先用生产数据核一遍,
否则一条不符合的历史记录会让整个列表 500。 **JSONB 原文(`submission.info` / `statistic_info` / `exercise.data`)不在读出侧
校验。** 它们的形状真相在写入侧 —— 判题机、`services/exercise.ts`。在读出侧再收
一遍的结果实测过两次:
- `info` 按采样键集收紧后,124192 条提交里 9163 条(RE、TLE、MLE 全中)对不上,
被 union 的空对象分支**静默剥成 `{}`**,管理员的测试点表格无声消失;
- `exercise.data` 按题型收紧后,后端读路径(`routes/content.ts` 硬 parse)变成
一道闸,一行脏数据能让整条练习列表 500。
**后端出参已经不 `parse` 了**(原来 136 处,全部改成 `satisfies`;撤的时候炸出两个
一直存在的线上 500,见 `../CLAUDE.md` 的「出参不 `parse`,用 `satisfies`」)。
所以现在收紧一个字段的直接后果落在 **`tsc` 编译期**,而不再是运行时 500 —— 这是好事,
但别因此就放心大胆收:契约里的形状仍然要对得上库里的存量数据,前端拿到对不上的值
一样会渲染错。收紧任何字段之前,拿根目录那份生产备份把全量数据跑一遍,
尤其要看**空值**而不只是键集合。
### Key Utilities ### Key Utilities
+165 -388
View File
@@ -1,98 +1,86 @@
import { import {
type AiAnalysisRecord,
type Contest as OjContest,
type ContestAccess,
type ContestList,
type ActivityRankItem,
type FormatCodeResponse, type FormatCodeResponse,
type Metrics,
type TutorialSummary,
type ClassComparisonResponse, type ClassComparisonResponse,
type ClassRankItem, type ClassRankItem,
type ClassUserRank, type ClassUserRank,
type UserRank,
type ProblemRank,
type CreateSubmissionResponse, type CreateSubmissionResponse,
type ProblemAuthor, type ProblemAuthor,
type ProblemListItem,
type YearlyAc,
type ProblemList,
type CreateFlowchartResponse, type CreateFlowchartResponse,
type FlowchartCurrent,
type FlowchartDetail,
type FlowchartList,
type FlowchartSubmission,
type AiDetail,
type DurationData,
type HeatmapItem,
type LoginSummary,
type SolvedList,
type ProblemSet,
type ProblemSetBadge,
type ProblemSetList,
type ProblemSetProblem,
type ProblemSetProgressList,
type UserBadge,
problemDetailSchema, problemDetailSchema,
problemListSchema,
problemListItemSchema,
submissionDetailSchema, submissionDetailSchema,
submissionListSchema, type FlowchartStatistics,
submissionStatisticsSchema, type SubmissionStatistics,
submissionStatisticsItemsSchema, type SubmissionStatisticsItems,
onlineCountSchema,
websiteConfigSchema,
contestListSchema,
contestSchema,
contestAccessSchema,
contestRankSchema,
announcementListSchema,
announcementSchema,
problemSetListSchema,
problemSetSchema,
problemSetBadgeSchema,
userBadgeSchema,
tutorialSchema,
metricsSchema,
activityRankItemSchema,
problemRankSchema,
userRankSchema,
flowchartListSchema,
flowchartDetailSchema,
flowchartCurrentSchema,
flowchartStatisticsSchema,
flowchartSubmissionSchema,
exerciseSchema,
exerciseDataByType,
problemSetProgressListSchema,
problemSetProblemSchema,
tutorialSummarySchema,
tutorialProgressSchema,
messageListSchema,
yearlyAcSchema,
aiDetailSchema,
solvedListSchema,
durationDataSchema,
heatmapItemSchema,
loginSummarySchema,
aiAnalysisRecordSchema,
} from "@oj2/contract" } from "@oj2/contract"
import api from "utils/api" import api from "utils/api"
import { contract } from "utils/contract" import { contract } from "utils/contract"
import { filterResult } from "oj/transforms" import { filterResult } from "oj/transforms"
import type { import type {
Announcement,
AnnouncementListItem,
ContestRank,
Profile, Profile,
Message,
SubmissionListItem,
Exercise, Exercise,
Problem, Problem,
ReactionKey, ReactionKey,
ReactionState, ReactionState,
Submission,
SubmissionListPayload, SubmissionListPayload,
SubmitCodePayload, SubmitCodePayload,
OnlineCount,
WebsiteConfig,
Tutorial,
TutorialProgress,
} from "utils/types" } from "utils/types"
/** /**
* zod camelsnake * zod camelsnake
* undefined * undefined
* *
* `contract()` `parse()` * `contract()` `parse()` `problemDetailSchema.parse(v) as Problem`
* `problemDetailSchema.parse(value) as Problem` `as` * `as` `parse`
* `languages` / `template` *
* `Problem` `as`
*/ */
function detailProblem(value: unknown): Problem { function detailProblem(value: unknown): Problem {
return contract("GET /problems/:id", problemDetailSchema, value) return contract("GET /problems/:id", problemDetailSchema, value)
} }
export async function getWebsiteConfig() { export function getWebsiteConfig() {
const endpoint = "site" return api.get<WebsiteConfig>("site")
return contract(
"GET /site",
websiteConfigSchema,
await api.get<unknown>(endpoint),
)
} }
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */ /** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
export async function getOnlineCount() { export function getOnlineCount() {
const endpoint = "site/online" return api.get<OnlineCount>("site/online")
return contract(
"GET /site/online",
onlineCountSchema,
await api.get<unknown>(endpoint),
)
} }
export async function getProblemList( export async function getProblemList(
@@ -100,14 +88,9 @@ export async function getProblemList(
limit = 10, limit = 10,
searchParams: Record<string, unknown> = {}, searchParams: Record<string, unknown> = {},
) { ) {
const endpoint = "problems" const res = await api.get<ProblemList>("problems", {
const res = contract( params: { paging: true, offset, limit, ...searchParams },
"GET /problems", })
problemListSchema,
await api.get<unknown>(endpoint, {
params: { paging: true, offset, limit, ...searchParams },
}),
)
return { return {
results: res.results.map(filterResult), results: res.results.map(filterResult),
total: res.total, total: res.total,
@@ -132,13 +115,11 @@ export function getProblemBeatRate(problemID: number) {
return api.get<string>(`problems/${problemID}/beat-count`) return api.get<string>(`problems/${problemID}/beat-count`)
} }
export async function getSubmission(id: string) { export async function getSubmission(id: string): Promise<Submission> {
const endpoint = `submissions/${encodeURIComponent(id)}` const response = await api.get<unknown>(
return contract( `submissions/${encodeURIComponent(id)}`,
"GET /submissions/:id",
submissionDetailSchema,
await api.get<unknown>(endpoint),
) )
return contract("GET /submissions/:id", submissionDetailSchema, response)
} }
export function submitCode(data: SubmitCodePayload) { export function submitCode(data: SubmitCodePayload) {
@@ -162,36 +143,16 @@ export function getSubmissions(params: Partial<SubmissionListPayload>) {
const endpoint = params.contestId const endpoint = params.contestId
? `contests/${encodeURIComponent(params.contestId)}/submissions` ? `contests/${encodeURIComponent(params.contestId)}/submissions`
: "submissions" : "submissions"
return getSubmissionPage(endpoint, params) // 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让
// 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE
return api.get<{ results: SubmissionListItem[]; total: number }>(endpoint, {
// contestId 走的是路径,page 只有前端分页器用
params: { ...params, contestId: undefined, page: undefined },
})
} }
/** export function getRankOfProblem(problemId: string) {
* `submissionListItemSchema.parse` return api.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
* schema****
* undefined
*
*/
async function getSubmissionPage(
endpoint: string,
params: Partial<SubmissionListPayload>,
) {
return contract(
`GET /${endpoint}`,
submissionListSchema,
await api.get<unknown>(endpoint, {
// contestId 走的是路径,page 只有前端分页器用
params: { ...params, contestId: undefined, page: undefined },
}),
)
}
export async function getRankOfProblem(problemId: string) {
const endpoint = `problems/${encodeURIComponent(problemId)}/rank`
return contract(
"GET /problems/:id/rank",
problemRankSchema,
await api.get<unknown>(endpoint),
)
} }
export function getTodaySubmissionCount(language?: string) { export function getTodaySubmissionCount(language?: string) {
@@ -208,56 +169,38 @@ export function adminRejudge(id: string) {
* username **** * username ****
* *
*/ */
export async function getSubmissionStatisticsItems( export function getSubmissionStatisticsItems(
duration: { start?: string; end: string }, duration: { start?: string; end: string },
username: string, username: string,
problemID?: string, problemID?: string,
) { ) {
const endpoint = "submissions/statistics/items" return api.get<SubmissionStatisticsItems>("submissions/statistics/items", {
return contract( params: { ...duration, problemId: problemID, username },
"GET /submissions/statistics/items", })
submissionStatisticsItemsSchema,
await api.get<unknown>(endpoint, {
params: { ...duration, problemId: problemID, username },
}),
)
} }
export async function getSubmissionStatistics( export function getSubmissionStatistics(
duration: { start?: string; end: string }, duration: { start?: string; end: string },
problemID?: string, problemID?: string,
username?: string, username?: string,
) { ) {
const endpoint = "submissions/statistics" return api.get<SubmissionStatistics>("submissions/statistics", {
return contract( params: { ...duration, problemId: problemID, username },
"GET /submissions/statistics", })
submissionStatisticsSchema,
await api.get<unknown>(endpoint, {
params: { ...duration, problemId: problemID, username },
}),
)
} }
/** /**
* 100 * 100
* Top10 limit=10 * Top10 limit=10
*/ */
export async function getRank(offset: number, limit: number) { export function getRank(offset: number, limit: number) {
const endpoint = "rankings/users" return api.get<UserRank>("rankings/users", { params: { offset, limit } })
return contract(
"GET /rankings/users",
userRankSchema,
await api.get<unknown>(endpoint, { params: { offset, limit } }),
)
} }
export async function getActivityRank(start: string) { export function getActivityRank(start: string) {
const endpoint = "rankings/activity" return api.get<ActivityRankItem[]>("rankings/activity", {
return contract( params: { start },
"GET /rankings/activity", })
activityRankItemSchema.array(),
await api.get<unknown>(endpoint, { params: { start } }),
)
} }
export function getClassRank(grade?: number | null) { export function getClassRank(grade?: number | null) {
@@ -286,37 +229,22 @@ export function getClassPK(
}) })
} }
export async function getContestList(query: { export function getContestList(query: {
offset: number offset: number
limit: number limit: number
keyword: string keyword: string
status: string status: string
tag: string tag: string
}) { }) {
const endpoint = "contests" return api.get<ContestList>("contests", { params: query })
return contract(
"GET /contests",
contestListSchema,
await api.get<unknown>(endpoint, { params: query }),
)
} }
export async function getContest(id: string) { export function getContest(id: string) {
const endpoint = `contests/${encodeURIComponent(id)}` return api.get<OjContest>(`contests/${encodeURIComponent(id)}`)
return contract(
"GET /contests/:id",
contestSchema,
await api.get<unknown>(endpoint),
)
} }
export async function getContestAccess(id: string) { export function getContestAccess(id: string) {
const endpoint = `contests/${encodeURIComponent(id)}/access` return api.get<ContestAccess>(`contests/${encodeURIComponent(id)}/access`)
return contract(
"GET /contests/:id/access",
contestAccessSchema,
await api.get<unknown>(endpoint),
)
} }
// 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch // 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch
@@ -330,29 +258,21 @@ export function checkContestPassword(contestID: string, password: string) {
} }
export async function getContestProblems(contestID: string) { export async function getContestProblems(contestID: string) {
const endpoint = `contests/${encodeURIComponent(contestID)}/problems` const res = await api.get<ProblemListItem[]>(
// 用 problemListItemSchema.array(),不是契约的 contestProblemsSchema —— `contests/${encodeURIComponent(contestID)}/problems`,
// 后者是 `array(union([列表项, 详情]))`,联合类型会让 filterResult 的类型收窄
// 落到详情分支上,而且学生侧这条接口只下发列表项。
const res = contract(
"GET /contests/:id/problems",
problemListItemSchema.array(),
await api.get<unknown>(endpoint),
) )
return res.map(filterResult) return res.map(filterResult)
} }
export async function getContestRank( export function getContestRank(
contestID: string, contestID: string,
query: { limit: number; offset: number }, query: { limit: number; offset: number },
) { ) {
// submissionInfo 在契约里是 Record<string, unknown>JSONB 原文), // submissionInfo 在契约里是 Record<string, unknown>JSONB 原文),
// 前端在这里收窄成 SubmissionInfo,见 utils/types 的 ContestRank // 前端在这里收窄成 SubmissionInfo,见 utils/types 的 ContestRank
const endpoint = `contests/${encodeURIComponent(contestID)}/rank` return api.get<{ results: ContestRank[]; total: number }>(
return contract( `contests/${encodeURIComponent(contestID)}/rank`,
"GET /contests/:id/rank", { params: query },
contestRankSchema,
await api.get<unknown>(endpoint, { params: query }),
) )
} }
@@ -368,31 +288,21 @@ export function updateProfile(data: { realName: string; mood: string }) {
return api.put<Profile>("me/profile", data) return api.put<Profile>("me/profile", data)
} }
export async function getAnnouncementList(offset = 0, limit = 10) { export function getAnnouncementList(offset = 0, limit = 10) {
const endpoint = "announcements" return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
return contract( params: { limit, offset },
"GET /announcements", })
announcementListSchema,
await api.get<unknown>(endpoint, { params: { limit, offset } }),
)
} }
export async function getAnnouncement(id: number) { export function getAnnouncement(id: number) {
const endpoint = `announcements/${id}` return api.get<Announcement>(`announcements/${id}`)
return contract(
"GET /announcements/:id",
announcementSchema,
await api.get<unknown>(endpoint),
)
} }
export async function getMessageList(offset = 0, limit = 10) { export function getMessageList(offset = 0, limit = 10) {
const endpoint = "messages" // language 的收窄同 getSubmissions,见那里的说明
return contract( return api.get<{ results: Message[]; total: number }>("messages", {
"GET /messages", params: { limit, offset },
messageListSchema, })
await api.get<unknown>(endpoint, { params: { limit, offset } }),
)
} }
export function getReaction(problemID: number) { export function getReaction(problemID: number) {
@@ -403,125 +313,71 @@ export function setReaction(problemID: number, type: ReactionKey) {
return api.post<ReactionState>(`problems/${problemID}/reaction`, { type }) return api.post<ReactionState>(`problems/${problemID}/reaction`, { type })
} }
export async function getMetrics(userid: number) { export function getMetrics(userid: number) {
const endpoint = `users/${userid}/metrics` return api.get<Metrics>(`users/${userid}/metrics`)
return contract(
"GET /users/:id/metrics",
metricsSchema,
await api.get<unknown>(endpoint),
)
} }
export async function getTutorial(id: number) { export function getTutorial(id: number) {
const endpoint = `tutorials/${id}` return api.get<Tutorial>(`tutorials/${id}`)
return contract(
"GET /tutorials/:id",
tutorialSchema,
await api.get<unknown>(endpoint),
)
} }
export async function getTutorials(type: "python" | "c") { export function getTutorials(type: "python" | "c") {
const endpoint = "tutorials" return api.get<TutorialSummary[]>("tutorials", { params: { type } })
return contract(
"GET /tutorials",
tutorialSummarySchema.array(),
await api.get<unknown>(endpoint, { params: { type } }),
)
} }
export async function getAIDetailData( export function getAIDetailData(start: string, end: string, username?: string) {
start: string, return api.get<AiDetail>("ai/detail", { params: { start, end, username } })
end: string,
username?: string,
) {
const endpoint = "ai/detail"
return contract(
"GET /ai/detail",
aiDetailSchema,
await api.get<unknown>(endpoint, { params: { start, end, username } }),
)
} }
export async function getAISolved( export function getAISolved(
start: string, start: string,
end: string, end: string,
offset: number, offset: number,
limit: number, limit: number,
username?: string, username?: string,
) { ) {
const endpoint = "ai/solved" return api.get<SolvedList>("ai/solved", {
return contract( params: { start, end, offset, limit, username },
"GET /ai/solved", })
solvedListSchema,
await api.get<unknown>(endpoint, {
params: { start, end, offset, limit, username },
}),
)
} }
export async function getAIDurationData( export function getAIDurationData(
end: string, end: string,
duration: string, duration: string,
username?: string, username?: string,
) { ) {
const endpoint = "ai/duration" return api.get<DurationData[]>("ai/duration", {
return contract( params: { end, duration, username },
"GET /ai/duration", })
durationDataSchema.array(),
await api.get<unknown>(endpoint, { params: { end, duration, username } }),
)
} }
export async function getAIHeatmapData(username?: string) { export function getAIHeatmapData(username?: string) {
const endpoint = "ai/heatmap" return api.get<HeatmapItem[]>("ai/heatmap", {
return contract( params: username ? { username } : {},
"GET /ai/heatmap", })
heatmapItemSchema.array(),
await api.get<unknown>(endpoint, {
params: username ? { username } : {},
}),
)
} }
export async function getAILoginSummary() { export function getAILoginSummary() {
const endpoint = "ai/login-summary" return api.get<LoginSummary>("ai/login-summary")
return contract(
"GET /ai/login-summary",
loginSummarySchema,
await api.get<unknown>(endpoint),
)
} }
export async function getAIPinnedReport() { export function getAIPinnedReport() {
const endpoint = "ai/pinned" return api.get<AiAnalysisRecord | null>("ai/pinned")
return contract(
"GET /ai/pinned",
aiAnalysisRecordSchema.nullable(),
await api.get<unknown>(endpoint),
)
} }
// ==================== 相似题目推荐 ==================== // ==================== 相似题目推荐 ====================
export async function getSimilarProblems(problemId: string) { export function getSimilarProblems(problemId: string) {
const endpoint = `problems/${encodeURIComponent(problemId)}/similar` return api
const res = contract( .get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
"GET /problems/:id/similar", .then((response) => response.map(filterResult))
problemListItemSchema.array(),
await api.get<unknown>(endpoint),
)
return res.map(filterResult)
} }
export type { YearlyAc as YearlyACData } from "@oj2/contract" export type { YearlyAc as YearlyACData } from "@oj2/contract"
export async function getProblemYearlyAC(problemId: string) { export function getProblemYearlyAC(problemId: string) {
const endpoint = `problems/${encodeURIComponent(problemId)}/yearly-ac` return api.get<YearlyAc[]>(
return contract( `problems/${encodeURIComponent(problemId)}/yearly-ac`,
"GET /problems/:id/yearly-ac",
yearlyAcSchema.array(),
await api.get<unknown>(endpoint),
) )
} }
@@ -535,16 +391,11 @@ export function submitFlowchart(data: {
return api.post<CreateFlowchartResponse>("flowcharts", data) return api.post<CreateFlowchartResponse>("flowcharts", data)
} }
export async function getFlowchartSubmission(id: string) { export function getFlowchartSubmission(id: string) {
const endpoint = `flowcharts/${encodeURIComponent(id)}` return api.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
return contract(
"GET /flowcharts/:id",
flowchartSubmissionSchema,
await api.get<unknown>(endpoint),
)
} }
export async function getFlowchartSubmissions(params: { export function getFlowchartSubmissions(params: {
username?: string username?: string
problemId?: string problemId?: string
myself?: string myself?: string
@@ -553,27 +404,17 @@ export async function getFlowchartSubmissions(params: {
today?: string today?: string
grade?: string grade?: string
}) { }) {
const endpoint = "flowcharts" return api.get<FlowchartList>("flowcharts", { params })
return contract(
"GET /flowcharts",
flowchartListSchema,
await api.get<unknown>(endpoint, { params }),
)
} }
export async function getFlowchartStatistics( export function getFlowchartStatistics(
duration: { start?: string; end: string }, duration: { start?: string; end: string },
problemID?: string, problemID?: string,
username?: string, username?: string,
) { ) {
const endpoint = "flowcharts/statistics" return api.get<FlowchartStatistics>("flowcharts/statistics", {
return contract( params: { ...duration, problemId: problemID, username },
"GET /flowcharts/statistics", })
flowchartStatisticsSchema,
await api.get<unknown>(endpoint, {
params: { ...duration, problemId: problemID, username },
}),
)
} }
export function retryFlowchartSubmission(submissionId: string) { export function retryFlowchartSubmission(submissionId: string) {
@@ -582,59 +423,36 @@ export function retryFlowchartSubmission(submissionId: string) {
) )
} }
export async function getCurrentProblemFlowchartSubmission(problemId: number) { export function getCurrentProblemFlowchartSubmission(problemId: number) {
const endpoint = `problems/${problemId}/flowchart/current` return api.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
return contract(
"GET /problems/:id/flowchart/current",
flowchartCurrentSchema,
await api.get<unknown>(endpoint),
)
} }
export async function getFlowchartSubmissionDetail(problemId: number, page = 0) { export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
const endpoint = `problems/${problemId}/flowchart/history` return api.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
return contract( params: { page },
"GET /problems/:id/flowchart/history", })
flowchartDetailSchema,
await api.get<unknown>(endpoint, { params: { page } }),
)
} }
// ==================== 题单相关API ==================== // ==================== 题单相关API ====================
export async function getProblemSetList( export function getProblemSetList(
offset = 0, offset = 0,
limit = 10, limit = 10,
keyword = "", keyword = "",
difficulty = "", difficulty = "",
status = "", status = "",
) { ) {
const endpoint = "problem-sets" return api.get<ProblemSetList>("problem-sets", {
return contract( params: { offset, limit, keyword, difficulty, status },
"GET /problem-sets", })
problemSetListSchema,
await api.get<unknown>(endpoint, {
params: { offset, limit, keyword, difficulty, status },
}),
)
} }
export async function getProblemSetDetail(id: number) { export function getProblemSetDetail(id: number) {
const endpoint = `problem-sets/${id}` return api.get<ProblemSet>(`problem-sets/${id}`)
return contract(
"GET /problem-sets/:id",
problemSetSchema,
await api.get<unknown>(endpoint),
)
} }
export async function getProblemSetProblems(problemSetId: number) { export function getProblemSetProblems(problemSetId: number) {
const endpoint = `problem-sets/${problemSetId}/problems` return api.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
return contract(
"GET /problem-sets/:id/problems",
problemSetProblemSchema.array(),
await api.get<unknown>(endpoint),
)
} }
export function joinProblemSet(problemSetId: number) { export function joinProblemSet(problemSetId: number) {
@@ -653,25 +471,17 @@ export function updateProblemSetProgress(
}) })
} }
export async function getUserBadges(username?: string) { export function getUserBadges(username?: string) {
const endpoint = `users/${encodeURIComponent(username ?? "me")}/badges` return api.get<UserBadge[]>(
return contract( `users/${encodeURIComponent(username ?? "me")}/badges`,
"GET /users/:username/badges",
userBadgeSchema.array(),
await api.get<unknown>(endpoint),
) )
} }
export async function getProblemSetBadges(problemSetId: number) { export function getProblemSetBadges(problemSetId: number) {
const endpoint = `problem-sets/${problemSetId}/badges` return api.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
return contract(
"GET /problem-sets/:id/badges",
problemSetBadgeSchema.array(),
await api.get<unknown>(endpoint),
)
} }
export async function getProblemSetUserProgress( export function getProblemSetUserProgress(
problemSetId: number, problemSetId: number,
params?: { params?: {
limit?: number limit?: number
@@ -680,42 +490,14 @@ export async function getProblemSetUserProgress(
completionStatus?: "" | "completed" | "in_progress" | "not_started" completionStatus?: "" | "completed" | "in_progress" | "not_started"
}, },
) { ) {
const endpoint = `problem-sets/${problemSetId}/user-progress` return api.get<ProblemSetProgressList>(
return contract( `problem-sets/${problemSetId}/user-progress`,
"GET /problem-sets/:id/user-progress", { params },
problemSetProgressListSchema,
await api.get<unknown>(endpoint, { params }),
) )
} }
export async function getExercises( export function getExercises(tutorialId: number): Promise<Exercise[]> {
tutorialId: number, return api.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
): Promise<Exercise[]> {
const endpoint = `tutorials/${tutorialId}/exercises`
// 外层走 exerciseSchema,内层 data 在这里按题型逐支校验:
// `z.infer` 只能把 data 还原成 Record<string, unknown>superRefine 无法把
// 校验结果反映到推断类型上),所以那 7 个 Exercise*.vue 直接读
// data.question / data.options 时本没有任何运行时保护。
// 生产库 151 道练习题已确认七种题型的键集全部吻合。
const rows = contract(
"GET /tutorials/:id/exercises",
exerciseSchema.array(),
await api.get<unknown>(endpoint),
)
for (const row of rows) {
const shape = exerciseDataByType[row.type]
if (!shape) continue
// 故意用同一个 contract():形状不符时它负责记日志并放行,不抛错
contract(
`GET /tutorials/:id/exercisestype=${row.type} 的 data`,
shape,
row.data,
)
}
// 类型上仍要收窄一次:契约推断出的 data 是宽松 record,组件要的是判别联合,
// 两者不重叠,所以只能经过 unknown。**这个断言是有意的,不是假校验** ——
// 上面那个循环已经在运行时按题型逐支验过;它只是把「运行时已确认」告诉 TS。
return rows as unknown as Exercise[]
} }
/** /**
@@ -741,13 +523,8 @@ export function reportExerciseAttempt(
}).catch(() => undefined) }).catch(() => undefined)
} }
export async function getLearnProgress(type: "python" | "c") { export function getLearnProgress(type: "python" | "c") {
const endpoint = "learn/progress" return api.get<TutorialProgress[]>("learn/progress", { params: { type } })
return contract(
"GET /learn/progress",
tutorialProgressSchema.array(),
await api.get<unknown>(endpoint, { params: { type } }),
)
} }
/** /**
+25 -110
View File
@@ -1,116 +1,25 @@
import type { z } from "zod" import type { z } from "zod"
/** /**
* * ****
* `shared/api.ts` getProfile `.parse()`
* *
* ## * ##
* *
* `@oj2/contract` `z.infer` ** * schema
* **drizzle * `tsc` **schema
* TypeScript `undefined` * JSONB ** schema
* schema *
* *
* `.parse()`** `as`** * 41 41 safeParse console.error
* `problemDetailSchema.parse(v) as Problem` * 7.4% 退
* *
* ## + * ##
* *
* **** * `schema.parse(v) as T` `as`
* * `parse` ZodError async
* *
* 1. `console.error` * `console.error`****
* 2. `window.__OJ2_CONTRACT_DRIFT__`线
*
* 3. ****
*
* `safeParse` `parse``parse` ZodError async
* `getProblem`
*
* ##
*
* `__OJ2_CONTRACT_DRIFT__`
* `schema.parse()` bug
* ****
*/
/**
*
*
*/
interface DriftReport {
/** 请求路径,带参数,方便直接复现 */
endpoint: string
/** zod 的 issue 摘要:路径 + 原因,多条用分号连 */
detail: string
/** 实际收到的数据。截断后的原始值,用来判断是字段缺失还是类型不同 */
received: unknown
/** 出现次数。同一个端点同一个 detail 只记一条,这里累加 */
count: number
}
const MAX_REPORTS = 200
const MAX_RECEIVED_CHARS = 2000
declare global {
interface Window {
__OJ2_CONTRACT_DRIFT__?: DriftReport[]
}
}
function collectDrift(endpoint: string, detail: string, received: unknown) {
if (typeof window === "undefined") return
const reports = (window.__OJ2_CONTRACT_DRIFT__ ??= [])
// 同一个端点 + 同一个原因只记一条,累加次数。列表接口一次几百条记录,
// 不去重的话控制台会被同一句话刷屏,真正的新问题反而看不见。
const existing = reports.find(
(item) => item.endpoint === endpoint && item.detail === detail,
)
if (existing) {
existing.count += 1
return
}
if (reports.length >= MAX_REPORTS) return
reports.push({
endpoint,
detail,
received: truncate(received),
count: 1,
})
}
/** 原始数据可能是一整个列表页,原样留着会占住大量内存;只用来判断形状,够看前 2KB 了 */
function truncate(value: unknown) {
try {
const text = JSON.stringify(value)
if (text === undefined) return value
return text.length <= MAX_RECEIVED_CHARS
? value
: `${text.slice(0, MAX_RECEIVED_CHARS)}…(截断,共 ${text.length} 字符)`
} catch {
return String(value)
}
}
function describe(error: z.ZodError, endpoint: string) {
const issues = error.issues.slice(0, 5).map((issue) => {
const path = issue.path.length ? issue.path.join(".") : "(根)"
return `${path}: ${issue.message}`
})
const more = error.issues.length > 5 ? `;另有 ${error.issues.length - 5}` : ""
return `${endpoint} 的响应不符合契约 —— ${issues.join("")}${more}`
}
/**
* `unknown` `as`
*
* ```ts
* const data = await api.get<unknown>("problems", { params })
* return contract("GET /problems", problemListSchema, data)
* ```
*
*
*/ */
export function contract<T extends z.ZodType>( export function contract<T extends z.ZodType>(
endpoint: string, endpoint: string,
@@ -120,13 +29,19 @@ export function contract<T extends z.ZodType>(
const result = schema.safeParse(value) const result = schema.safeParse(value)
if (result.success) return result.data if (result.success) return result.data
collectDrift(endpoint, describe(result.error, endpoint), value) const issues = result.error.issues
.slice(0, 5)
.map((issue) => `${issue.path.join(".") || "(根)"}: ${issue.message}`)
.join("")
const more =
result.error.issues.length > 5
? `;另有 ${result.error.issues.length - 5}`
: ""
console.error( console.error(
`[契约] ${describe(result.error, endpoint)}\n` + `[契约] ${endpoint} 的响应不符合契约 —— ${issues}${more}\n` +
" 已放行原始数据(页面照常渲染)。全部历史分歧见 window.__OJ2_CONTRACT_DRIFT__。\n" + " 已放行原始数据(页面照常渲染)。契约在 packages/contract/src/。",
" 契约在 packages/contract/src/,后端对不上的字段在 apps/api/src/routes/。",
) )
// 放行原始数据。断言在这里是**有意的**:形状确实可能不符,但调用方需要的是 // 放行原始数据。断言在这里是**有意的**:形状确实可能不符,但调用方需要的是
// 「能渲染的东西」而不是一个异常分歧已经通过上面两条记录暴露出来了。 // 「能渲染的东西」而不是一个异常分歧已经通过上面那条日志暴露出来了。
return value as z.infer<T> return value as z.infer<T>
} }
+11 -11
View File
@@ -1,5 +1,5 @@
import { toAdminType } from "@oj2/contract" import { toAdminType } from "@oj2/contract"
import type { JudgeCaseResult, SubmissionDetail } from "@oj2/contract" import type { JudgeCaseResult, JudgeInfo } from "@oj2/contract"
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns" import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { User } from "./types" import { User } from "./types"
import { USER_TYPE } from "./constants" import { USER_TYPE } from "./constants"
@@ -23,18 +23,18 @@ function calculateACRate(acCount: number, totalCount: number): string {
/** /**
* `submission.info` * `submission.info`
* *
* `info` **** * `info` `z.unknown()`
* `routes/submission.ts` `full ? row.submission.info : {}` * ****
* `.data` * `info: {}` `data: null`
*
* *
* `data` null 124191 12048 * **** data
* * `JudgeCaseResult`
*/ */
export function submissionCaseResults( export function submissionCaseResults(info: unknown): JudgeCaseResult[] {
info: SubmissionDetail["info"] | null | undefined, if (!info || typeof info !== "object" || !("data" in info)) return []
): JudgeCaseResult[] { const data = (info as JudgeInfo).data
if (!info || !("data" in info) || !info.data) return [] return Array.isArray(data) ? data : []
return info.data
} }
export function getACRate(acCount: number, totalCount: number): string { export function getACRate(acCount: number, totalCount: number): string {
+4 -3
View File
@@ -265,9 +265,10 @@ export type {
export type { CreateFlowchartRequest as SubmitFlowchartPayload } from "@oj2/contract" export type { CreateFlowchartRequest as SubmitFlowchartPayload } from "@oj2/contract"
/** /**
* **info / statisticInfo / language ** * `statisticInfo` / `language`
* `judgeInfoSchema` / `statisticInfoSchema` / `problemLanguageSchema` * `statisticInfoSchema` / `problemLanguageSchema``info`
* 124191 packages/contract/src/submission.ts * `z.unknown()` `JudgeInfo` / `JudgeCaseResult` TS
* `utils/functions` `submissionCaseResults()`
* *
* `result` 9 * `result` 9
* constants.ts SubmissionStatus.submitting * constants.ts SubmissionStatus.submitting
+10 -2
View File
@@ -6,6 +6,7 @@ import { achievementRaritySchema } from "./achievement"
import { rankProfileSchema } from "./account" import { rankProfileSchema } from "./account"
import { paginatedSchema, sampleUserSchema } from "./common" import { paginatedSchema, sampleUserSchema } from "./common"
import { reactionKeySchema } from "./content" import { reactionKeySchema } from "./content"
import { problemLanguageSchema } from "./language"
import { import {
astRulesSchema, astRulesSchema,
problemDifficultySchema, problemDifficultySchema,
@@ -671,7 +672,12 @@ export const adminProblemSchema = z.object({
languages: z.array(z.string()), languages: z.array(z.string()),
template: z.record(z.string(), z.string()), template: z.record(z.string(), z.string()),
createTime: z.string(), createTime: z.string(),
lastUpdateTime: z.string(), /**
* `problem.last_update_time` 961 470 NULL
* problemDetailSchema nullable
* 500 parse
*/
lastUpdateTime: z.string().nullable(),
timeLimit: z.number().int(), timeLimit: z.number().int(),
memoryLimit: z.number().int(), memoryLimit: z.number().int(),
visible: z.boolean(), visible: z.boolean(),
@@ -718,7 +724,9 @@ export const createProblemRequestSchema = z.object({
testCaseScore: z.array(problemTestCaseScoreSchema), testCaseScore: z.array(problemTestCaseScoreSchema),
timeLimit: z.number().int().min(1).max(1000 * 60), timeLimit: z.number().int().min(1).max(1000 * 60),
memoryLimit: z.number().int().min(1).max(1024), memoryLimit: z.number().int().min(1).max(1024),
languages: z.array(z.string()).min(1), // 收窄到语言联合而不是裸 string[]`problem.languages` 列上挂着
// `$type<ProblemLanguage[]>()`,那个断言得有人兑现 —— 闸就设在这里(写入侧)。
languages: z.array(problemLanguageSchema).min(1),
template: z.record(z.string(), z.string()), template: z.record(z.string(), z.string()),
visible: z.boolean(), visible: z.boolean(),
difficulty: z.enum(["Low", "Mid", "High"]), difficulty: z.enum(["Low", "Mid", "High"]),
+17 -51
View File
@@ -118,30 +118,16 @@ export const exerciseAttemptRequestSchema = z.object({
}) })
/** /**
* * ****`apps/api/src/services/exercise.ts`
* `exerciseDataError`
* *
* ** 151 ** * `exerciseSchema` superRefine
* mcq 46 / fill 37 / sort 25 / predict 24 / debug 11 / match 6 / group 2 * `GET /tutorials/:id/exercises``routes/content.ts` parse
* data 500
* `question`
* *
* `Record<string, unknown>` * JSONB
* `data.question` / `data.options` / `data.lines` *
* ****
*
* `exerciseSchema` ** parse**`routes/content.ts`
* `rows.map(... exerciseSchema.parse ...)`
* 500
* 151/151
*
* `data` **** **** `type`
* superRefine `type` discriminatedUnion
*
*/
/**
* **** `data`
* `z.infer` `data` `Record<string, unknown>`superRefine
*
* `utils/types` Exercise****
*
*/ */
export const exerciseDataByType: Record<string, z.ZodType> = { export const exerciseDataByType: Record<string, z.ZodType> = {
mcq: z.object({ question: z.string(), options: z.array(z.string()), answer: z.array(z.number()) }), mcq: z.object({ question: z.string(), options: z.array(z.string()), answer: z.array(z.number()) }),
@@ -154,36 +140,16 @@ export const exerciseDataByType: Record<string, z.ZodType> = {
} }
/** /**
* `type` `data` * `data` `Record<string, unknown>`****
* `type: "mcq"` `{question, code}` mcq `data.options` * schema parse`routes/content.ts`
* 500 `exerciseDataByType`
*/ */
export const exerciseSchema = z export const exerciseSchema = z.object({
.object({ id: z.number().int(),
id: z.number().int(), type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]), data: z.record(z.string(), z.unknown()),
data: z.record(z.string(), z.unknown()), order: z.number().int(),
order: z.number().int(), })
})
.superRefine((value, ctx) => {
// type 是枚举,映射表覆盖了全部七个值;这里只是让 TS 收窄,顺带在
// 将来加了新题型却忘了补形状时,以一条清楚的 issue 而不是 undefined 崩掉。
const shape = exerciseDataByType[value.type]
if (!shape) {
ctx.addIssue({ code: "custom", path: ["type"], message: `题型 ${value.type} 没有对应的内容形状` })
return
}
const parsed = shape.safeParse(value.data)
if (!parsed.success) {
ctx.addIssue({
code: "custom",
path: ["data"],
message: `type=${value.type} 的 data 形状不符:${parsed.error.issues
.slice(0, 3)
.map((issue) => `${issue.path.join(".") || "(根)"} ${issue.message}`)
.join("")}`,
})
}
})
export type Message = z.infer<typeof messageSchema> export type Message = z.infer<typeof messageSchema>
export type MessageList = z.infer<typeof messageListSchema> export type MessageList = z.infer<typeof messageListSchema>
+3 -2
View File
@@ -33,8 +33,9 @@ export const contestProblemsSchema = z.array(z.union([problemListItemSchema, pro
* `acm_contest_rank.submission_info` JSONB * `acm_contest_rank.submission_info` JSONB
* *
* ** snake_case** * ** snake_case**
* camelCase * camelCase
* `checked` * `checked`
* 2401
* *
* `z.record(z.string(), z.unknown())` * `z.record(z.string(), z.unknown())`
* `SubmissionInfo` utils/types ContestRank * `SubmissionInfo` utils/types ContestRank
+47 -51
View File
@@ -19,56 +19,51 @@ export const judgeStatusSchema = z.union([
]) ])
/** /**
* `submission.info` JSONB * `submission.info` JSONB ****
* *
* ** 124191 ** * zod schema 124192
* 9163 RE 8480/8480TLE 338/338MLE 1/1 退
* `output_md5: null` SQL `judge/sql/engine.ts` CaseResult
* `output` `error_message` null
*
* *
* - `err` 124191 ** null** `string` * `info` `union([完整形状, z.object({})])`
* `z.null()` * `{}` parse ****
* - `data` **12048 null** *
* nullable `Info` data * JSONB ****`judge/run.ts`
* 12048 * `info` `z.unknown()` TS
* - SQL `error_message`201 * `submissionCaseResults()`
* `score`10 strictObject * data ****
* *
* ** snake_case** * ** snake_case**
*/ */
export const judgeCaseResultSchema = z.object({ export interface JudgeCaseResult {
error: z.number(), error: number
memory: z.number(), memory: number
output: z.string().nullable(), /** SQL 判题没有这个键 */
result: judgeStatusSchema, output?: string | null
signal: z.number(), result: JudgeStatus
cpu_time: z.number(), signal: number
exit_code: z.number(), cpu_time: number
real_time: z.number(), exit_code: number
test_case: z.string(), real_time: number
output_md5: z.string(), test_case: string
/** SQL 判题会带上中文原因,沙箱判题没有这个键 */ /** 非正常退出的测试点上是 null */
error_message: z.string().optional(), output_md5: string | null
score: z.number().optional(), /** SQL 判题会带上中文原因(通过的测试点是 null),沙箱判题没有这个键 */
}) error_message?: string | null
score?: number
export const judgeInfoSchema = z.object({ }
err: z.string().nullable(),
data: z.array(judgeCaseResultSchema).nullable(),
})
/** /**
* `info` **** * `info` ****
* * `info: {}``routes/submission.ts` `full ? row.submission.info : {}`
* 1. JSONB * `.data`
* 2. **** `info: {}` `routes/submission.ts:841`
* `full ? row.submission.info : {}`
*
* 2 **
* 500**`submissionDetailSchema.parse` onError internal-error
*
*
* info****
*
*/ */
export const submissionInfoSchema = z.union([judgeInfoSchema, z.object({})]) export interface JudgeInfo {
err: string | null
data: JudgeCaseResult[] | null
}
/** /**
* `submission.statistic_info` JSONB * `submission.statistic_info` JSONB
@@ -76,11 +71,11 @@ export const submissionInfoSchema = z.union([judgeInfoSchema, z.object({})])
* time_cost / memory_cost 112097 * time_cost / memory_cost 112097
* score 3993err_info 3153ast_results 56 27 * score 3993err_info 3153ast_results 56 27
* *
* **** 8916 JSONB shell * `looseObject` + = ****
* JSON `objectValue()` `{ value: "<原串>" }` * `err_info`
* schema 8916 * `JudgeCaseResult`
*/ */
export const statisticInfoSchema = z.object({ export const statisticInfoSchema = z.looseObject({
score: z.number().optional(), score: z.number().optional(),
/** 判题机写进 statistic_info 的错误文本,教师面板的「最近一条错在哪」也读它 */ /** 判题机写进 statistic_info 的错误文本,教师面板的「最近一条错在哪」也读它 */
err_info: z.string().optional(), err_info: z.string().optional(),
@@ -129,8 +124,8 @@ export const submissionDetailSchema = z.object({
username: z.string(), username: z.string(),
code: z.string(), code: z.string(),
result: judgeStatusSchema, result: judgeStatusSchema,
/** 未判完或非管理员看时为 `{}`,见 submissionInfoSchema 的注释 */ /** 判题机原文;未判完或非管理员看时为 `{}`,见 JudgeInfo 的注释 */
info: submissionInfoSchema, info: z.unknown(),
language: problemLanguageSchema, language: problemLanguageSchema,
statisticInfo: statisticInfoSchema, statisticInfo: statisticInfoSchema,
contestId: z.number().int().nullable(), contestId: z.number().int().nullable(),
@@ -154,7 +149,10 @@ export const submissionDetailSchema = z.object({
* *
*/ */
export const embeddedSubmissionSchema = submissionDetailSchema export const embeddedSubmissionSchema = submissionDetailSchema
.omit({ info: true, contestId: true, problemId: true }) // problemDisplayId 也要去掉:下面的 problem 就是它,同一个值留两份,
// 而路由只填了 problem —— 这里漏 omit 的那阵子,凡是收到过站内信的人
// 打开消息页都是 500parse 抛在缺失的 problemDisplayId 上,列表为空时才碰巧不炸)。
.omit({ info: true, contestId: true, problemId: true, problemDisplayId: true })
// 旧 SubmissionSafeModelSerializer 里 problem 是 // 旧 SubmissionSafeModelSerializer 里 problem 是
// `SlugRelatedField(slug_field="_id")`,即**展示用题号**而非数字主键。 // `SlugRelatedField(slug_field="_id")`,即**展示用题号**而非数字主键。
// 站内信页面拿它拼 `/problem/<题号>` 链接,给数字 id 会拼出打不开的地址。 // 站内信页面拿它拼 `/problem/<题号>` 链接,给数字 id 会拼出打不开的地址。
@@ -314,8 +312,6 @@ export const formatCodeRequestSchema = z.object({
export const formatCodeResponseSchema = z.object({ code: z.string() }) export const formatCodeResponseSchema = z.object({ code: z.string() })
export type JudgeStatus = z.infer<typeof judgeStatusSchema> export type JudgeStatus = z.infer<typeof judgeStatusSchema>
export type JudgeInfo = z.infer<typeof judgeInfoSchema>
export type JudgeCaseResult = z.infer<typeof judgeCaseResultSchema>
export type StatisticInfo = z.infer<typeof statisticInfoSchema> export type StatisticInfo = z.infer<typeof statisticInfoSchema>
export type CreateSubmissionRequest = z.infer< export type CreateSubmissionRequest = z.infer<
typeof createSubmissionRequestSchema typeof createSubmissionRequestSchema