Compare commits
3 Commits
684f2d29a5
...
127718dc06
| Author | SHA1 | Date | |
|---|---|---|---|
| 127718dc06 | |||
| b9a80d62bc | |||
| 7d15e6aeaa |
78
CLAUDE.md
78
CLAUDE.md
@@ -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 → 中文名)决定后台下拉能选什么,
|
||||
|
||||
@@ -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 id(problemset*、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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 掉了 content,Zod 会 strip 掉多出来的键,这里不必手工再挑一遍
|
||||
results: rows.map(serialize),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
} satisfies AdminAnnouncementList)
|
||||
})
|
||||
|
||||
adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[])
|
||||
})
|
||||
|
||||
@@ -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", "生成失败,请稍后再试")
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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", "生成失败,请稍后再试")
|
||||
|
||||
@@ -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/code,Zod 会 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= 查询参数,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 不必再 safeParse:reaction.type 列上挂着 $type<ReactionKey>()
|
||||
const counts = Object.fromEntries(
|
||||
reactionKeySchema.options.map((key) => [key, 0]),
|
||||
) as ReactionCounts
|
||||
for (const row of rows) counts[row.type] = row.value
|
||||
return { mine: mine.type, counts } satisfies ReactionState
|
||||
}
|
||||
|
||||
contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
@@ -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)))
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 problem:problem 只有按题号筛选时才出现在 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 problem:problem 只有按题号筛选时才出现在 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) => {
|
||||
|
||||
@@ -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[])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -13,15 +13,21 @@ Vite(Rolldown 内核)、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 server(5173),后端得另外起
|
||||
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
|
||||
|
||||
|
||||
@@ -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/exercises(type=${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 } })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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 的那阵子,凡是收到过站内信的人
|
||||
// 打开消息页都是 500(parse 抛在缺失的 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
|
||||
|
||||
Reference in New Issue
Block a user