Compare commits

...

3 Commits

Author SHA1 Message Date
127718dc06 refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
Some checks failed
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
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
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

View File

@@ -4,28 +4,14 @@ OJ2 是判题狗Online Judge的后端重写Django 6 → Bun + TypeScrip
上一代在 `../OnlineJudge/`Django`../ojnext/`Vue SPA**仍然完全冻结、
一行都不改**。
> **2026-08-26回滚路径已废弃且已经不可逆。** 旧 Django 后端确认不再使用,
> `0002_drop_django_leftovers` 删掉了它的 7 张框架表(含 `django_session`、
> `django_migrations`)。**这条迁移已在生产库执行完毕**
> `docker exec oj-api oj2-api migrate` 回「没有待执行的迁移」)
> **旧栈已不可逆地下线。** `0002_drop_django_leftovers` 删掉了 Django 的框架表
> `django_session` 等),且已在生产库执行完毕。所以「停新栈起旧栈」「把 NPM 上游
> 改回 8080」都已失效**唯一退路是从数据库备份恢复** —— 切换手册里的「回滚保证」
> 那节只剩历史价值
>
> 所以旧栈现在**起不来**了:「停新栈起旧栈」「把 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都只落在 OJ2先确认 OJ2 是否有对应逻辑、
> 是否重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策
@@ -63,12 +49,19 @@ bun run dev # api(3000) + worker + web(5173) 一起起
常用检查:
```bash
bunx tsc --noEmit -p apps/api # 后端类型检查
bun run --filter '@oj2/api' typecheck # 后端类型检查
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` 就是它们,判题沙箱回的也是
这套编码,所以只能新增、不能改已有的含义。题目表情 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_NODE_TARGETS_BY_LANGUAGE`target → 中文名)决定后台下拉能选什么,

View File

@@ -15,6 +15,17 @@
// 详见 CLAUDE.md「改 schema 走 drizzle migration」
// * bigint identity 的 maxValue 用字符串,不能写成 JS number 字面量(会丢精度)。
// * 索引不写 `.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、
// user_badge、ai_analysis这是历史巧合不是设计——这些 app 的 0001_initial 生成时
@@ -22,7 +33,26 @@
// problem、contest、submission都是 int4。现存最大 id 一万出头,确实都用不上 bigint
// 但 2026-08-26 评估后决定**不改**:省 4 字节/行毫无意义ALTER TYPE 要重写整表并拿
// 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 { sql } from "drizzle-orm"
@@ -75,10 +105,10 @@ export const achievement = pgTable("achievement", {
name: text().notNull(),
description: text().notNull(),
icon: text().notNull(),
rarity: text().notNull(),
rarity: text().notNull().$type<AchievementRarity>(),
hidden: boolean().default(false).notNull(),
metric: text().notNull(),
operator: text().notNull(),
operator: text().notNull().$type<AchievementOperator>(),
threshold: integer().notNull(),
visible: boolean().default(true).notNull(),
unlockCount: integer("unlock_count").default(0).notNull(),
@@ -111,7 +141,7 @@ export const flowchartSubmission = pgTable("flowchart_submission", {
id: text().primaryKey().notNull(),
mermaidCode: text("mermaid_code").notNull(),
flowchartData: jsonb("flowchart_data").notNull(),
status: integer().notNull(),
status: integer().notNull().$type<FlowchartStatus>(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
aiScore: doublePrecision("ai_score"),
aiGrade: varchar("ai_grade", { length: 10 }),
@@ -189,7 +219,7 @@ export const judgeServer = pgTable("judge_server", {
export const exercise = pgTable("exercise", {
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(),
order: integer().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(),
lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }).notNull(),
visible: boolean().notNull(),
difficulty: text().notNull(),
status: text().notNull(),
difficulty: text().notNull().$type<ProblemSetDifficulty>(),
status: text().notNull().$type<ProblemSetStatus>(),
createdById: integer("created_by_id").notNull(),
endTime: timestamp("end_time", { withTimezone: true, mode: 'string' }),
}, (table) => [
@@ -324,7 +354,7 @@ export const problemsetSubmission = pgTable("problemset_submission", {
export const reaction = pgTable("reaction", {
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(),
problemId: integer("problem_id").notNull(),
userId: integer("user_id").notNull(),
@@ -354,14 +384,14 @@ export const problem = pgTable("problem", {
testCaseId: text("test_case_id").notNull(),
testCaseScore: jsonb("test_case_score").notNull(),
hint: text(),
languages: jsonb().notNull(),
template: jsonb().notNull(),
languages: jsonb().notNull().$type<ProblemLanguage[]>(),
template: jsonb().notNull().$type<Record<string, string>>(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }),
timeLimit: integer("time_limit").notNull(),
memoryLimit: integer("memory_limit").notNull(),
visible: boolean().default(true).notNull(),
difficulty: text().notNull(),
difficulty: text().notNull().$type<ProblemDifficulty>(),
source: text(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
submissionNumber: bigint("submission_number", { mode: "number" }).default(0).notNull(),
@@ -386,9 +416,9 @@ export const problem = pgTable("problem", {
flowchartHint: text("flowchart_hint"),
mermaidCode: text("mermaid_code"),
showFlowchart: boolean("show_flowchart").default(false).notNull(),
astRules: jsonb("ast_rules"),
sqlConfig: jsonb("sql_config"),
sqlDisplay: jsonb("sql_display"),
astRules: jsonb("ast_rules").$type<AstRules>(),
sqlConfig: jsonb("sql_config").$type<SqlConfig>(),
sqlDisplay: jsonb("sql_display").$type<SqlDisplay>(),
}, (table) => [
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")),
@@ -439,9 +469,9 @@ export const submission = pgTable("submission", {
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
userId: integer("user_id").notNull(),
code: text().notNull(),
result: integer().default(6).notNull(),
result: integer().default(6).notNull().$type<JudgeStatus>(),
info: jsonb().default({}).notNull(),
language: text().notNull(),
language: text().notNull().$type<ProblemLanguage>(),
/**
* 已停用,见 problem.share_submission 的说明。历史上 12.3 万条提交里有 40 条
* 为真2022 年 39 条、2023 年 1 条入口在更早的那版前端上ojnext 和 OJ2
@@ -560,7 +590,7 @@ export const tutorial = pgTable("tutorial", {
order: integer().notNull(),
createdById: integer("created_by_id").notNull(),
code: text(),
type: varchar({ length: 10 }).notNull(),
type: varchar({ length: 10 }).notNull().$type<TutorialType>(),
}, (table) => [
index("tutorial_created_by_id_07973cab").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
foreignKey({
@@ -635,7 +665,7 @@ export const userBadge = pgTable("user_badge", {
export const userProfile = pgTable("user_profile", {
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(),
mood: text(),
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(),
acceptedNumber: integer("accepted_number").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(),
userId: integer("user_id").notNull(),
}, (table) => [
@@ -705,7 +735,7 @@ export const problemsetBadge = pgTable("problemset_badge", {
name: text().notNull(),
description: text().notNull(),
icon: text().notNull(),
conditionType: text("condition_type").notNull(),
conditionType: text("condition_type").notNull().$type<BadgeConditionType>(),
conditionValue: integer("condition_value").notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
problemsetId: bigint("problemset_id", { mode: "number" }).notNull(),

View File

@@ -1,4 +1,4 @@
import { flowchartUpdateSchema, type FlowchartUpdate } from "@oj2/contract"
import type { FlowchartUpdate } from "@oj2/contract"
import { redis } from "./redis"
@@ -72,7 +72,7 @@ export function userEventTopic(userId: number) {
}
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(

View File

@@ -1,4 +1,4 @@
import { flowchartUpdateSchema } from "@oj2/contract"
import type { FlowchartUpdate } from "@oj2/contract"
import { eq } from "drizzle-orm"
import { db, schema } from "../db"
@@ -71,7 +71,7 @@ export async function evaluateFlowchart(
processingTime: (performance.now() - started) / 1000,
evaluationTime: new Date().toISOString(),
}).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({
await publishFlowchartUpdate(row.flowchart.userId, {
type: "flowchart_evaluation_completed",
submissionId: row.flowchart.id,
score: result.score,
@@ -79,7 +79,7 @@ export async function evaluateFlowchart(
feedback: result.feedback,
suggestions: result.suggestions,
criteriaDetails: result.criteria,
}))
} satisfies FlowchartUpdate)
} catch (error) {
// 原来这里把 error.message 原样推给学生、前端还直接 message.error 弹出来 ——
// AI provider 的地址、内部报错就这么进了浏览器。真实原因留在服务端日志里,
@@ -91,10 +91,10 @@ export async function evaluateFlowchart(
// 就算成功AI 侧的偶发失败(限流、超时、网络抖动)永远等不到重试。
if (!isFinalAttempt) throw error
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",
submissionId: row.flowchart.id,
}))
} satisfies FlowchartUpdate)
throw error
}
}

View File

@@ -1,7 +1,4 @@
import {
submissionUpdateSchema,
type SubmissionUpdate,
} from "@oj2/contract"
import { submissionUpdateSchema, type SubmissionUpdate } from "@oj2/contract"
import { redis } from "../redis"
@@ -20,10 +17,7 @@ export async function publishSubmissionUpdate(
userId: number,
data: SubmissionUpdate,
) {
const event: SubmissionEvent = {
userId,
data: submissionUpdateSchema.parse(data),
}
const event: SubmissionEvent = { userId, data }
await redis.publish(submissionUpdateChannel, JSON.stringify(event))
}

View File

@@ -1,6 +1,6 @@
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 { config } from "../config"
@@ -248,15 +248,12 @@ async function persistResult(
.for("update")
if (!rank) throw new Error("Contest rank could not be created")
const rankInfo = objectValue(rank.submissionInfo)
const previousInfo = objectValue(rankInfo[String(problemId)])
const alreadyAccepted = previousInfo.is_ac === true
const rankInfo = rank.submissionInfo
const previousInfo = rankInfo[String(problemId)]
const alreadyAccepted = previousInfo?.is_ac === true
if (!alreadyAccepted) {
const errorNumber =
typeof previousInfo.error_number === "number"
? previousInfo.error_number
: 0
const nextInfo: Record<string, unknown> = {
const errorNumber = previousInfo?.error_number ?? 0
const nextInfo: ContestSubmissionInfo = {
is_ac: acceptedNow,
ac_time: 0,
error_number:

View File

@@ -1,3 +1,5 @@
import type { SqlDisplay } from "@oj2/contract"
import { selfCommand } from "../../runtime"
import { JudgeStatus, type JudgeStatusValue } from "../status"
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") {
return runJob<{ tables: unknown[]; expected: unknown }>(
// 子进程产出的形状由 engine.ts 的 dumpDisplayTables / runDisplay 决定,就是契约里的
// SqlDisplay —— 同一个仓库里的两端,不在这儿再 parse 一遍
return runJob<SqlDisplay>(
{ kind: "display", initSql, refSql, mode },
{ trustedMs: DISPLAY_BUDGET_MS, studentMs: null },
)

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那也是答案对了
* 和还没判完的两个,还排掉 SYSTEM_ERROR —— 判题机自己崩了不是学生的问题,
* 不该推着 AI 提示的解锁进度往前走。
*/
export const NON_FAILURE_RESULTS: number[] = [
export const NON_FAILURE_RESULTS: JudgeStatusValue[] = [
JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED,
JudgeStatus.PENDING,

View File

@@ -2,15 +2,16 @@ import { randomBytes } from "node:crypto"
import { extname, resolve } from "node:path"
import {
STUDENT_ROLES,
activityRankItemSchema,
metricsSchema,
problemRankSchema,
myRankSchema,
rankProfileSchema,
registerRequestSchema,
STUDENT_ROLES,
updateProfileRequestSchema,
userRankSchema,
type ActivityRankItem,
type Metrics,
type MyRank,
type ProblemRank,
type RankProfile,
type UserRank,
} from "@oj2/contract"
import {
and,
@@ -144,7 +145,7 @@ accountRoutes.get("/users/:id/metrics", async (c) => {
.from(schema.submission)
.where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId)))
if (!row?.total || !row.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交")
return success(c, 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,
])
return success(c, userRankSchema.parse({
return success(c, {
results: rows.map((row) => serializeRankRow(row, online)),
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
me,
}))
} satisfies UserRank)
})
function serializeRankRow({ profile, user }: {
profile: typeof schema.userProfile.$inferSelect
user: typeof schema.user.$inferSelect
}, online: Set<number> | null = null) {
return rankProfileSchema.parse({
return {
id: profile.id,
user: sampleUser(user, profile.realName),
acceptedNumber: profile.acceptedNumber,
submissionNumber: profile.submissionNumber,
mood: profile.mood,
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),
rank: (ahead?.value ?? 0) + 1,
})
} satisfies MyRank
}
accountRoutes.get("/rankings/activity", async (c) => {
@@ -279,7 +280,7 @@ accountRoutes.get("/rankings/activity", async (c) => {
))
.groupBy(schema.submission.userId, schema.user.username)
.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) => {
@@ -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)))
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)
})
/**

View File

@@ -1,9 +1,9 @@
import {
achievementListSchema,
achievementSchema,
achievementSummarySchema,
markAchievementsReadSchema,
pendingAchievementSchema,
type Achievement,
type AchievementList,
type AchievementSummary,
type PendingAchievement,
} from "@oj2/contract"
import { and, asc, count, desc, eq, inArray } from "drizzle-orm"
import { Hono } from "hono"
@@ -27,13 +27,13 @@ async function resolveUser(requested: string | undefined, currentId: number) {
}
function pendingData(row: { achievement: typeof schema.achievement.$inferSelect }) {
return pendingAchievementSchema.parse({
return {
id: row.achievement.id,
name: row.achievement.name,
description: row.achievement.description,
icon: row.achievement.icon,
rarity: row.achievement.rarity,
})
} satisfies PendingAchievement
}
achievementRoutes.get("/achievements", requireAuth, async (c) => {
@@ -52,7 +52,7 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
const record = unlocked.get(achievement.id)
const masked = achievement.hidden && !record
const progress = metrics[achievement.metric]
return achievementSchema.parse({
return {
id: achievement.id,
name: masked ? "???" : achievement.name,
description: masked ? "达成条件保密" : achievement.description,
@@ -67,9 +67,9 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => {
backfilled: record?.backfilled ?? false,
progress: masked ? null : typeof progress === "number" ? progress : 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) => {
@@ -85,7 +85,7 @@ achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
const rarities = ["bronze", "silver", "gold", "platinum"] as const
const total = achievements.length
const unlocked = unlockedRows.length
return success(c, achievementSummarySchema.parse({
return success(c, {
username: target.username,
total,
unlocked,
@@ -97,7 +97,7 @@ achievementRoutes.get("/achievements/summary", requireAuth, async (c) => {
unlocked: unlockedRows.filter((item) => item.achievement.rarity === rarity).length,
})),
recent: unlockedRows.slice(0, 10).map(pendingData),
}))
} satisfies AchievementSummary)
})
achievementRoutes.get("/achievements/pending", requireAuth, async (c) => {

View File

@@ -1,16 +1,16 @@
import {
STUDENT_ROLES,
adminTypeSchema,
adminUserListSchema,
adminUserRankSchema,
adminUserSchema,
deleteUsersRequestSchema,
importUsersRequestSchema,
rankProfileSchema,
resetPasswordResponseSchema,
STUDENT_ROLES,
updateUserRequestSchema,
type AdminType,
type AdminUser,
type AdminUserList,
type AdminUserRank,
type ProblemPermission,
type RankProfile,
type ResetPasswordResponse,
} from "@oj2/contract"
import { randomInt } from "node:crypto"
import { z } from "zod"
@@ -66,7 +66,7 @@ function serialize(row: {
user: typeof schema.user.$inferSelect
realName: string | null
}, isOnline: boolean) {
return adminUserSchema.parse({
return {
id: row.user.id,
username: row.user.username,
email: row.user.email,
@@ -79,7 +79,7 @@ function serialize(row: {
isOnline,
rawPassword: row.user.rawPassword,
className: row.user.className,
})
} satisfies AdminUser
}
function selectUser(id: number) {
@@ -122,16 +122,19 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => {
.limit(limit).offset(offset),
])
return success(c, adminUserRankSchema.parse({
results: rows.map(({ profile, user }) => rankProfileSchema.parse({
return success(c, {
results: rows.map(({ profile, user }) => ({
id: profile.id,
user: sampleUser(user, profile.realName),
acceptedNumber: profile.acceptedNumber,
submissionNumber: profile.submissionNumber,
mood: profile.mood,
})),
// 这张榜不下发在线状态null = 「调用方不该知道」,见契约里 isOnline 的注释)。
// 原来是靠 schema 的 .default(null) 填出来的,改成显式写死。
isOnline: null,
} satisfies RankProfile)),
total: totalRows[0]?.value ?? 0,
}))
} satisfies AdminUserRank)
})
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)
.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))),
total: totalRows[0]?.value ?? 0,
}))
} satisfies AdminUserList)
})
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))
// 旧密码登出来的会话立刻作废,理由同 PUT /users/:id
await revokeUserSessions(id, "session-ended")
return success(c, resetPasswordResponseSchema.parse({ password }))
return success(c, { password } satisfies ResetPasswordResponse)
})

View File

@@ -1,8 +1,8 @@
import {
achievementMetricSchema,
adminAchievementSchema,
createAchievementRequestSchema,
updateAchievementRequestSchema,
type AchievementMetric,
type AdminAchievement,
} from "@oj2/contract"
import { asc, eq } from "drizzle-orm"
import { Hono } from "hono"
@@ -17,7 +17,7 @@ import { queryInteger } from "../helpers"
export const adminAchievementRoutes = new Hono<AppEnv>()
function serialize(row: typeof schema.achievement.$inferSelect) {
return adminAchievementSchema.parse({
return {
id: row.id,
name: row.name,
description: row.description,
@@ -32,12 +32,12 @@ function serialize(row: typeof schema.achievement.$inferSelect) {
unlockCount: row.unlockCount,
order: row.order,
createTime: row.createTime,
})
} satisfies AdminAchievement
}
/** 下拉框的可选项就是代码里注册了什么,见 services/achievement-metrics.ts 的说明 */
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) => {
const rows = await db.select().from(schema.achievement)

View File

@@ -1,8 +1,8 @@
import {
adminAiReportListSchema,
adminAiReportListItemSchema,
adminAiReportSchema,
toggleAiReportPinResponseSchema,
import type {
AdminAiReport,
AdminAiReportList,
AdminAiReportListItem,
ToggleAiReportPinResponse,
} from "@oj2/contract"
import { and, count, desc, eq, ilike } from "drizzle-orm"
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 }) {
return adminAiReportListItemSchema.parse({
return {
id: row.id,
username: row.username,
createTime: row.createTime,
analysisExcerpt: excerpt(row.analysis),
isPinned: row.isPinned,
})
} satisfies AdminAiReportListItem
}
const listColumns = {
@@ -53,10 +53,10 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(and(eq(schema.aiAnalysis.isPinned, true), where))
.orderBy(desc(schema.aiAnalysis.createTime))
return success(c, adminAiReportListSchema.parse({
return success(c, {
results: rows.map(listItem),
total: rows.length,
}))
} satisfies AdminAiReportList)
}
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)
.orderBy(desc(schema.aiAnalysis.createTime)).limit(limit).offset(offset),
])
return success(c, adminAiReportListSchema.parse({
return success(c, {
results: rows.map(listItem),
total: totalRows[0]?.value ?? 0,
}))
} satisfies AdminAiReportList)
})
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)
if (!row) return failure(c, 404, "report-not-found", "AIAnalysis not found")
// data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词
return success(c, adminAiReportSchema.parse(row))
return success(c, row satisfies AdminAiReport)
})
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))
})
return success(c, toggleAiReportPinResponseSchema.parse({ isPinned: next }))
return success(c, { isPinned: next } satisfies ToggleAiReportPinResponse)
})

View File

@@ -1,8 +1,8 @@
import {
adminAnnouncementListSchema,
adminAnnouncementSchema,
createAnnouncementRequestSchema,
updateAnnouncementRequestSchema,
type AdminAnnouncement,
type AdminAnnouncementList,
} from "@oj2/contract"
import { count, desc, eq } from "drizzle-orm"
import { Hono } from "hono"
@@ -19,7 +19,7 @@ function serialize(row: {
user: typeof schema.user.$inferSelect
realName: string | null
}) {
return adminAnnouncementSchema.parse({
return {
id: row.announcement.id,
title: row.announcement.title,
tag: row.announcement.tag,
@@ -29,7 +29,7 @@ function serialize(row: {
createdBy: sampleUser(row.user, row.realName),
createTime: row.announcement.createTime,
lastUpdateTime: row.announcement.lastUpdateTime,
})
} satisfies AdminAnnouncement
}
function selectOne(id: number) {
@@ -55,11 +55,11 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => {
.limit(limit)
.offset(offset),
])
return success(c, adminAnnouncementListSchema.parse({
return success(c, {
// 列表 schema omit 掉了 contentZod 会 strip 掉多出来的键,这里不必手工再挑一遍
results: rows.map(serialize),
total: totalRows[0]?.value ?? 0,
}))
} satisfies AdminAnnouncementList)
})
adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => {

View File

@@ -1,12 +1,12 @@
import {
dashboardInfoSchema,
judgeServerListSchema,
judgeServerSchema,
orphanTestCaseSchema,
updateJudgeServerRequestSchema,
updateWebsiteConfigRequestSchema,
uploadImageResponseSchema,
websiteConfigSchema,
type DashboardInfo,
type JudgeServer,
type JudgeServerList,
type OrphanTestCase,
type UploadImageResponse,
type WebsiteConfig,
} from "@oj2/contract"
import { randomInt } from "node:crypto"
import { mkdir, readdir, rm, stat } from "node:fs/promises"
@@ -57,7 +57,7 @@ const OPTION_KEYS = {
adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
const options = await getWebsiteOptions()
return success(c, websiteConfigSchema.parse({
return success(c, {
websiteBaseUrl: options.website_base_url,
websiteName: options.website_name,
websiteNameShortcut: options.website_name_shortcut,
@@ -66,7 +66,7 @@ adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
submissionListShowAll: options.submission_list_show_all,
classList: options.class_list,
enableMaxkb: options.enable_maxkb,
}))
} satisfies WebsiteConfig)
})
adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
@@ -96,14 +96,14 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => {
const rows = await db.select().from(schema.judgeServer).orderBy(desc(schema.judgeServer.lastHeartbeat))
return success(c, judgeServerListSchema.parse({
return success(c, {
// 后台要显示 token 才能拿去配判题机。这个接口是超管专属的
token: config.judgeServerToken,
servers: rows.map((row) => judgeServerSchema.parse({
servers: rows.map((row) => ({
...row,
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal",
})),
}))
} satisfies JudgeServer)),
} satisfies JudgeServerList)
})
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 rows = await Promise.all(ids.map(async (id) => {
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)
})
@@ -180,12 +180,12 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
.where(gte(schema.judgeServer.lastHeartbeat, aliveSince())),
])
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST前端从未读过不再下发
return success(c, dashboardInfoSchema.parse({
return success(c, {
userCount: users?.value ?? 0,
todaySubmissionCount: submissions?.value ?? 0,
recentContestCount: contests?.value ?? 0,
judgeServerCount: servers?.value ?? 0,
}))
} satisfies DashboardInfo)
})
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 image = form?.get("image")
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()
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 兜。这里显式限一道:文件写在本地磁盘上,
// 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂
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}`
@@ -236,13 +236,13 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
await Bun.write(resolve(config.uploadDirectory, name), image)
} catch (error) {
console.error("Failed to save uploaded image", error)
return success(c, 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,
msg: "Success",
filePath: `${config.uploadUriPrefix}/${name}`,
}))
} satisfies UploadImageResponse)
})
function randomFileName() {

View File

@@ -1,10 +1,10 @@
import {
acmHelperItemSchema,
adminContestListSchema,
adminContestSchema,
createContestRequestSchema,
updateAcmHelperRequestSchema,
updateContestRequestSchema,
type AcmHelperItem,
type AdminContest,
type AdminContestList,
} from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray } from "drizzle-orm"
import { Hono } from "hono"
@@ -31,7 +31,7 @@ async function serialize(row: {
user: typeof schema.user.$inferSelect
realName: string | null
}) {
return adminContestSchema.parse({
return {
id: row.contest.id,
title: row.contest.title,
description: row.contest.description,
@@ -45,7 +45,7 @@ async function serialize(row: {
createdBy: sampleUser(row.user, row.realName),
status: contestStatus(row.contest),
contestType: row.contest.password ? "Password Protected" : "Public",
})
} satisfies AdminContest
}
function selectContest(id: number) {
@@ -84,10 +84,10 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => {
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(where).orderBy(desc(schema.contest.createTime)).limit(limit).offset(offset),
])
return success(c, adminContestListSchema.parse({
return success(c, {
results: await Promise.all(rows.map(serialize)),
total: totalRows[0]?.value ?? 0,
}))
} satisfies AdminContestList)
})
adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => {
@@ -266,8 +266,7 @@ adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) =>
const results = []
for (const rank of ranks) {
if (rank.acceptedNumber <= 0) continue
for (const [problemId, raw] of Object.entries(objectValue(rank.submissionInfo))) {
const info = objectValue(raw)
for (const [problemId, info] of Object.entries(rank.submissionInfo)) {
if (info.is_ac !== true) continue
results.push({
id: rank.id,
@@ -285,7 +284,7 @@ adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) =>
}
// 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的
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) => {
@@ -306,9 +305,9 @@ adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) =>
)).limit(1)
if (!rank) return failure(c, 404, "rank-not-found", "Rank id does not exist")
const info = objectValue(rank.submissionInfo)
const entry = objectValue(info[parsed.data.problemId])
if (!info[parsed.data.problemId]) {
const info = rank.submissionInfo
const entry = info[parsed.data.problemId]
if (!entry) {
return failure(c, 404, "problem-not-in-rank", "Problem id does not exist")
}
entry.checked = parsed.data.checked

View File

@@ -1,13 +1,13 @@
import {
STUDENT_ROLES,
TUTORIAL_READ_SECONDS,
learnExerciseAttemptSchema,
learnExerciseProgressListSchema,
learnExerciseProgressSchema,
learnStudentProgressListSchema,
learnStudentProgressSchema,
learnTutorialProgressListSchema,
learnTutorialProgressSchema,
type LearnExerciseAttempt,
type LearnExerciseProgress,
type LearnExerciseProgressList,
type LearnStudentProgress,
type LearnStudentProgressList,
type LearnTutorialProgress,
type LearnTutorialProgressList,
} from "@oj2/contract"
import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm"
import { Hono } from "hono"
@@ -120,16 +120,16 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
.where(inArray(schema.exercise.tutorialId, tutorialIds))
: [{ value: 0 }]
return success(c, learnStudentProgressListSchema.parse({
return success(c, {
tutorialCount: tutorialIds.length,
exerciseCount: exerciseCountRow?.value ?? 0,
results: rows.map((row) => learnStudentProgressSchema.parse({
results: rows.map((row) => ({
...row,
exerciseTried: attempts.get(row.userId)?.tried ?? 0,
exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
})),
}))
} satisfies LearnStudentProgress)),
} satisfies LearnStudentProgressList)
})
adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => {
@@ -166,13 +166,13 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order))
return success(c, learnTutorialProgressListSchema.parse({
return success(c, {
studentCount,
results: rows.map(({ readSeconds, ...row }) => learnTutorialProgressSchema.parse({
results: rows.map(({ readSeconds, ...row }) => ({
...row,
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)
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
return success(c, learnExerciseProgressListSchema.parse({
return success(c, {
studentCount: studentCountRow?.value ?? 0,
results: rows.map((row) => learnExerciseProgressSchema.parse({
results: rows.map((row) => ({
...row,
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))
return success(c, rows.map((row) => learnExerciseAttemptSchema.parse(row)))
return success(c, rows satisfies LearnExerciseAttempt[])
})

View File

@@ -1,18 +1,19 @@
import {
addContestProblemRequestSchema,
adminProblemListItemSchema,
adminProblemListSchema,
adminProblemSchema,
createProblemRequestSchema,
makeProblemPublicRequestSchema,
updateProblemRequestSchema,
generateSqlTestCaseRequestSchema,
generateSqlTestCaseResponseSchema,
makeProblemPublicRequestSchema,
sqlPreviewRequestSchema,
sqlTestCaseScriptSchema,
uploadTestCaseResponseSchema,
updateProblemRequestSchema,
type AdminProblem,
type AdminProblemList,
type AdminProblemListItem,
type AstRules,
type GenerateSqlTestCaseResponse,
type SqlConfig,
type SqlDisplay,
type SqlTestCaseScript,
type UploadTestCaseResponse,
} from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm"
import { Hono } from "hono"
@@ -30,7 +31,7 @@ import { config } from "../../config"
import { readFile } from "node:fs/promises"
import { resolve } from "node:path"
import { getTopReactions } from "../../services/reaction"
import { objectValue, queryInteger, sampleUser, stringArray } from "../helpers"
import { objectValue, queryInteger, sampleUser } from "../helpers"
export const adminProblemRoutes = new Hono<AppEnv>()
@@ -130,7 +131,7 @@ async function serialize(row: ProblemRow) {
.where(eq(schema.user.id, row.createdById)).limit(1),
tagNames(row.id),
])
return adminProblemSchema.parse({
return {
id: row.id,
_id: row.displayId,
title: row.title,
@@ -141,8 +142,8 @@ async function serialize(row: ProblemRow) {
testCaseId: row.testCaseId,
testCaseScore: Array.isArray(row.testCaseScore) ? row.testCaseScore : [],
hint: row.hint,
languages: stringArray(row.languages),
template: objectValue(row.template),
languages: row.languages,
template: row.template,
createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime,
timeLimit: row.timeLimit,
@@ -164,9 +165,9 @@ async function serialize(row: ProblemRow) {
astRules: row.astRules,
answers: Array.isArray(row.answers) ? row.answers : [],
prompt: row.prompt,
sqlConfig: row.sqlConfig ? objectValue(row.sqlConfig) : null,
sqlDisplay: row.sqlDisplay ? objectValue(row.sqlDisplay) : null,
})
sqlConfig: row.sqlConfig,
sqlDisplay: row.sqlDisplay,
} satisfies AdminProblem
}
/** 公共校验,对齐旧 `ProblemBase.common_checks` */
@@ -205,7 +206,7 @@ async function generateSqlDisplay(
testCaseId: string,
answers: Record<string, unknown>[],
sqlConfig: SqlConfig,
): Promise<{ error: string } | { display: unknown }> {
): Promise<{ error: string } | { display: SqlDisplay }> {
const info = await readInfo(testCaseId)
if (!info) return { error: "测试点信息读取失败,请重新上传测试点" }
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 [topReactions, tags] = await Promise.all([getTopReactions(problemIds), tagNamesFor(problemIds)])
return success(c, adminProblemListSchema.parse({
return success(c, {
results: rows.map(({ problem, user: creator, realName }) =>
adminProblemListItemSchema.parse({
({
id: problem.id,
_id: problem.displayId,
title: problem.title,
@@ -309,9 +310,9 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => {
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
topReaction: topReactions.get(problem.id) ?? null,
})),
} satisfies AdminProblemListItem)),
total: totalRow[0]?.value ?? 0,
}))
} satisfies AdminProblemList)
})
adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => {
@@ -331,7 +332,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => {
}
const checked = commonChecks(parsed.data)
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
let sqlDisplay: unknown = null
let sqlDisplay: SqlDisplay | null = null
if (checked.sql) {
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)
@@ -388,7 +389,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => {
if (duplicate) return failure(c, 409, "display-id-exists", "Display ID already exists")
// SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上
let sqlDisplay: unknown = null
let sqlDisplay: SqlDisplay | null = null
if (checked.sql) {
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)
@@ -467,9 +468,9 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
.where(where).orderBy(desc(schema.problem.createTime)).limit(limit).offset(offset),
])
const tags = await tagNamesFor(rows.map(({ problem }) => problem.id))
return success(c, adminProblemListSchema.parse({
return success(c, {
results: rows.map(({ problem, user: creator, realName }) =>
adminProblemListItemSchema.parse({
({
id: problem.id,
_id: problem.displayId,
title: problem.title,
@@ -482,9 +483,9 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission
allowFlowchart: problem.allowFlowchart,
showFlowchart: problem.showFlowchart,
topReaction: null,
})),
} satisfies AdminProblemListItem)),
total: totalRow[0]?.value ?? 0,
}))
} satisfies AdminProblemList)
})
adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermission, async (c) => {
@@ -500,7 +501,7 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio
}
const checked = commonChecks(parsed.data)
if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error)
let sqlDisplay: unknown = null
let sqlDisplay: SqlDisplay | null = null
if (checked.sql) {
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)
@@ -652,10 +653,10 @@ adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => {
const sql = ["1", "true", "True"].includes(String(form?.get("sql") ?? ""))
try {
const result = await processTestCaseZip(new Uint8Array(await file.arrayBuffer()), { sql })
return success(c, uploadTestCaseResponseSchema.parse({
return success(c, {
id: result.testCaseId,
info: result.info,
}), 201)
} satisfies UploadTestCaseResponse, 201)
} catch (error) {
if (error instanceof TestCaseError) return failure(c, 400, "invalid-test-case", error.message)
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 类型")
try {
const scripts = await readSqlScripts(problem.testCaseId)
return success(c, scripts.map((script) => sqlTestCaseScriptSchema.parse(script)))
return success(c, scripts satisfies SqlTestCaseScript[])
} catch (error) {
console.error("Failed to read SQL test case scripts", error)
return failure(c, 500, "test-case-error", "测试点脚本读取失败")
@@ -735,7 +736,7 @@ SELECT 语句,或增删改题的 UPDATE/DELETE/INSERT 语句)和题型。
请只返回 SQL 脚本本身,连 \`\`\` 都不需要,不要任何解释文字。`,
`题型:${parsed.data.mode}\n标准答案\n${parsed.data.refSql}`,
)
return success(c, generateSqlTestCaseResponseSchema.parse({ sql }))
return success(c, { sql } satisfies GenerateSqlTestCaseResponse)
} catch (error) {
console.error("SQL test case generation failed", error)
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")

View File

@@ -1,16 +1,16 @@
import {
addProblemToSetRequestSchema,
adminProblemSetBadgeSchema,
adminProblemSetListSchema,
adminProblemSetProblemSchema,
adminProblemSetProgressSchema,
adminProblemSetSchema,
createProblemSetBadgeRequestSchema,
createProblemSetRequestSchema,
updateProblemInSetRequestSchema,
updateProblemSetBadgeRequestSchema,
updateProblemSetRequestSchema,
updateProblemSetStatusRequestSchema,
type AdminProblemSet,
type AdminProblemSetBadge,
type AdminProblemSetList,
type AdminProblemSetProblem,
type AdminProblemSetProgress,
} from "@oj2/contract"
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"
import { Hono } from "hono"
@@ -20,7 +20,7 @@ import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { recalculateBadge, resyncProgress } from "../../services/problemset"
import { queryInteger, sampleUser } from "../helpers"
import { asFilterValue, queryInteger, sampleUser } from "../helpers"
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]))
return rows.map((row) => {
const creator = creatorById.get(row.createdById)
return adminProblemSetSchema.parse({
return {
id: row.id,
title: row.title,
description: row.description,
@@ -78,7 +78,7 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
lastUpdateTime: row.lastUpdateTime,
problemsCount: problemsBySet.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}%`),
)!)
}
if (difficulty) filters.push(eq(schema.problemset.difficulty, difficulty))
if (status) filters.push(eq(schema.problemset.status, status))
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
const where = filters.length ? and(...filters) : undefined
const [totalRows, rows] = await Promise.all([
@@ -111,10 +111,10 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
db.select().from(schema.problemset).where(where)
.orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset),
])
return success(c, adminProblemSetListSchema.parse({
return success(c, {
results: await serializeMany(rows),
total: totalRows[0]?.value ?? 0,
}))
} satisfies AdminProblemSetList)
})
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))
.where(eq(schema.problemsetProblem.problemsetId, row.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,
problemsetId: item.problemsetId,
problemId: item.problemId,
@@ -205,7 +205,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c
isRequired: item.isRequired,
score: item.score,
hint: item.hint,
})))
} satisfies AdminProblemSetProblem)))
})
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)))
.groupBy(schema.userBadge.badgeId)
const countByBadge = new Map(earned.map((item) => [item.badgeId, item.value]))
return badges.map((badge) => adminProblemSetBadgeSchema.parse({
return badges.map((badge) => ({
id: badge.id,
problemsetId: badge.problemsetId,
name: badge.name,
@@ -298,7 +298,7 @@ async function badgesWithCount(badges: BadgeRow[]) {
conditionType: badge.conditionType,
conditionValue: badge.conditionValue,
earnedCount: countByBadge.get(badge.id) ?? 0,
}))
} satisfies AdminProblemSetBadge))
}
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))
.orderBy(desc(schema.problemsetProgress.joinTime))
return success(c, rows.map(({ progress, username, realName }) =>
adminProblemSetProgressSchema.parse({
({
id: progress.id,
userId: progress.userId,
username,
@@ -388,7 +388,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c
completedProblemsCount: progress.completedProblemsCount,
totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore,
})))
} satisfies AdminProblemSetProgress)))
})
adminProblemSetRoutes.delete("/problem-sets/:id/progress/:userId", requireTeacher, async (c) => {

View File

@@ -1,13 +1,13 @@
import {
acTrendSchema,
adminTagSchema,
batchProblemTagRequestSchema,
batchProblemTagResponseSchema,
generateFlowchartRequestSchema,
generateFlowchartResponseSchema,
renameTagRequestSchema,
renameTagResponseSchema,
stuckProblemSchema,
type AcTrend,
type AdminTag,
type BatchProblemTagResponse,
type GenerateFlowchartResponse,
type RenameTagResponse,
type StuckProblem,
} from "@oj2/contract"
import { and, asc, countDistinct, count, desc, eq, gte, ilike, inArray, isNull, lte, ne, sql } from "drizzle-orm"
import { Hono } from "hono"
@@ -46,7 +46,7 @@ adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0
.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) => {
@@ -63,7 +63,7 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
if (!target) {
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))
return links.length
})
return success(c, renameTagResponseSchema.parse({
return success(c, {
merged: true, id: target.id, name: target.name, affectedCount: affected,
}))
} satisfies RenameTagResponse)
})
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)
})
return success(c, batchProblemTagResponseSchema.parse({
return success(c, {
problemCount: problems.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`)
.orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`))
.limit(40)
return success(c, rows.map((row) => stuckProblemSchema.parse({
return success(c, rows.map((row) => ({
problemId: row.displayId,
problemTitle: row.title,
total: row.total,
failed: row.failed,
failedUsers: row.failedUsers,
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
})))
} satisfies StuckProblem)))
})
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 (!entry.yearly.every((row) => row.total > minPerYear)) continue
result.push(acTrendSchema.parse({
result.push({
problemId: entry.displayId,
problemTitle: entry.title,
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,
}))
.sort((left, right) => left.year - right.year),
}))
} satisfies AcTrend)
}
return success(c, result)
})
@@ -292,7 +292,7 @@ adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) =
请只返回 mermaid 代码,连 \`\`\` 都不需要。`,
parsed.data.python,
)
return success(c, generateFlowchartResponseSchema.parse({ flowchart }))
return success(c, { flowchart } satisfies GenerateFlowchartResponse)
} catch (error) {
console.error("Flowchart generation failed", error)
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")

View File

@@ -1,12 +1,12 @@
import {
adminExerciseSchema,
adminTutorialGroupsSchema,
adminTutorialSchema,
createExerciseRequestSchema,
createTutorialRequestSchema,
setTutorialVisibilityRequestSchema,
updateExerciseRequestSchema,
updateTutorialRequestSchema,
type AdminExercise,
type AdminTutorial,
type AdminTutorialGroups,
} from "@oj2/contract"
import { asc, desc, eq } from "drizzle-orm"
import { Hono } from "hono"
@@ -24,7 +24,7 @@ function serializeTutorial(row: {
user: typeof schema.user.$inferSelect
realName: string | null
}) {
return adminTutorialSchema.parse({
return {
id: row.tutorial.id,
title: row.tutorial.title,
content: row.tutorial.content,
@@ -35,7 +35,7 @@ function serializeTutorial(row: {
createdBy: sampleUser(row.user, row.realName),
createdAt: row.tutorial.createdAt,
updatedAt: row.tutorial.updatedAt,
})
} satisfies AdminTutorial
}
function selectTutorial(id: number) {
@@ -57,10 +57,10 @@ adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => {
.orderBy(asc(schema.tutorial.order), desc(schema.tutorial.createdAt))
const all = rows.map(serializeTutorial)
// 分组返回,形状对齐旧 TutorialAdminAPI.get列表 schema omit 掉了 content/codeZod 会 strip
return success(c, adminTutorialGroupsSchema.parse({
return success(c, {
python: all.filter((item) => item.type === "python"),
c: all.filter((item) => item.type === "c"),
}))
} satisfies AdminTutorialGroups)
})
adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => {
@@ -126,12 +126,12 @@ adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => {
// ---------------------------------------------------------------- 练习
function serializeExercise(row: typeof schema.exercise.$inferSelect) {
return adminExerciseSchema.parse({
return {
id: row.id,
type: row.type,
data: objectValue(row.data),
order: row.order,
})
} satisfies AdminExercise
}
// 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数,

View File

@@ -1,16 +1,17 @@
import {
HINT_MIN_FAILURES,
aiAnalysisRecordSchema,
aiAnalysisRequestSchema,
aiDetailSchema,
aiHintRequestSchema,
classAnalysisRequestSchema,
classPkAnalysisRequestSchema,
durationDataSchema,
heatmapItemSchema,
loginSummarySchema,
solvedListSchema,
solvedProblemSchema,
HINT_MIN_FAILURES,
type AiAnalysisRecord,
type AiDetail,
type DurationData,
type Grade,
type HeatmapItem,
type LoginSummary,
type SolvedList,
type SolvedProblem,
} from "@oj2/contract"
import { and, asc, count, countDistinct, eq, gte, inArray, isNull, lte, min, sql } from "drizzle-orm"
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 { config } from "../config"
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 { completeChat, streamChat } from "../services/ai"
import { consumeToken } from "../services/throttling"
@@ -27,7 +28,7 @@ import { countFailedSubmissions, isTeacherOrAbove, objectValue, queryInteger, ro
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: "困难" }
/**
@@ -61,15 +62,15 @@ const calendarDay = new Intl.DateTimeFormat("en-CA", {
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"
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
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 values = grades.flatMap((item) => weights[item] ?? [])
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 rank = all.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 },
acTime: item.first, rank, acCount: all.length, grade: grade(periodRank, period.length, all.length), periodRank, periodAcCount: period.length,
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
attempts: attemptsByProblem.get(item.problemId) ?? 1,
})
} satisfies SolvedProblem
}).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
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)
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) {
@@ -195,7 +196,7 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
eq(schema.submission.userId, user.id),
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
const errorCounts = new Map<number, number>()
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)
const firstAc = await firstAcQuery(user, start, end)
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,
activity, errors, rankScope: "global",
})
} satisfies AiDetail
const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all([
buildSolved(user, start, end, firstAc),
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),
}
}).sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime))
return aiDetailSchema.parse({
return {
user: user.username, className: user.className, start, end, flowcharts,
solvedCount: solved.length, attempts: solved.map((item) => item.attempts),
grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty,
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
activity, errors, rankScope: scopeIds ? "class" : "global",
})
} satisfies AiDetail
}
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 acceptedRows = inRange.filter((row) => accepted.includes(row.result))
const solved = [...new Set(acceptedRows.map((row) => row.problemId))]
return durationDataSchema.parse({
return {
unit: config.unit,
index: config.count - 1 - index,
start: bucket.start.toISOString(),
@@ -366,7 +367,7 @@ async function buildDuration(user: AuthUser, endText: string, duration: string)
problemCount: solved.length,
acceptedCount: acceptedRows.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)
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)
}
}
return success(c, loginSummarySchema.parse({ summary, analysis, analysisError }))
return success(c, { summary, analysis, analysisError } satisfies LoginSummary)
})
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))
.where(and(eq(schema.aiAnalysis.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true))).limit(1)
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),
analysis: row.analysis.analysis, createTime: row.analysis.createTime, isPinned: row.analysis.isPinned, username: row.username,
}))
} satisfies AiAnalysisRecord)
})
aiRoutes.post("/ai/analysis", requireAuth, async (c) => {

View File

@@ -1,10 +1,10 @@
import {
STUDENT_ROLES,
classComparisonRequestSchema,
classComparisonResponseSchema,
classComparisonSchema,
classRankItemSchema,
classUserRankSchema,
STUDENT_ROLES,
type ClassComparison,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
} from "@oj2/contract"
import { and, eq, gte, inArray, like, lte, sql } from "drizzle-orm"
import { Hono } from "hono"
@@ -94,7 +94,7 @@ classroomRoutes.get("/rankings/classes", async (c) => {
acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0,
}
}).sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission)
return success(c, result.map((item, index) => 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) => {
@@ -121,7 +121,7 @@ classroomRoutes.get("/me/class-rank", requireAuth, async (c) => {
const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10)
selected = ranks.slice(start, start + 10)
}
return success(c, 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) => {
@@ -166,7 +166,7 @@ classroomRoutes.post("/classes/comparison", async (c) => {
const middle = topCount + bottomCount < userCount ? ac.slice(topCount, -bottomCount) : ac
const totalAc = ac.reduce((sum, value) => sum + value, 0)
const totalSubmission = submissions.reduce((sum, value) => sum + value, 0)
const base: Record<string, number | string> = {
const base: ClassComparison = {
className,
userCount,
totalAc,
@@ -197,21 +197,18 @@ classroomRoutes.post("/classes/comparison", async (c) => {
}
return base
})
const maxMedian = Math.max(1, ...comparisons.map((item) => Number(item.medianAc)))
const maxMiddle = Math.max(1, ...comparisons.map((item) => Number(item.middle80Avg)))
const maxMedian = Math.max(1, ...comparisons.map((item) => item.medianAc))
const maxMiddle = Math.max(1, ...comparisons.map((item) => item.middle80Avg))
for (const item of comparisons) {
item.compositeScore = rounded(
0.4 * (Number(item.medianAc) / maxMedian * 100) +
0.15 * (Number(item.middle80Avg) / maxMiddle * 100) +
0.2 * Number(item.activeRate) +
0.15 * Number(item.passRate) +
0.1 * Number(item.excellentRate),
0.4 * (item.medianAc / maxMedian * 100) +
0.15 * (item.middle80Avg / maxMiddle * 100) +
0.2 * item.activeRate +
0.15 * item.passRate +
0.1 * item.excellentRate,
1,
)
}
comparisons.sort((a, b) => Number(b.compositeScore) - Number(a.compositeScore) || Number(b.medianAc) - Number(a.medianAc))
return success(c, classComparisonResponseSchema.parse({
comparisons: comparisons.map((item) => classComparisonSchema.parse(item)),
hasTimeRange,
}))
comparisons.sort((a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc)
return success(c, { comparisons, hasTimeRange } satisfies ClassComparisonResponse)
})

View File

@@ -1,20 +1,22 @@
import {
announcementListItemSchema,
announcementListSchema,
announcementSchema,
createMessageRequestSchema,
exerciseSchema,
messageListSchema,
messageSchema,
reactionKeySchema,
reactionStateSchema,
setReactionRequestSchema,
embeddedSubmissionSchema,
exerciseAttemptRequestSchema,
reactionKeySchema,
setReactionRequestSchema,
tutorialProgressPingSchema,
tutorialProgressSchema,
tutorialSchema,
tutorialSummarySchema,
type Announcement,
type AnnouncementList,
type AnnouncementListItem,
type EmbeddedSubmission,
type Exercise,
type Message,
type MessageList,
type ReactionCounts,
type ReactionState,
type Tutorial,
type TutorialProgress,
type TutorialSummary,
} from "@oj2/contract"
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
import { Hono } from "hono"
@@ -38,8 +40,8 @@ contentRoutes.get("/announcements", async (c) => {
.where(eq(schema.announcement.visible, true))
.orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset),
])
return success(c, announcementListSchema.parse({
results: rows.map(({ announcement, user, realName }) => announcementListItemSchema.parse({
return success(c, {
results: rows.map(({ announcement, user, realName }) => ({
id: announcement.id,
title: announcement.title,
tag: announcement.tag,
@@ -47,9 +49,9 @@ contentRoutes.get("/announcements", async (c) => {
createdBy: sampleUser(user, realName),
createTime: announcement.createTime,
lastUpdateTime: announcement.lastUpdateTime,
})),
} satisfies AnnouncementListItem)),
total: totalRows[0]?.value ?? 0,
}))
} satisfies AnnouncementList)
})
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))
.where(and(eq(schema.announcement.id, id), eq(schema.announcement.visible, true))).limit(1)
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
return success(c, announcementSchema.parse({
return success(c, {
id: row.announcement.id,
title: row.announcement.title,
tag: row.announcement.tag,
@@ -68,7 +70,7 @@ contentRoutes.get("/announcements/:id", async (c) => {
createdBy: sampleUser(row.user, row.realName),
createTime: row.announcement.createTime,
lastUpdateTime: row.announcement.lastUpdateTime,
}))
} satisfies Announcement)
})
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))
.where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset),
])
return success(c, messageListSchema.parse({
results: rows.map(({ message, sender, realName, submission, displayId }) => messageSchema.parse({
return success(c, {
results: rows.map(({ message, sender, realName, submission, displayId }) => ({
id: message.id,
sender: sampleUser(sender, realName),
createTime: message.createTime,
message: message.message,
submission: embeddedSubmissionSchema.parse({
submission: {
id: submission.id,
createTime: submission.createTime,
userId: submission.userId,
@@ -104,10 +106,10 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
problem: displayId,
showLink: true,
}),
})),
} satisfies EmbeddedSubmission,
} satisfies Message)),
total: totalRows[0]?.value ?? 0,
}))
} satisfies MessageList)
})
/**
@@ -140,15 +142,16 @@ contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
async function reactionState(problemId: number, userId: number) {
const [mine] = await db.select({ type: schema.reaction.type }).from(schema.reaction)
.where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1)
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)
.where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type)
const counts = Object.fromEntries(reactionKeySchema.options.map((key) => [key, 0]))
for (const row of rows) {
const key = reactionKeySchema.safeParse(row.type)
if (key.success) counts[key.data] = row.value
}
return reactionStateSchema.parse({ mine: mine.type, counts })
// fromEntries 推不出这个键集,但 options 就是 ReactionKey 的全集,断言是成立的。
// row.type 不必再 safeParsereaction.type 列上挂着 $type<ReactionKey>()
const counts = Object.fromEntries(
reactionKeySchema.options.map((key) => [key, 0]),
) as ReactionCounts
for (const row of rows) counts[row.type] = row.value
return { mine: mine.type, counts } satisfies ReactionState
}
contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
@@ -183,7 +186,7 @@ contentRoutes.get("/tutorials", async (c) => {
const type = c.req.query("type") === "c" ? "c" : "python"
const rows = await db.select({ id: schema.tutorial.id, title: schema.tutorial.title }).from(schema.tutorial)
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order))
return success(c, rows.map((row) => tutorialSummarySchema.parse(row)))
return success(c, rows satisfies TutorialSummary[])
})
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))
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
return success(c, tutorialSchema.parse({
return success(c, {
id: row.tutorial.id,
title: row.tutorial.title,
content: row.tutorial.content,
@@ -204,7 +207,7 @@ contentRoutes.get("/tutorials/:id", async (c) => {
createdBy: sampleUser(row.user, row.realName),
createdAt: row.tutorial.createdAt,
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]))
return success(c, rows.map((row) => tutorialProgressSchema.parse({
return success(c, rows.map((row) => ({
tutorialId: row.tutorialId,
viewCount: row.viewCount ?? 0,
totalSeconds: row.totalSeconds ?? 0,
@@ -260,7 +263,7 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
lastViewedAt: row.lastViewedAt,
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
})))
} satisfies TutorialProgress)))
})
/**
@@ -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)
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
const rows = await db.select().from(schema.exercise).where(eq(schema.exercise.tutorialId, id)).orderBy(asc(schema.exercise.order))
return success(c, rows.map((row) => 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)))
})

View File

@@ -1,13 +1,13 @@
import {
STUDENT_ROLES,
contestAccessSchema,
contestListSchema,
contestPasswordRequestSchema,
contestRankItemSchema,
contestRankSchema,
contestSchema,
problemDetailSchema,
problemListItemSchema,
STUDENT_ROLES,
type Contest,
type ContestAccess,
type ContestList,
type ContestRank,
type ContestRankItem,
type ProblemDetail,
type ProblemListItem,
} from "@oj2/contract"
import { and, asc, count, desc, eq, gte, ilike, inArray, lte, sql } from "drizzle-orm"
import { Hono } from "hono"
@@ -27,7 +27,7 @@ import {
requireContestAccess,
type ContestEnv,
} from "../services/contest"
import { objectValue, publicTemplates, queryInteger, sampleUser, stringArray } from "./helpers"
import { objectValue, publicTemplates, queryInteger, sampleUser } from "./helpers"
export const contestRoutes = new Hono<ContestEnv>()
@@ -47,7 +47,7 @@ function serializeContest(
createdBy: ReturnType<typeof sampleUser>,
includeNow = false,
) {
return contestSchema.parse({
return {
id: contest.id,
title: contest.title,
description: contest.description,
@@ -60,7 +60,7 @@ function serializeContest(
status: contestStatus(contest),
contestType: contest.password ? "Password Protected" : "Public",
now: includeNow ? new Date().toISOString() : undefined,
})
} satisfies Contest
}
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),
])
const byId = await creators([...new Set(rows.map((row) => row.createdById))])
return success(c, contestListSchema.parse({
return success(c, {
results: rows.map((row) => serializeContest(
row,
byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null),
)),
total: totalRow[0]?.value ?? 0,
}))
} satisfies ContestList)
})
// 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 }))
if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist")
const access = await canAccessContest(c, contest, "details")
return success(c, 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 allowed = contestDetailsAllowed(c.get("user"), contest)
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.displayId,
title: problem.title,
@@ -179,7 +179,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess("
showFlowchart: problem.showFlowchart,
hasAstRules: problem.astRules !== null,
myStatus: myStatusOf(statuses, problem.id),
})))
} satisfies ProblemListItem)))
})
contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => {
@@ -192,7 +192,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
const tags = await contestProblemTags([row.problem.id])
const allowed = contestDetailsAllowed(c.get("user"), contest)
const statuses = await contestProblemStatuses(c.get("user")?.id)
return success(c, problemDetailSchema.parse({
return success(c, {
id: row.problem.id,
_id: row.problem.displayId,
title: row.problem.title,
@@ -201,7 +201,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
outputDescription: row.problem.outputDescription,
samples: Array.isArray(row.problem.samples) ? row.problem.samples : [],
hint: row.problem.hint,
languages: stringArray(row.problem.languages),
languages: row.problem.languages,
template: publicTemplates(row.problem.template),
createTime: row.problem.createTime,
lastUpdateTime: row.problem.lastUpdateTime,
@@ -224,11 +224,11 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont
mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode,
flowchartData: row.problem.allowFlowchart ? null : objectValue(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
sqlConfig: row.problem.sqlConfig,
sqlDisplay: row.problem.sqlDisplay,
// 代码要求:只给渲染好的文案,规则原文不下发给学生
astRequirements: astRequirements(row.problem.astRules),
}))
} satisfies ProblemDetail)
})
contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => {
@@ -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),
])
const admin = isContestAdmin(c.get("user"), contest)
return success(c, contestRankSchema.parse({
results: rows.map(({ rank, user, realName }) => contestRankItemSchema.parse({
return success(c, {
results: rows.map(({ rank, user, realName }) => ({
id: rank.id,
// 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84
// `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)`
@@ -256,9 +256,9 @@ contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("rank
submissionNumber: rank.submissionNumber,
acceptedNumber: rank.acceptedNumber,
totalTime: rank.totalTime,
submissionInfo: objectValue(rank.submissionInfo),
submissionInfo: rank.submissionInfo,
contestId: rank.contestId,
})),
} satisfies ContestRankItem)),
total: totalRows[0]?.value ?? 0,
}))
} satisfies ContestRank)
})

View File

@@ -2,13 +2,13 @@ import { randomBytes } from "node:crypto"
import {
createFlowchartRequestSchema,
createFlowchartResponseSchema,
flowchartCurrentSchema,
flowchartDetailSchema,
flowchartListItemSchema,
flowchartListSchema,
flowchartStatisticsSchema,
flowchartSubmissionSchema,
type CreateFlowchartResponse,
type FlowchartCurrent,
type FlowchartDetail,
type FlowchartList,
type FlowchartListItem,
type FlowchartStatistics,
type FlowchartSubmission,
} from "@oj2/contract"
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
import { Hono } from "hono"
@@ -45,7 +45,7 @@ function flowchartData(
flowchart: typeof schema.flowchartSubmission.$inferSelect,
username: string,
) {
return flowchartSubmissionSchema.parse({
return {
id: flowchart.id,
username,
problemId: flowchart.problemId,
@@ -62,7 +62,7 @@ function flowchartData(
aiModel: flowchart.aiModel,
processingTime: flowchart.processingTime,
evaluationTime: flowchart.evaluationTime,
})
} satisfies FlowchartSubmission
}
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))
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) => {
@@ -121,7 +121,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
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 (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)
.orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset),
])
return success(c, flowchartListSchema.parse({
results: rows.map(({ flowchart, username, problem }) => flowchartListItemSchema.parse({
return success(c, {
results: rows.map(({ flowchart, username, problem }) => ({
id: flowchart.id,
username,
problem: problem.displayId,
@@ -151,9 +151,9 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
processingTime: flowchart.processingTime,
evaluationTime: flowchart.evaluationTime,
showLink: canView(user, flowchart, problem),
})),
} satisfies FlowchartListItem)),
total: totalRows[0]?.value ?? 0,
}))
} satisfies FlowchartList)
})
const FLOWCHART_COMPLETED = 2
@@ -242,7 +242,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
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 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 }
}
return success(c, flowchartStatisticsSchema.parse({
return success(c, {
totalCount: rows.length,
// 分母是有分数的条数,不是总条数 —— 对齐 Django 的 Avg(),它跳过 NULL
avgScore: scoreCount ? rounded(scoreSum / scoreCount, 1) : 0,
@@ -304,7 +304,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
username: row.username,
realName: stripClassPrefix(row.username, row.className),
})),
}))
} satisfies FlowchartStatistics)
})
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))
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) => {
@@ -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 })
.from(schema.flowchartSubmission).where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2)))
.orderBy(desc(schema.flowchartSubmission.createTime))
return success(c, 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) => {
@@ -371,5 +371,5 @@ flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) =>
.orderBy(asc(schema.flowchartSubmission.createTime))
const selected = page === 0 ? rows.at(-1) : rows[page - 1]
if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range")
return success(c, 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)
})

View File

@@ -1,9 +1,4 @@
import {
ADMIN_ROLES,
TEACHER_ROLES,
sampleUserSchema,
type SampleUser,
} from "@oj2/contract"
import { ADMIN_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
import { and, count, eq, notInArray } from "drizzle-orm"
@@ -26,11 +21,11 @@ export function sampleUser(
realName: string | null | undefined,
options: { includeRealName?: boolean } = {},
): SampleUser {
return sampleUserSchema.parse({
return {
id: source.id,
username: source.username,
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
}
/**
* 拿 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> {
return value && typeof value === "object" && !Array.isArray(value)
? (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(
value: string | undefined,
fallback: number,

View File

@@ -1,11 +1,4 @@
import {
problemAuthorSchema,
problemDetailSchema,
problemListItemSchema,
problemListSchema,
tagSchema,
yearlyAcSchema,
} from "@oj2/contract"
import type { ProblemAuthor, ProblemDetail, ProblemList, ProblemListItem, Tag, YearlyAc } from "@oj2/contract"
import {
and,
asc,
@@ -28,7 +21,7 @@ import { db, schema } from "../db"
import { astRequirements } from "../judge/ast"
import { failure, success } from "../http"
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>()
@@ -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) {
const templates: Record<string, string> = {}
for (const [language, raw] of Object.entries(objectValue(value))) {
@@ -76,7 +65,7 @@ function listItem(
statuses: Record<string, unknown>,
) {
const status = toObject(statuses[String(row.problem.id)]).status
return problemListItemSchema.parse({
return {
id: row.problem.id,
_id: row.problem.displayId,
title: row.problem.title,
@@ -90,7 +79,7 @@ function listItem(
showFlowchart: row.problem.showFlowchart,
hasAstRules: row.problem.astRules !== null,
myStatus: typeof status === "number" ? status : null,
})
} satisfies ProblemListItem
}
problemRoutes.get("/problems", optionalAuth, async (c) => {
@@ -103,7 +92,7 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
const tag = c.req.query("tag")?.trim()
if (author) filters.push(eq(schema.user.username, author))
if (keyword) filters.push(or(ilike(schema.problem.title, `%${keyword}%`), ilike(schema.problem.displayId, `%${keyword}%`))!)
if (difficulty) filters.push(eq(schema.problem.difficulty, difficulty))
if (difficulty) filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty)))
if (tag) {
filters.push(inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags)
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id))
@@ -140,10 +129,10 @@ problemRoutes.get("/problems", optionalAuth, async (c) => {
getProblemTags(rows.map((row) => row.problem.id)),
getProblemStatuses(c.get("user")?.id),
])
return success(c, problemListSchema.parse({
return success(c, {
results: rows.map((row) => listItem(row, tags, statuses)),
total: totalRow?.value ?? 0,
}))
} satisfies ProblemList)
})
problemRoutes.get("/problem-tags", async (c) => {
@@ -161,7 +150,7 @@ problemRoutes.get("/problem-tags", async (c) => {
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
.groupBy(schema.problemTag.id, schema.problemTag.name).having(sql`count(${schema.problemTags.problemId}) > 0`)
.orderBy(asc(schema.problemTag.name))
return success(c, rows.map((row) => tagSchema.parse(row)))
return success(c, rows satisfies Tag[])
})
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))
.where(and(isNull(schema.problem.contestId), eq(schema.user.isDisabled, false), showAll ? undefined : eq(schema.problem.visible, true)))
.groupBy(schema.user.username).orderBy(desc(count(schema.problem.id)))
return success(c, rows.map((row) => problemAuthorSchema.parse(row)))
return success(c, rows satisfies ProblemAuthor[])
})
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`,
}).from(schema.submission).where(and(eq(schema.submission.problemId, problem.id), isNull(schema.submission.contestId), notInArray(schema.submission.result, [6, 7])))
.groupBy(year).orderBy(year)
return success(c, rows.map((row) => yearlyAcSchema.parse({ ...row, acRate: row.total > 0 ? Math.round(row.accepted / row.total * 10_000) / 100 : 0 })))
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) => {
@@ -283,7 +272,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
}
const samples = Array.isArray(row.problem.samples) ? row.problem.samples : []
const data = problemDetailSchema.parse({
const data = {
id: row.problem.id,
_id: row.problem.displayId,
title: row.problem.title,
@@ -292,7 +281,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
outputDescription: row.problem.outputDescription,
samples,
hint: row.problem.hint,
languages: stringArray(row.problem.languages),
languages: row.problem.languages,
template: publicTemplates(row.problem.template),
createTime: row.problem.createTime,
lastUpdateTime: row.problem.lastUpdateTime,
@@ -316,11 +305,11 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => {
? null
: objectValue(row.problem.flowchartData),
flowchartHint: row.problem.flowchartHint,
sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null,
sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null,
sqlConfig: row.problem.sqlConfig,
sqlDisplay: row.problem.sqlDisplay,
// 代码要求:只给渲染好的文案,规则原文不下发给学生
astRequirements: astRequirements(row.problem.astRules),
})
} satisfies ProblemDetail
return success(c, data)
})

View File

@@ -1,13 +1,13 @@
import {
problemSetBadgeSchema,
problemSetListSchema,
problemSetProblemSchema,
problemSetProgressListSchema,
problemSetProgressSchema,
problemSetSchema,
updateProblemSetProgressRequestSchema,
joinProblemSetRequestSchema,
userBadgeSchema,
updateProblemSetProgressRequestSchema,
type ProblemSet,
type ProblemSetBadge,
type ProblemSetList,
type ProblemSetProblem,
type ProblemSetProgress,
type ProblemSetProgressList,
type UserBadge,
} from "@oj2/contract"
import {
and,
@@ -32,7 +32,7 @@ import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { updateAchievementsForProblemSet } from "../services/achievements"
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>()
@@ -65,7 +65,7 @@ async function problemSetCreators(ids: number[]) {
}
function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) {
return problemSetBadgeSchema.parse({
return {
id: badge.id,
problemsetId: badge.problemsetId,
name: badge.name,
@@ -74,7 +74,7 @@ function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: b
conditionType: badge.conditionType,
conditionValue: badge.conditionValue,
isEarned: earned,
})
} satisfies ProblemSetBadge
}
/**
@@ -113,7 +113,7 @@ async function serializeProblemSets(
const earned = new Set(earnedRows.map((item) => item.id))
return rows.map((row) => {
const progress = progressBySet.get(row.id)
return problemSetSchema.parse({
return {
id: row.id,
title: row.title,
description: row.description,
@@ -128,7 +128,7 @@ async function serializeProblemSets(
completedCount: progress?.completedProblemsCount ?? 0,
userProgress: progressSummary(progress),
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 status = c.req.query("status")?.trim()
if (keyword) filters.push(or(ilike(schema.problemset.title, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`))!)
if (difficulty) filters.push(eq(schema.problemset.difficulty, difficulty))
if (status) filters.push(eq(schema.problemset.status, status))
if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty)))
if (status) filters.push(eq(schema.problemset.status, asFilterValue(status)))
const where = and(...filters)
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.problemset).where(where),
db.select().from(schema.problemset).where(where).orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset),
])
return success(c, problemSetListSchema.parse({
return success(c, {
results: await serializeProblemSets(rows, c.get("user")?.id, true),
total: totalRows[0]?.value ?? 0,
}))
} satisfies ProblemSetList)
})
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)
: []
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,
problemsetId: link.problemsetId,
problem: { id: problemId, _id: displayId, title, difficulty },
@@ -198,7 +198,7 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
score: link.score,
hint: link.hint,
isCompleted: String(problemId) in completed,
})))
} satisfies ProblemSetProblem)))
})
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))
.innerJoin(schema.problemset, eq(schema.problemsetBadge.problemsetId, schema.problemset.id))
.where(eq(schema.userBadge.userId, target.id)).orderBy(desc(schema.userBadge.earnedTime))
return success(c, rows.map(({ userBadge, badge, problemSet }) => userBadgeSchema.parse({
return success(c, rows.map(({ userBadge, badge, problemSet }) => ({
id: userBadge.id,
userId: userBadge.userId,
badge: badgeData(badge),
earnedTime: userBadge.earnedTime,
problemset: { id: problemSet.id, title: problemSet.title },
})))
} satisfies UserBadge)))
})
problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
@@ -399,7 +399,7 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)),
])
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem]))
const results = rows.map(({ progress, user: progressUser, realName }) => problemSetProgressSchema.parse({
const results = rows.map(({ progress, user: progressUser, realName }) => ({
id: progress.id,
problemsetId: progress.problemsetId,
user: sampleUser(progressUser, realName),
@@ -411,12 +411,12 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c
totalProblemsCount: progress.totalProblemsCount,
totalScore: progress.totalScore,
completedProblems: Object.keys(objectValue(progress.progressDetail)).flatMap((key) => problemMap.get(key) ?? []),
}))
} satisfies ProblemSetProgress))
const stats = statsRows[0]
return success(c, problemSetProgressListSchema.parse({
return success(c, {
results,
total: stats?.total ?? 0,
statistics: { total: stats?.total ?? 0, completed: stats?.completed ?? 0, avgProgress: Number(stats?.avgProgress ?? 0) },
problems: problemRows,
}))
} satisfies ProblemSetProgressList)
})

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 { Hono } from "hono"
import { resolve } from "node:path"
@@ -14,7 +14,7 @@ export const siteRoutes = new Hono()
siteRoutes.get("/site", async (c) => {
const options = await getWebsiteOptions()
return success(c, websiteConfigSchema.parse({
return success(c, {
websiteBaseUrl: options.website_base_url,
websiteName: options.website_name,
websiteNameShortcut: options.website_name_shortcut,
@@ -23,7 +23,7 @@ siteRoutes.get("/site", async (c) => {
submissionListShowAll: options.submission_list_show_all,
classList: options.class_list,
enableMaxkb: options.enable_maxkb,
}))
} satisfies WebsiteConfig)
})
/**
@@ -31,7 +31,7 @@ siteRoutes.get("/site", async (c) => {
* 而榜单页本身就允许匿名看。谁在线是另一回事,只在 /rankings/users 里对老师下发。
*/
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 就会走这里)
@@ -47,11 +47,6 @@ const fallbackQuotes = [
let categoryPaths: string[] | null = null
const sentenceCache = new Map<string, Quote[]>()
interface Quote {
hitokoto: string
from: string
}
async function loadSentences(path: string) {
const cached = sentenceCache.get(path)
if (cached) return cached
@@ -78,10 +73,10 @@ async function randomQuote() {
siteRoutes.get("/quotes/random", async (c) => {
try {
return success(c, quoteSchema.parse(await randomQuote()))
return success(c, (await randomQuote()) satisfies Quote)
} catch {
const item = fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]!
return success(c, quoteSchema.parse(item))
return success(c, item satisfies Quote)
}
})

View File

@@ -2,14 +2,14 @@ import { randomBytes } from "node:crypto"
import {
createSubmissionRequestSchema,
createSubmissionResponseSchema,
formatCodeRequestSchema,
formatCodeResponseSchema,
submissionDetailSchema,
submissionListItemSchema,
submissionListSchema,
submissionStatisticsItemsSchema,
submissionStatisticsSchema,
type CreateSubmissionResponse,
type FormatCodeResponse,
type SubmissionDetail,
type SubmissionList,
type SubmissionListItem,
type SubmissionStatistics,
type SubmissionStatisticsItems,
} from "@oj2/contract"
import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
import { Hono } from "hono"
@@ -23,7 +23,7 @@ import {
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
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 {
canAccessContest,
@@ -36,22 +36,10 @@ import {
import { CodeFormatError, formatCode } from "../services/format-code"
import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling"
import {
isAdminRole,
queryInteger,
rounded,
stripClassPrefix,
todayStart,
} from "./helpers"
import { asFilterValue, isAdminRole, queryInteger, rounded, stripClassPrefix, todayStart } from "./helpers"
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> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -100,7 +88,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
.limit(1)
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(
c,
400,
@@ -162,7 +150,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
return success(
c,
createSubmissionResponseSchema.parse({ submissionId }),
{ submissionId } satisfies CreateSubmissionResponse,
201,
)
})
@@ -258,9 +246,11 @@ const FAILURE_MESSAGE_LIMIT = 400
* statistic_info 的那一段,提交详情页读的也是它。
*/
async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
// result 手写成 JudgeStatusValue这条裸 SQL 读的就是 submission.result 那一列,
// 口径要和列上的 $type 一致
const byUser = new Map<
number,
{ id: string; problem: string; result: number; error: string | null }
{ id: string; problem: string; result: JudgeStatusValue; error: string | null }
>()
if (!userIds.length) return byUser
@@ -269,7 +259,7 @@ async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
user_id: number
id: string
problem: string
result: number
result: JudgeStatusValue
error: string | null
}>(sql`
select user_id, id, problem, result, error from (
@@ -606,7 +596,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
return success(
c,
submissionStatisticsSchema.parse({
{
submissionCount,
acceptedCount,
judgingCount,
@@ -615,7 +605,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
data,
dataUnaccepted,
dataAttempted,
}),
} satisfies SubmissionStatistics,
)
})
@@ -661,10 +651,10 @@ submissionRoutes.get("/submissions/statistics/items", requireTeacher, async (c)
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success(
c,
submissionStatisticsItemsSchema.parse({
{
items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
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")
try {
const code = await formatCode(parsed.data.code, parsed.data.language)
return success(c, formatCodeResponseSchema.parse({ code }))
return success(c, { code } satisfies FormatCodeResponse)
} catch (error) {
if (error instanceof CodeFormatError) {
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 与
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
const full = isAdminRole(user)
return submissionDetailSchema.parse({
return {
id: row.submission.id,
createTime: row.submission.createTime,
userId: row.submission.userId,
@@ -847,7 +837,7 @@ async function submissionDetail(id: string, user: AuthUser) {
// problem 表本来就 join 了,不额外查库
problemDisplayId: row.problem.displayId,
showLink: true,
})
} satisfies SubmissionDetail
}
/**
@@ -926,7 +916,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
// 「非管理员即受限」,不能写成「是普通用户才受限」——
// 后者对匿名用户user 为 null会短路匿名反而能看到全部提交权限大于登录学生。
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 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 (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
else if (username) filters.push(usernameFilter(username))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, Number(result)))
if (language) filters.push(eq(schema.submission.language, language))
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, asFilterValue(language)))
if (c.req.query("today") === "1") filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`)
const where = and(...filters)
// count 不 join problemproblem 只有按题号筛选时才出现在 where 里,无条件 join 会让
@@ -960,8 +950,8 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
// 来源题单的标题。一页里不同题单最多几个,按主键查一次就够
problemsetTitleMap(rows.map((row) => row.submission.problemsetId)),
])
return success(c, submissionListSchema.parse({
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({
return success(c, {
results: rows.map(({ submission, problem }) => ({
id: submission.id,
problem: problem.displayId,
problemTitle: problem.title,
@@ -976,9 +966,9 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
problemSet: submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId)
? { id: submission.problemsetId, title: problemsetTitles.get(submission.problemsetId)! }
: null,
})),
} satisfies SubmissionListItem)),
total: totalRows[0]?.value ?? 0,
}))
} satisfies SubmissionList)
})
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 (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
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}`)
const where = and(...filters)
// count 不 join problemproblem 只有按题号筛选时才出现在 where 里,无条件 join 会让
@@ -1013,8 +1003,8 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
// `isNull(problem.contestId)`admin/problemset.ts:232——而这条列表只出比赛提交
// 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。
// 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch那边同样是死代码。
return success(c, submissionListSchema.parse({
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({
return success(c, {
results: rows.map(({ submission, problem }) => ({
id: submission.id,
problem: problem.displayId,
problemTitle: problem.title,
@@ -1028,9 +1018,9 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
// 比赛提交没有来源题单题单只收非比赛题admin/problemset.ts 加题时卡了
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
problemSet: null,
})),
} satisfies SubmissionListItem)),
total: totalRows[0]?.value ?? 0,
}))
} satisfies SubmissionList)
})
submissionRoutes.get("/submissions/:id", requireAuth, async (c) => {

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())` —— 七种题型的字段完全不同,
* 用 zod 写成判别联合会让**读**路径也跟着卡(后台详情、学生端列表都过同一个 schema
* 历史脏数据会把整页打不开。所以和 astRulesError 一样:只在写入前校验,读路径照样放行
* 两层:先按 `exerciseDataByType` 查形状(键在不在、类型对不对),再走下面的语义
* 检查(选项够不够、下标越不越界)。形状那层是后补的 —— 之前只有语义检查
* 而它**一次都没查过 `question`**,一道没有题干的练习能存进库
*
* 为什么非校验不可:以前唯一的校验在前端 ExerciseManager 的 buildData(),而它对
* fill 和 mcq 几乎不查 —— 一道没有 `{{空位}}` 的填空题能存进库,学生端渲染出来是
@@ -17,6 +20,16 @@ export function exerciseDataError(
type: ExerciseType,
data: Record<string, unknown>,
): 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) {
case "mcq": {
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[] {
return Array.isArray(value) && value.every((item) => typeof item === "string")
? (value as string[])

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 { db, schema } from "../db"
@@ -12,9 +12,9 @@ export async function getUserProfileById(userId: number, showRealName: boolean)
.limit(1)
if (!row) return null
return userProfileSchema.parse({
return {
id: row.profile.id,
user: sessionUserSchema.parse({
user: {
id: row.user.id,
username: row.user.username,
email: row.user.email,
@@ -24,12 +24,12 @@ export async function getUserProfileById(userId: number, showRealName: boolean)
lastLogin: row.user.lastLogin,
isDisabled: row.user.isDisabled,
className: row.user.className,
}),
} satisfies SessionUser,
realName: showRealName ? row.profile.realName : null,
acmProblemsStatus: row.profile.acmProblemsStatus,
avatar: row.profile.avatar,
mood: row.profile.mood,
acceptedNumber: row.profile.acceptedNumber,
submissionNumber: row.profile.submissionNumber,
})
} satisfies UserProfile
}

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 { touchSession } from "./auth/session"
@@ -345,11 +345,11 @@ async function handleMessage(
return
}
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
? { type: "flowchart_evaluation_failed", submissionId: flowchart.id }
: { type: "flowchart_evaluation_update", submissionId: flowchart.id }
ws.send(JSON.stringify(flowchartUpdateSchema.parse(replay)))
? { type: "flowchart_evaluation_failed" as const, submissionId: flowchart.id }
: { type: "flowchart_evaluation_update" as const, submissionId: flowchart.id }
ws.send(JSON.stringify(replay satisfies FlowchartUpdate))
return
}

View File

@@ -13,15 +13,21 @@ ViteRolldown 内核、Naive UI、Pinia、Vue Router。
## Commands
前端一般不单独起,`OJ2/` 根目录 `bun run dev` 会把 api + worker + web 一起拉起来。
只跑前端或要验证时:
```bash
npm start # Start dev server on port 5173
npm run build # Production build
npm run build:staging # Staging build
npm run build:test # Test build
npm fmt # Format with Prettier
bun run dev # 只起前端 dev server5173后端得另外起
bun run type-check # 类型检查。改完 .vue / .ts 必须跑这个
bun run build # 生产构建
bun run fmt # 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
@@ -41,9 +47,12 @@ src/
### Module Pattern
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
- `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/`:
- `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`)
`@oj2/contract` 的 zod schema 是**前后端唯一的形状来源**`utils/types.ts` 只做
`z.infer` 派生与少量前端专有的收窄(都写了理由)。读接口应当走守卫:
`z.infer` 派生与少量前端专有的收窄(都写了理由)。
运行时闸门**只挂三处**:题目详情、提交详情、`shared/api.ts` 的用户资料 ——
原本就写了 `.parse()` 的那三条。留着它们的理由是**别抛错**,不是校验:
```ts
const endpoint = `problems/${encodeURIComponent(id)}`
return contract("GET /problems/:id", problemDetailSchema, await api.get<unknown>(endpoint))
// 原来是 problemDetailSchema.parse(v) as Problem —— `as` 让校验白做,
// 而 parse 抛错会让整个题目页白屏
return contract("GET /problems/:id", problemDetailSchema, value)
```
**失败策略是「记日志 + 放行原始数据」,不抛错。** 形状对不上时:控制台打一条带
端点和字段路径的记录、去重后记进 `window.__OJ2_CONTRACT_DRIFT__`、然后**返回原始
数据让页面继续渲染**。面向学生的生产站点,少一个字段的代价远小于白屏。
失败时记一条控制台日志再**放行原始数据**,页面照常渲染。
排查线上分歧就是打开控制台敲 `window.__OJ2_CONTRACT_DRIFT__`;某条路径长期为空之后,
那条路径可以升级成硬失败(直接 `schema.parse`),在那之前不要改。
**不要把它铺到更多端点上。** 试过一次41 个),收益是 41 次 safeParse 加一条
没人读的 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

View File

@@ -1,98 +1,86 @@
import {
type AiAnalysisRecord,
type Contest as OjContest,
type ContestAccess,
type ContestList,
type ActivityRankItem,
type FormatCodeResponse,
type Metrics,
type TutorialSummary,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
type UserRank,
type ProblemRank,
type CreateSubmissionResponse,
type ProblemAuthor,
type ProblemListItem,
type YearlyAc,
type ProblemList,
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,
problemListSchema,
problemListItemSchema,
submissionDetailSchema,
submissionListSchema,
submissionStatisticsSchema,
submissionStatisticsItemsSchema,
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,
type FlowchartStatistics,
type SubmissionStatistics,
type SubmissionStatisticsItems,
} from "@oj2/contract"
import api from "utils/api"
import { contract } from "utils/contract"
import { filterResult } from "oj/transforms"
import type {
Announcement,
AnnouncementListItem,
ContestRank,
Profile,
Message,
SubmissionListItem,
Exercise,
Problem,
ReactionKey,
ReactionState,
Submission,
SubmissionListPayload,
SubmitCodePayload,
OnlineCount,
WebsiteConfig,
Tutorial,
TutorialProgress,
} from "utils/types"
/**
* 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的
* 键名映射,抄漏一个字段就是静默 undefined。
*
* 走 `contract()` 而不是裸 `parse()`这里原来是
* `problemDetailSchema.parse(value) as Problem` —— `as` 把校验结果又断言回本地
* 类型,等于校验白做。契约现在把 `languages` / `template` 都收进了联合,
* `Problem` 不再需要额外窄化,`as` 也就没有存在的理由了。
* 走 `contract()` 而不是裸 `parse()`:原来是 `problemDetailSchema.parse(v) as Problem`
* `as` 把校验结果又断言回去、等于没校验,而 `parse` 抛错会让整个题目页白屏。
* 现在形状不符时记一条控制台分歧再放行原始数据。
*/
function detailProblem(value: unknown): Problem {
return contract("GET /problems/:id", problemDetailSchema, value)
}
export async function getWebsiteConfig() {
const endpoint = "site"
return contract(
"GET /site",
websiteConfigSchema,
await api.get<unknown>(endpoint),
)
export function getWebsiteConfig() {
return api.get<WebsiteConfig>("site")
}
/** 当前在线人数。只有聚合数字,「谁在线」在榜单接口里、且只对老师下发 */
export async function getOnlineCount() {
const endpoint = "site/online"
return contract(
"GET /site/online",
onlineCountSchema,
await api.get<unknown>(endpoint),
)
export function getOnlineCount() {
return api.get<OnlineCount>("site/online")
}
export async function getProblemList(
@@ -100,14 +88,9 @@ export async function getProblemList(
limit = 10,
searchParams: Record<string, unknown> = {},
) {
const endpoint = "problems"
const res = contract(
"GET /problems",
problemListSchema,
await api.get<unknown>(endpoint, {
params: { paging: true, offset, limit, ...searchParams },
}),
)
const res = await api.get<ProblemList>("problems", {
params: { paging: true, offset, limit, ...searchParams },
})
return {
results: res.results.map(filterResult),
total: res.total,
@@ -132,13 +115,11 @@ export function getProblemBeatRate(problemID: number) {
return api.get<string>(`problems/${problemID}/beat-count`)
}
export async function getSubmission(id: string) {
const endpoint = `submissions/${encodeURIComponent(id)}`
return contract(
"GET /submissions/:id",
submissionDetailSchema,
await api.get<unknown>(endpoint),
export async function getSubmission(id: string): Promise<Submission> {
const response = await api.get<unknown>(
`submissions/${encodeURIComponent(id)}`,
)
return contract("GET /submissions/:id", submissionDetailSchema, response)
}
export function submitCode(data: SubmitCodePayload) {
@@ -162,36 +143,16 @@ export function getSubmissions(params: Partial<SubmissionListPayload>) {
const endpoint = params.contestId
? `contests/${encodeURIComponent(params.contestId)}/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 },
})
}
/**
* 提交列表。后端在 `submissionListItemSchema.parse` 上真的会抛 —— 它逐个列表项
* 过 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 getRankOfProblem(problemId: string) {
return api.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
}
export function getTodaySubmissionCount(language?: string) {
@@ -208,56 +169,38 @@ export function adminRejudge(id: string) {
* 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人,
* 和上面那个按班级模糊匹配的不是一回事。
*/
export async function getSubmissionStatisticsItems(
export function getSubmissionStatisticsItems(
duration: { start?: string; end: string },
username: string,
problemID?: string,
) {
const endpoint = "submissions/statistics/items"
return contract(
"GET /submissions/statistics/items",
submissionStatisticsItemsSchema,
await api.get<unknown>(endpoint, {
params: { ...duration, problemId: problemID, username },
}),
)
return api.get<SubmissionStatisticsItems>("submissions/statistics/items", {
params: { ...duration, problemId: problemID, username },
})
}
export async function getSubmissionStatistics(
export function getSubmissionStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
const endpoint = "submissions/statistics"
return contract(
"GET /submissions/statistics",
submissionStatisticsSchema,
await api.get<unknown>(endpoint, {
params: { ...duration, problemId: problemID, username },
}),
)
return api.get<SubmissionStatistics>("submissions/statistics", {
params: { ...duration, problemId: problemID, username },
})
}
/**
* 全服榜单。上限100 名)由服务端定,调用方只管翻页 ——
* 「全服 Top10」就是这个榜的第一页取 limit=10 即可,不需要另一个上限参数。
*/
export async function getRank(offset: number, limit: number) {
const endpoint = "rankings/users"
return contract(
"GET /rankings/users",
userRankSchema,
await api.get<unknown>(endpoint, { params: { offset, limit } }),
)
export function getRank(offset: number, limit: number) {
return api.get<UserRank>("rankings/users", { params: { offset, limit } })
}
export async function getActivityRank(start: string) {
const endpoint = "rankings/activity"
return contract(
"GET /rankings/activity",
activityRankItemSchema.array(),
await api.get<unknown>(endpoint, { params: { start } }),
)
export function getActivityRank(start: string) {
return api.get<ActivityRankItem[]>("rankings/activity", {
params: { start },
})
}
export function getClassRank(grade?: number | null) {
@@ -286,37 +229,22 @@ export function getClassPK(
})
}
export async function getContestList(query: {
export function getContestList(query: {
offset: number
limit: number
keyword: string
status: string
tag: string
}) {
const endpoint = "contests"
return contract(
"GET /contests",
contestListSchema,
await api.get<unknown>(endpoint, { params: query }),
)
return api.get<ContestList>("contests", { params: query })
}
export async function getContest(id: string) {
const endpoint = `contests/${encodeURIComponent(id)}`
return contract(
"GET /contests/:id",
contestSchema,
await api.get<unknown>(endpoint),
)
export function getContest(id: string) {
return api.get<OjContest>(`contests/${encodeURIComponent(id)}`)
}
export async function getContestAccess(id: string) {
const endpoint = `contests/${encodeURIComponent(id)}/access`
return contract(
"GET /contests/:id/access",
contestAccessSchema,
await api.get<unknown>(endpoint),
)
export function getContestAccess(id: string) {
return api.get<ContestAccess>(`contests/${encodeURIComponent(id)}/access`)
}
// 注意和 GET /access 不一样:这个返回裸 true密码错是 403 走 catch
@@ -330,29 +258,21 @@ export function checkContestPassword(contestID: string, password: string) {
}
export async function getContestProblems(contestID: string) {
const endpoint = `contests/${encodeURIComponent(contestID)}/problems`
// 用 problemListItemSchema.array(),不是契约的 contestProblemsSchema ——
// 后者是 `array(union([列表项, 详情]))`,联合类型会让 filterResult 的类型收窄
// 落到详情分支上,而且学生侧这条接口只下发列表项。
const res = contract(
"GET /contests/:id/problems",
problemListItemSchema.array(),
await api.get<unknown>(endpoint),
const res = await api.get<ProblemListItem[]>(
`contests/${encodeURIComponent(contestID)}/problems`,
)
return res.map(filterResult)
}
export async function getContestRank(
export function getContestRank(
contestID: string,
query: { limit: number; offset: number },
) {
// submissionInfo 在契约里是 Record<string, unknown>JSONB 原文),
// 前端在这里收窄成 SubmissionInfo见 utils/types 的 ContestRank
const endpoint = `contests/${encodeURIComponent(contestID)}/rank`
return contract(
"GET /contests/:id/rank",
contestRankSchema,
await api.get<unknown>(endpoint, { params: query }),
return api.get<{ results: ContestRank[]; total: number }>(
`contests/${encodeURIComponent(contestID)}/rank`,
{ params: query },
)
}
@@ -368,31 +288,21 @@ export function updateProfile(data: { realName: string; mood: string }) {
return api.put<Profile>("me/profile", data)
}
export async function getAnnouncementList(offset = 0, limit = 10) {
const endpoint = "announcements"
return contract(
"GET /announcements",
announcementListSchema,
await api.get<unknown>(endpoint, { params: { limit, offset } }),
)
export function getAnnouncementList(offset = 0, limit = 10) {
return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
params: { limit, offset },
})
}
export async function getAnnouncement(id: number) {
const endpoint = `announcements/${id}`
return contract(
"GET /announcements/:id",
announcementSchema,
await api.get<unknown>(endpoint),
)
export function getAnnouncement(id: number) {
return api.get<Announcement>(`announcements/${id}`)
}
export async function getMessageList(offset = 0, limit = 10) {
const endpoint = "messages"
return contract(
"GET /messages",
messageListSchema,
await api.get<unknown>(endpoint, { params: { limit, offset } }),
)
export function getMessageList(offset = 0, limit = 10) {
// language 的收窄同 getSubmissions见那里的说明
return api.get<{ results: Message[]; total: number }>("messages", {
params: { limit, offset },
})
}
export function getReaction(problemID: number) {
@@ -403,125 +313,71 @@ export function setReaction(problemID: number, type: ReactionKey) {
return api.post<ReactionState>(`problems/${problemID}/reaction`, { type })
}
export async function getMetrics(userid: number) {
const endpoint = `users/${userid}/metrics`
return contract(
"GET /users/:id/metrics",
metricsSchema,
await api.get<unknown>(endpoint),
)
export function getMetrics(userid: number) {
return api.get<Metrics>(`users/${userid}/metrics`)
}
export async function getTutorial(id: number) {
const endpoint = `tutorials/${id}`
return contract(
"GET /tutorials/:id",
tutorialSchema,
await api.get<unknown>(endpoint),
)
export function getTutorial(id: number) {
return api.get<Tutorial>(`tutorials/${id}`)
}
export async function getTutorials(type: "python" | "c") {
const endpoint = "tutorials"
return contract(
"GET /tutorials",
tutorialSummarySchema.array(),
await api.get<unknown>(endpoint, { params: { type } }),
)
export function getTutorials(type: "python" | "c") {
return api.get<TutorialSummary[]>("tutorials", { params: { type } })
}
export async function getAIDetailData(
start: string,
end: string,
username?: string,
) {
const endpoint = "ai/detail"
return contract(
"GET /ai/detail",
aiDetailSchema,
await api.get<unknown>(endpoint, { params: { start, end, username } }),
)
export function getAIDetailData(start: string, end: string, username?: string) {
return api.get<AiDetail>("ai/detail", { params: { start, end, username } })
}
export async function getAISolved(
export function getAISolved(
start: string,
end: string,
offset: number,
limit: number,
username?: string,
) {
const endpoint = "ai/solved"
return contract(
"GET /ai/solved",
solvedListSchema,
await api.get<unknown>(endpoint, {
params: { start, end, offset, limit, username },
}),
)
return api.get<SolvedList>("ai/solved", {
params: { start, end, offset, limit, username },
})
}
export async function getAIDurationData(
export function getAIDurationData(
end: string,
duration: string,
username?: string,
) {
const endpoint = "ai/duration"
return contract(
"GET /ai/duration",
durationDataSchema.array(),
await api.get<unknown>(endpoint, { params: { end, duration, username } }),
)
return api.get<DurationData[]>("ai/duration", {
params: { end, duration, username },
})
}
export async function getAIHeatmapData(username?: string) {
const endpoint = "ai/heatmap"
return contract(
"GET /ai/heatmap",
heatmapItemSchema.array(),
await api.get<unknown>(endpoint, {
params: username ? { username } : {},
}),
)
export function getAIHeatmapData(username?: string) {
return api.get<HeatmapItem[]>("ai/heatmap", {
params: username ? { username } : {},
})
}
export async function getAILoginSummary() {
const endpoint = "ai/login-summary"
return contract(
"GET /ai/login-summary",
loginSummarySchema,
await api.get<unknown>(endpoint),
)
export function getAILoginSummary() {
return api.get<LoginSummary>("ai/login-summary")
}
export async function getAIPinnedReport() {
const endpoint = "ai/pinned"
return contract(
"GET /ai/pinned",
aiAnalysisRecordSchema.nullable(),
await api.get<unknown>(endpoint),
)
export function getAIPinnedReport() {
return api.get<AiAnalysisRecord | null>("ai/pinned")
}
// ==================== 相似题目推荐 ====================
export async function getSimilarProblems(problemId: string) {
const endpoint = `problems/${encodeURIComponent(problemId)}/similar`
const res = contract(
"GET /problems/:id/similar",
problemListItemSchema.array(),
await api.get<unknown>(endpoint),
)
return res.map(filterResult)
export function getSimilarProblems(problemId: string) {
return api
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => response.map(filterResult))
}
export type { YearlyAc as YearlyACData } from "@oj2/contract"
export async function getProblemYearlyAC(problemId: string) {
const endpoint = `problems/${encodeURIComponent(problemId)}/yearly-ac`
return contract(
"GET /problems/:id/yearly-ac",
yearlyAcSchema.array(),
await api.get<unknown>(endpoint),
export function getProblemYearlyAC(problemId: string) {
return api.get<YearlyAc[]>(
`problems/${encodeURIComponent(problemId)}/yearly-ac`,
)
}
@@ -535,16 +391,11 @@ export function submitFlowchart(data: {
return api.post<CreateFlowchartResponse>("flowcharts", data)
}
export async function getFlowchartSubmission(id: string) {
const endpoint = `flowcharts/${encodeURIComponent(id)}`
return contract(
"GET /flowcharts/:id",
flowchartSubmissionSchema,
await api.get<unknown>(endpoint),
)
export function getFlowchartSubmission(id: string) {
return api.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
}
export async function getFlowchartSubmissions(params: {
export function getFlowchartSubmissions(params: {
username?: string
problemId?: string
myself?: string
@@ -553,27 +404,17 @@ export async function getFlowchartSubmissions(params: {
today?: string
grade?: string
}) {
const endpoint = "flowcharts"
return contract(
"GET /flowcharts",
flowchartListSchema,
await api.get<unknown>(endpoint, { params }),
)
return api.get<FlowchartList>("flowcharts", { params })
}
export async function getFlowchartStatistics(
export function getFlowchartStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
const endpoint = "flowcharts/statistics"
return contract(
"GET /flowcharts/statistics",
flowchartStatisticsSchema,
await api.get<unknown>(endpoint, {
params: { ...duration, problemId: problemID, username },
}),
)
return api.get<FlowchartStatistics>("flowcharts/statistics", {
params: { ...duration, problemId: problemID, username },
})
}
export function retryFlowchartSubmission(submissionId: string) {
@@ -582,59 +423,36 @@ export function retryFlowchartSubmission(submissionId: string) {
)
}
export async function getCurrentProblemFlowchartSubmission(problemId: number) {
const endpoint = `problems/${problemId}/flowchart/current`
return contract(
"GET /problems/:id/flowchart/current",
flowchartCurrentSchema,
await api.get<unknown>(endpoint),
)
export function getCurrentProblemFlowchartSubmission(problemId: number) {
return api.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
}
export async function getFlowchartSubmissionDetail(problemId: number, page = 0) {
const endpoint = `problems/${problemId}/flowchart/history`
return contract(
"GET /problems/:id/flowchart/history",
flowchartDetailSchema,
await api.get<unknown>(endpoint, { params: { page } }),
)
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
return api.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
params: { page },
})
}
// ==================== 题单相关API ====================
export async function getProblemSetList(
export function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
const endpoint = "problem-sets"
return contract(
"GET /problem-sets",
problemSetListSchema,
await api.get<unknown>(endpoint, {
params: { offset, limit, keyword, difficulty, status },
}),
)
return api.get<ProblemSetList>("problem-sets", {
params: { offset, limit, keyword, difficulty, status },
})
}
export async function getProblemSetDetail(id: number) {
const endpoint = `problem-sets/${id}`
return contract(
"GET /problem-sets/:id",
problemSetSchema,
await api.get<unknown>(endpoint),
)
export function getProblemSetDetail(id: number) {
return api.get<ProblemSet>(`problem-sets/${id}`)
}
export async function getProblemSetProblems(problemSetId: number) {
const endpoint = `problem-sets/${problemSetId}/problems`
return contract(
"GET /problem-sets/:id/problems",
problemSetProblemSchema.array(),
await api.get<unknown>(endpoint),
)
export function getProblemSetProblems(problemSetId: number) {
return api.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
}
export function joinProblemSet(problemSetId: number) {
@@ -653,25 +471,17 @@ export function updateProblemSetProgress(
})
}
export async function getUserBadges(username?: string) {
const endpoint = `users/${encodeURIComponent(username ?? "me")}/badges`
return contract(
"GET /users/:username/badges",
userBadgeSchema.array(),
await api.get<unknown>(endpoint),
export function getUserBadges(username?: string) {
return api.get<UserBadge[]>(
`users/${encodeURIComponent(username ?? "me")}/badges`,
)
}
export async function getProblemSetBadges(problemSetId: number) {
const endpoint = `problem-sets/${problemSetId}/badges`
return contract(
"GET /problem-sets/:id/badges",
problemSetBadgeSchema.array(),
await api.get<unknown>(endpoint),
)
export function getProblemSetBadges(problemSetId: number) {
return api.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
}
export async function getProblemSetUserProgress(
export function getProblemSetUserProgress(
problemSetId: number,
params?: {
limit?: number
@@ -680,42 +490,14 @@ export async function getProblemSetUserProgress(
completionStatus?: "" | "completed" | "in_progress" | "not_started"
},
) {
const endpoint = `problem-sets/${problemSetId}/user-progress`
return contract(
"GET /problem-sets/:id/user-progress",
problemSetProgressListSchema,
await api.get<unknown>(endpoint, { params }),
return api.get<ProblemSetProgressList>(
`problem-sets/${problemSetId}/user-progress`,
{ params },
)
}
export async function getExercises(
tutorialId: number,
): 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[]
export function getExercises(tutorialId: number): Promise<Exercise[]> {
return api.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
}
/**
@@ -741,13 +523,8 @@ export function reportExerciseAttempt(
}).catch(() => undefined)
}
export async function getLearnProgress(type: "python" | "c") {
const endpoint = "learn/progress"
return contract(
"GET /learn/progress",
tutorialProgressSchema.array(),
await api.get<unknown>(endpoint, { params: { type } }),
)
export function getLearnProgress(type: "python" | "c") {
return api.get<TutorialProgress[]>("learn/progress", { params: { type } })
}
/**

View File

@@ -1,116 +1,25 @@
import type { z } from "zod"
/**
* 契约的运行时闸门。
* 契约的运行时闸门。**只挂在三条路径上**:题目详情、提交详情、用户资料
* `shared/api.ts` 的 getProfile—— 也就是原本就写了 `.parse()` 的那三处。
*
* ## 为什么要有这一层
* ## 为什么只有三处
*
* `@oj2/contract` 的收益只有一半是类型:`z.infer` 给出编译期的形状,但**编译期
* 管不了后端实际下发了什么**。改后端字段、drizzle 改名、序列化时漏一个键,
* TypeScript 一概看不见,页面上表现为某个 `undefined` 静默渲染成空白。
* 契约真正的价值在于同一份 schema 能在运行时把这种分歧当场抓出来
* 前后端同仓、同一次编译、共享同一份 schema「后端改字段前端不知道」这种漂移
* `tsc` 已经抓了,改了对不上当场编译不过。这里能多抓到的只有一种:**schema 与
* 库里 JSONB 原文不符**,而那不是契约漂移,是 schema 写错了 —— 而且它的真相在
* 写入侧,不是在这里
*
* 原来只有三处调用 `.parse()`,而且**后面都紧跟一个 `as`** 把它重新断言回本地
* 类型(`problemDetailSchema.parse(v) as Problem`)—— 校验结果被丢弃,等于没校验
* 曾经把它铺到 41 个端点上,收益是 41 次 safeParse 加一个没人读的 console.error
* 判题产物那次收紧还因此让 7.4% 的提交静默丢了测试点明细。所以退回三处
*
* ## 失败策略:记日志 + 放行原始数据
* ## 留着这三处的理由是「别抛错」,不是「校验」
*
* **不抛错。** 这是面向学生的生产站点,契约分歧的代价不该是白屏 —— 少了哪个
* 字段,页面大体上照样能用,只是那处空着。所以解析失败时:
*
* 1. `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)
* ```
*
* 端点字符串是手写的,刻意不让调用方漏掉 —— 它只用于日志和去重,写错不影响正确性。
* 这三条原来是 `schema.parse(v) as T` —— `as` 把校验结果又断言回本地类型,等于
* 没校验;而 `parse` 抛出的 ZodError 会打断整个 async 函数,一失败就是白屏。
* 面向学生的生产站点,少一个字段页面照样能用,整页崩掉不行。所以这里:
* 记一条带端点和字段路径的 `console.error`,然后**返回原始数据**。
*/
export function contract<T extends z.ZodType>(
endpoint: string,
@@ -120,13 +29,19 @@ export function contract<T extends z.ZodType>(
const result = schema.safeParse(value)
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(
`[契约] ${describe(result.error, endpoint)}\n` +
" 已放行原始数据(页面照常渲染)。全部历史分歧见 window.__OJ2_CONTRACT_DRIFT__。\n" +
" 契约在 packages/contract/src/,后端对不上的字段在 apps/api/src/routes/。",
`[契约] ${endpoint} 的响应不符合契约 —— ${issues}${more}\n` +
" 已放行原始数据(页面照常渲染)。契约在 packages/contract/src/。",
)
// 放行原始数据。断言在这里是**有意的**:形状确实可能不符,但调用方需要的是
// 「能渲染的东西」而不是一个异常分歧已经通过上面两条记录暴露出来了。
// 「能渲染的东西」而不是一个异常分歧已经通过上面那条日志暴露出来了。
return value as z.infer<T>
}

View File

@@ -1,5 +1,5 @@
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 { User } from "./types"
import { USER_TYPE } from "./constants"
@@ -23,18 +23,18 @@ function calculateACRate(acCount: number, totalCount: number): string {
/**
* 从 `submission.info` 里取测试点明细,取不到就返回空数组。
*
* 契约里 `info` 是**联合类型**:判题机写的完整形状,或者空对象 —— 后者是后端对
* 非管理员下发的权限投影(`routes/submission.ts` 的 `full ? row.submission.info : {}`
* 也是待判提交的初值。所以调用方不能直接 `.data`,得先在这里收口。
* `info` 在契约里是 `z.unknown()`(判题产物不在读出侧校验,见契约那边的说明),
* 它有三种真实取值:判题机写的完整形状、**空对象**(后端对非管理员下发的
* `info: {}`,也是待判提交的初值)、以及 `data: null`(编译失败等没有逐测试点
* 结果的情形)。三种「没有」在这里一并归成空数组。
*
* 另外 `data` 本身也可能是 null生产库 124191 条提交里有 12048 条是编译失败之类
* 没有逐测试点结果的情形。两种「没有」在这里一并归成空数组
* **这是判题产物在前端唯一需要的运行时判断** —— 有没有 data 数组。数组项的形状
* 直接信判题机(`JudgeCaseResult`
*/
export function submissionCaseResults(
info: SubmissionDetail["info"] | null | undefined,
): JudgeCaseResult[] {
if (!info || !("data" in info) || !info.data) return []
return info.data
export function submissionCaseResults(info: unknown): JudgeCaseResult[] {
if (!info || typeof info !== "object" || !("data" in info)) return []
const data = (info as JudgeInfo).data
return Array.isArray(data) ? data : []
}
export function getACRate(acCount: number, totalCount: number): string {

View File

@@ -265,9 +265,10 @@ export type {
export type { CreateFlowchartRequest as SubmitFlowchartPayload } from "@oj2/contract"
/**
* 提交详情。**info / statisticInfo / language 三处窄化都搬进契约了**
* `judgeInfoSchema` / `statisticInfoSchema` / `problemLanguageSchema`
* 依据是生产库 124191 条提交的实测分布,见 packages/contract/src/submission.ts。
* 提交详情。`statisticInfo` / `language` 的窄化在契约里
* `statisticInfoSchema` / `problemLanguageSchema``info` 在契约里是
* `z.unknown()`,形状由 `JudgeInfo` / `JudgeCaseResult` 两个 TS 类型描述,
* 取值统一走 `utils/functions` 的 `submissionCaseResults()`(原因见契约那边)。
*
* 前端仍要保留一处:`result` 多一个 9 —— 点了提交、还没拿到结果时前端本地先填的
* 伪状态,后端永远不会下发,见 constants.ts 的 SubmissionStatus.submitting。

View File

@@ -6,6 +6,7 @@ import { achievementRaritySchema } from "./achievement"
import { rankProfileSchema } from "./account"
import { paginatedSchema, sampleUserSchema } from "./common"
import { reactionKeySchema } from "./content"
import { problemLanguageSchema } from "./language"
import {
astRulesSchema,
problemDifficultySchema,
@@ -671,7 +672,12 @@ export const adminProblemSchema = z.object({
languages: z.array(z.string()),
template: z.record(z.string(), 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(),
memoryLimit: z.number().int(),
visible: z.boolean(),
@@ -718,7 +724,9 @@ export const createProblemRequestSchema = z.object({
testCaseScore: z.array(problemTestCaseScoreSchema),
timeLimit: z.number().int().min(1).max(1000 * 60),
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()),
visible: z.boolean(),
difficulty: z.enum(["Low", "Mid", "High"]),

View File

@@ -118,30 +118,16 @@ export const exerciseAttemptRequestSchema = z.object({
})
/**
* 练一练的内容,按题型分派。
* 题型 → 内容形状。**只在写入侧校验**`apps/api/src/services/exercise.ts` 的
* `exerciseDataError`),读出侧不校验。
*
* 形状按**生产库 151 道练习题实测**得出,七种题型的键集逐个吻合、没有越界数据:
* mcq 46 / fill 37 / sort 25 / predict 24 / debug 11 / match 6 / group 2。
* 这张表曾经挂在 `exerciseSchema` 的 superRefine 上,于是学生端那条
* `GET /tutorials/:id/exercises``routes/content.ts` 里硬 parse变成了一道
* 读闸:一行 data 对不上,整条练习列表 500 —— 坏的不是那一道,是整页。而写入侧
* 当时并不检查 `question`,两边严紧度不一致,脏数据进得来、出不去。
*
* 为什么值得从 `Record<string, unknown>` 收紧:这七种题型各自被一个组件渲染
* 它们直接读 `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在类型上仍然要自己收窄一次但**运行时**
* 走的就是这张表。
* JSONB 的形状真相在写入侧,就在写入侧卡住:那里能给老师一句中文原因
* 也不会让历史数据把学生端打不开。
*/
export const exerciseDataByType: Record<string, z.ZodType> = {
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` 的内容对不上时也算不通过,这正是要拦的情况:
* `type: "mcq"` 配一份 `{question, code}` 会在渲染 mcq 组件时炸在 `data.options` 上。
* 练一练。`data` `Record<string, unknown>`**读出侧刻意不按题型收紧** ——
* 这个 schema 后端也在 parse`routes/content.ts`),收紧它等于给学生端加一道
* 会 500 的闸。七种题型各自的形状见上面的 `exerciseDataByType`,在写入时卡。
*/
export const exerciseSchema = z
.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
data: z.record(z.string(), z.unknown()),
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 const exerciseSchema = z.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
data: z.record(z.string(), z.unknown()),
order: z.number().int(),
})
export type Message = z.infer<typeof messageSchema>
export type MessageList = z.infer<typeof messageListSchema>

View File

@@ -33,8 +33,9 @@ export const contestProblemsSchema = z.array(z.union([problemListItemSchema, pro
* `acm_contest_rank.submission_info` 的 JSONB 原文。
*
* 键名是**判题链路写进去的 snake_case**(历史比赛的榜单行也是这个形状),
* 不要跟着响应字段一起改成 camelCase。字段全部可选:只有真正提交过的题目
* 才会出现,`checked` 是前端本地标「已看」时补的
* 不要跟着响应字段一起改成 camelCase。只有真正提交过的题目才会有自己的键,
* 键一旦存在,前四个字段判题链路一定会写全;`checked` 是前端本地标「已看」时补的
* 所以只有它可选。生产库 2401 行榜单实测全部符合。
*
* 原来契约这里是 `z.record(z.string(), z.unknown())`,于是前端不得不
* 自己再声明一份 `SubmissionInfo` 去覆盖它utils/types 的 ContestRank

View File

@@ -19,56 +19,51 @@ export const judgeStatusSchema = z.union([
])
/**
* 判题机原始输出(`submission.info` 的 JSONB 原文)。
* 判题机原始输出(`submission.info` 的 JSONB 原文)。**只是类型,不作运行时校验。**
*
* 形状按**生产库 124191 条提交实测**得出,不是照着前端那份额外手抄的:
* 这里曾经是一组 zod schema按生产库实测的键集收紧过结果是 124192 条提交里有
* 9163 条RE 8480/8480、TLE 338/338、MLE 1/1 全中)被判成不符:沙箱在非正常退出
* 的测试点上写 `output_md5: null`,而 SQL 判题(`judge/sql/engine.ts` 的 CaseResult
* 压根没有 `output` 这个键、`error_message` 通过时是 null。收紧当时只对了键集合
* 没对空值。
*
* - `err` 实测 124191 条**全是 null**,从来没见过字符串 —— 但契约仍留 `string`
* 因为判题机层面它是有意义的通道,收紧成 `z.null()` 会在它第一次真的报错时炸
* - `data` 有 **12048 条是 null**(编译失败等没有逐测试点结果的情形),
* 所以它必须 nullable。前端原来手抄的 `Info` 把 data 写成了非空数组,
* 这 12048 条在类型上根本不成立,只是没有一处会去读它才没炸。
* - 数组项比前端手抄的多三处SQL 判题多带 `error_message`201 个测试点)、
* 部分带 `score`10 个)。所以这里的字段一律可选,不用 strictObject。
* 更糟的是失败方式:`info` 当时是 `union([完整形状, z.object({})])`,对不上的一律
* 落进第二支被剥成 `{}` 且 parse 成功 —— 管理员的测试点表格**静默消失**
*
* 结论JSONB 的形状真相在**写入侧**(判题机、`judge/run.ts`),在读出侧再校验一遍
* 只会在两边分叉时丢数据。所以 `info` 回到 `z.unknown()`,形状以下面的 TS 类型
* 描述,取值处由 `submissionCaseResults()` 做一次真正需要的运行时判断(有没有
* data 数组)。**改这里的字段时对着判题机改,不要对着采样出来的键集改。**
*
* 键名是**判题沙箱定的 snake_case**,不要跟着响应字段一起改。
*/
export const judgeCaseResultSchema = z.object({
error: z.number(),
memory: z.number(),
output: z.string().nullable(),
result: judgeStatusSchema,
signal: z.number(),
cpu_time: z.number(),
exit_code: z.number(),
real_time: z.number(),
test_case: z.string(),
output_md5: z.string(),
/** SQL 判题会带上中文原因,沙箱判题没有这个键 */
error_message: z.string().optional(),
score: z.number().optional(),
})
export const judgeInfoSchema = z.object({
err: z.string().nullable(),
data: z.array(judgeCaseResultSchema).nullable(),
})
export interface JudgeCaseResult {
error: number
memory: number
/** SQL 判题没有这个键 */
output?: string | null
result: JudgeStatus
signal: number
cpu_time: number
exit_code: number
real_time: number
test_case: string
/** 非正常退出的测试点上是 null */
output_md5: string | null
/** SQL 判题会带上中文原因(通过的测试点是 null沙箱判题没有这个键 */
error_message?: string | null
score?: number
}
/**
* `info` 允许的两种取值,**不能只写成完整形状**
*
* 1. 完整形状:判题机写的 JSONB 原文;
* 2. **空对象**:后端对非管理员用 `info: {}` 下发的占位(`routes/submission.ts:841`
* 的 `full ? row.submission.info : {}`),同一个空对象也是插入待判提交时的初值。
*
* 第 2 种是真实存在的合法取值,收紧成只认完整形状会让**每一条非管理员看的提交详情
* 直接 500**`submissionDetailSchema.parse` 在路由里抛,被 onError 兜成 internal-error
* 这不是假想:收紧当天就在本地实测复现了。
*
* 换句话说,空对象表达的是「这条响应对你不含 info」一个**权限投影**
* 而不是「字段缺失」—— 契约要如实描述它。
* `info` 的完整形状。实际取值还有第三种:**空对象** —— 后端对非管理员下发
* `info: {}``routes/submission.ts` 的 `full ? row.submission.info : {}`
* 也是插入待判提交时的初值。所以调用方不能直接 `.data`。
*/
export const submissionInfoSchema = z.union([judgeInfoSchema, z.object({})])
export interface JudgeInfo {
err: string | null
data: JudgeCaseResult[] | null
}
/**
* 判题产出的统计(`submission.statistic_info` 的 JSONB 原文)。
@@ -76,11 +71,11 @@ export const submissionInfoSchema = z.union([judgeInfoSchema, z.object({})])
* 五个键全部可选依据是生产库实测的出现次数time_cost / memory_cost 各 112097、
* score 3993、err_info 3153、ast_results 56另有 27 条空对象。
*
* **不能用严格对象。** 有 8916 条历史记录里的 JSONB 原文内嵌了带转义的 shell
* 输出、本身不是合法 JSON后端 `objectValue()` 会把它兜成 `{ value: "<原串>" }`
* 再下发 —— 严格 schema 会把这 8916 条判成契约分歧,而它们其实是正常的失败记录
* 用 `looseObject`:所有键可选 + 不剥未知键 = **对任何对象都不会失败、也不丢字段**
* 它在这里的作用是给前端一个能读 `err_info` 的类型,而不是一道闸门。判题产物的
* 闸门在写入侧,理由见上面 `JudgeCaseResult`
*/
export const statisticInfoSchema = z.object({
export const statisticInfoSchema = z.looseObject({
score: z.number().optional(),
/** 判题机写进 statistic_info 的错误文本,教师面板的「最近一条错在哪」也读它 */
err_info: z.string().optional(),
@@ -129,8 +124,8 @@ export const submissionDetailSchema = z.object({
username: z.string(),
code: z.string(),
result: judgeStatusSchema,
/** 未判完或非管理员看时为 `{}`,见 submissionInfoSchema 的注释 */
info: submissionInfoSchema,
/** 判题机原文;未判完或非管理员看时为 `{}`,见 JudgeInfo 的注释 */
info: z.unknown(),
language: problemLanguageSchema,
statisticInfo: statisticInfoSchema,
contestId: z.number().int().nullable(),
@@ -154,7 +149,10 @@ export const submissionDetailSchema = z.object({
* 将来有人「顺手」把空值改成真值就不会变成泄露,因为这里压根没有这些字段。
*/
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 是
// `SlugRelatedField(slug_field="_id")`,即**展示用题号**而非数字主键。
// 站内信页面拿它拼 `/problem/<题号>` 链接,给数字 id 会拼出打不开的地址。
@@ -314,8 +312,6 @@ export const formatCodeRequestSchema = z.object({
export const formatCodeResponseSchema = z.object({ code: z.string() })
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 CreateSubmissionRequest = z.infer<
typeof createSubmissionRequestSchema