refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
Some checks failed
Deploy / deploy (push) Has been cancelled

## 出参改 satisfies

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

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

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

## 校验责任挪回写入侧

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

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

## 文档

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

## 验证

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 05:45:15 -06:00
parent b9a80d62bc
commit 127718dc06
36 changed files with 562 additions and 524 deletions

View File

@@ -106,26 +106,46 @@ dev 直接起不来。
这些整数是**落库的值**12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是 这些整数是**落库的值**12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是
这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。 这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。
### 契约收紧要挑地方:闸在写入侧,不在读出侧 ### 出参不 `parse`,用 `satisfies`
`packages/contract` 的 schema 前后端共用,而且**后端在读路径上 `parse`** **后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。**
`submissionDetailSchema` / `exerciseSchema` / `contestRankItemSchema` 都是)。 出参是后端自己刚拼出来的字面量TS 已经在编译期校验过;再 `parse` 一遍拿不到任何新
所以收紧一个字段不只是「类型更准」,是给全部历史数据加了一道闸: 信息,唯一可能失败的输入是**库里的历史数据**,而失败的代价是 500。这一层原来有 136 处,
已经全部撤掉,撤的时候当场炸出两个一直存在的线上 500
- 对不上就 500 —— `exerciseSchema` 按题型收紧过,库里一行脏数据能让整条学生练习 - `adminProblemSchema.lastUpdateTime` 写的是 `z.string()`,但 `problem.last_update_time`
列表打不开,坏的不是那一道题 是全库唯一可空的列961 道题里 470 道是 NULL——**后台打开任何一道没编辑过的老题都是 500**
- 更坏的是**不 500**`info` 当时写成 `union([完整形状, z.object({})])`,对不上的 - `embeddedSubmissionSchema``submissionDetailSchema` 继承了 `problemDisplayId` 却没
一律落进空对象那支且 parse 成功,管理员详情页的测试点表格**静默消失**。全量核出来 omit而路由只填了同义的 `problem`——**凡是收到过站内信的人,消息页都打不开**(列表为空
9163/124192 条中招RE 8480/8480、TLE 338/338、MLE 1/1 全中 —— 沙箱在非正常退出 时才碰巧不炸,所以一直没人报)。
的测试点上写 `output_md5: null`,而契约写的是 `z.string()`
所以:**JSONB 原文(`submission.info` / `statistic_info` / `exercise.data`)的形状 两个都是「读出侧校验」自己造出来的故障,不是它拦住的故障。历史上还有两次同类:
真相在写入侧** —— 判题机、`services/exercise.ts``exerciseDataError` —— 闸就设在 `exerciseSchema` 按题型收紧后一行脏数据让整条练习列表 500`info` 写成
那里,读出侧放行。下一条 AST 规则的 `astRulesError()` 是同一个道理的另一个实例。 `union([完整形状, z.object({})])` 后对不上的一律落进空对象那支且 parse **成功**
管理员详情页的测试点表格静默消失(全量核出 9163/124192 条中招RE 8480/8480 全中——
沙箱在非正常退出的测试点上写 `output_md5: null`,而契约写的是 `z.string()`)。
真要收紧读出侧的字段,先拿根目录那份生产备份跑一遍全量,**重点看空值,不是键集合** **闸设在写入侧,一共三处形态:**
上面那次翻车就是键集合全对、空值没看。前端那侧(`utils/contract.ts` 为什么只挂三处)
`apps/web/CLAUDE.md` 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 代码规则有两张表,必须同增同减

View File

@@ -15,6 +15,17 @@
// 详见 CLAUDE.md「改 schema 走 drizzle migration」 // 详见 CLAUDE.md「改 schema 走 drizzle migration」
// * bigint identity 的 maxValue 用字符串,不能写成 JS number 字面量(会丢精度)。 // * bigint identity 的 maxValue 用字符串,不能写成 JS number 字面量(会丢精度)。
// * 索引不写 `.desc()`,生成 SQL 时方向会被丢掉。 // * 索引不写 `.desc()`,生成 SQL 时方向会被丢掉。
// * `.$type<...>()` 的收窄(见下)—— `pull` 只会吐出 text/integer/jsonb重新 pull
// 会把这些断言全抹掉,之后出参的 `satisfies` 会当场编译不过(这是好事,别拿
// `as` 糊过去,把断言补回来)。
//
// 2026-09-10出参不再 `xxxSchema.parse()` 而是 `satisfies`(原来 136 处),
// 收窄的责任因此挪到了列上:枚举型的列和几个形状确定的 JSONB 都挂了 `.$type<>()`。
// `$type` 只是 TS 层的断言、不产生任何 SQL所以它成立与否得靠数据说话 ——
// 下面每一处都拿根目录那份生产备份逐列核过12.4 万条提交的 result 全在 -2..6,10、
// 961 道题的 languages 全是合法数组、10050 条榜单条目形状全对,无一例外)。
// **再给别的列加 $type 之前,照样先核一遍全量数据。** 兑现这些断言的是写入侧的
// `safeParse`,不是读出侧。前因后果见 CLAUDE.md「出参不 `parse`,用 `satisfies`」。
// //
// 关于 10 张表的 bigint idproblemset*、achievement、user_achievement、user_stat、 // 关于 10 张表的 bigint idproblemset*、achievement、user_achievement、user_stat、
// user_badge、ai_analysis这是历史巧合不是设计——这些 app 的 0001_initial 生成时 // user_badge、ai_analysis这是历史巧合不是设计——这些 app 的 0001_initial 生成时
@@ -22,7 +33,26 @@
// problem、contest、submission都是 int4。现存最大 id 一万出头,确实都用不上 bigint // problem、contest、submission都是 int4。现存最大 id 一万出头,确实都用不上 bigint
// 但 2026-08-26 评估后决定**不改**:省 4 字节/行毫无意义ALTER TYPE 要重写整表并拿 // 但 2026-08-26 评估后决定**不改**:省 4 字节/行毫无意义ALTER TYPE 要重写整表并拿
// ACCESS EXCLUSIVE 锁,而且其中 6 处 id 被外键绑着得连坐。别再提这件事了。 // ACCESS EXCLUSIVE 锁,而且其中 6 处 id 被外键绑着得连坐。别再提这件事了。
import type { AdminType, ProblemPermission } from "@oj2/contract" import type {
AchievementOperator,
AchievementRarity,
AdminType,
AstRules,
BadgeConditionType,
ContestSubmissionInfo,
ExerciseType,
FlowchartStatus,
JudgeStatus,
ProblemDifficulty,
ProblemLanguage,
ProblemPermission,
ProblemSetDifficulty,
ProblemSetStatus,
ReactionKey,
SqlConfig,
SqlDisplay,
TutorialType,
} from "@oj2/contract"
import { pgTable, index, foreignKey, primaryKey, bigint, text, jsonb, timestamp, integer, boolean, serial, doublePrecision, varchar, unique, uniqueIndex } from "drizzle-orm/pg-core" import { pgTable, index, foreignKey, primaryKey, bigint, text, jsonb, timestamp, integer, boolean, serial, doublePrecision, varchar, unique, uniqueIndex } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm" import { sql } from "drizzle-orm"
@@ -75,10 +105,10 @@ export const achievement = pgTable("achievement", {
name: text().notNull(), name: text().notNull(),
description: text().notNull(), description: text().notNull(),
icon: text().notNull(), icon: text().notNull(),
rarity: text().notNull(), rarity: text().notNull().$type<AchievementRarity>(),
hidden: boolean().default(false).notNull(), hidden: boolean().default(false).notNull(),
metric: text().notNull(), metric: text().notNull(),
operator: text().notNull(), operator: text().notNull().$type<AchievementOperator>(),
threshold: integer().notNull(), threshold: integer().notNull(),
visible: boolean().default(true).notNull(), visible: boolean().default(true).notNull(),
unlockCount: integer("unlock_count").default(0).notNull(), unlockCount: integer("unlock_count").default(0).notNull(),
@@ -111,7 +141,7 @@ export const flowchartSubmission = pgTable("flowchart_submission", {
id: text().primaryKey().notNull(), id: text().primaryKey().notNull(),
mermaidCode: text("mermaid_code").notNull(), mermaidCode: text("mermaid_code").notNull(),
flowchartData: jsonb("flowchart_data").notNull(), flowchartData: jsonb("flowchart_data").notNull(),
status: integer().notNull(), status: integer().notNull().$type<FlowchartStatus>(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
aiScore: doublePrecision("ai_score"), aiScore: doublePrecision("ai_score"),
aiGrade: varchar("ai_grade", { length: 10 }), aiGrade: varchar("ai_grade", { length: 10 }),
@@ -189,7 +219,7 @@ export const judgeServer = pgTable("judge_server", {
export const exercise = pgTable("exercise", { export const exercise = pgTable("exercise", {
id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "exercise_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }), id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "exercise_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
type: varchar({ length: 16 }).notNull(), type: varchar({ length: 16 }).notNull().$type<ExerciseType>(),
data: jsonb().notNull(), data: jsonb().notNull(),
order: integer().notNull(), order: integer().notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).notNull(), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).notNull(),
@@ -219,8 +249,8 @@ export const problemset = pgTable("problemset", {
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }).notNull(), lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }).notNull(),
visible: boolean().notNull(), visible: boolean().notNull(),
difficulty: text().notNull(), difficulty: text().notNull().$type<ProblemSetDifficulty>(),
status: text().notNull(), status: text().notNull().$type<ProblemSetStatus>(),
createdById: integer("created_by_id").notNull(), createdById: integer("created_by_id").notNull(),
endTime: timestamp("end_time", { withTimezone: true, mode: 'string' }), endTime: timestamp("end_time", { withTimezone: true, mode: 'string' }),
}, (table) => [ }, (table) => [
@@ -324,7 +354,7 @@ export const problemsetSubmission = pgTable("problemset_submission", {
export const reaction = pgTable("reaction", { export const reaction = pgTable("reaction", {
id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "reaction_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }), id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "reaction_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
type: varchar({ length: 20 }).notNull(), type: varchar({ length: 20 }).notNull().$type<ReactionKey>(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
problemId: integer("problem_id").notNull(), problemId: integer("problem_id").notNull(),
userId: integer("user_id").notNull(), userId: integer("user_id").notNull(),
@@ -354,14 +384,14 @@ export const problem = pgTable("problem", {
testCaseId: text("test_case_id").notNull(), testCaseId: text("test_case_id").notNull(),
testCaseScore: jsonb("test_case_score").notNull(), testCaseScore: jsonb("test_case_score").notNull(),
hint: text(), hint: text(),
languages: jsonb().notNull(), languages: jsonb().notNull().$type<ProblemLanguage[]>(),
template: jsonb().notNull(), template: jsonb().notNull().$type<Record<string, string>>(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }), lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }),
timeLimit: integer("time_limit").notNull(), timeLimit: integer("time_limit").notNull(),
memoryLimit: integer("memory_limit").notNull(), memoryLimit: integer("memory_limit").notNull(),
visible: boolean().default(true).notNull(), visible: boolean().default(true).notNull(),
difficulty: text().notNull(), difficulty: text().notNull().$type<ProblemDifficulty>(),
source: text(), source: text(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations // You can use { mode: "bigint" } if numbers are exceeding js number limitations
submissionNumber: bigint("submission_number", { mode: "number" }).default(0).notNull(), submissionNumber: bigint("submission_number", { mode: "number" }).default(0).notNull(),
@@ -386,9 +416,9 @@ export const problem = pgTable("problem", {
flowchartHint: text("flowchart_hint"), flowchartHint: text("flowchart_hint"),
mermaidCode: text("mermaid_code"), mermaidCode: text("mermaid_code"),
showFlowchart: boolean("show_flowchart").default(false).notNull(), showFlowchart: boolean("show_flowchart").default(false).notNull(),
astRules: jsonb("ast_rules"), astRules: jsonb("ast_rules").$type<AstRules>(),
sqlConfig: jsonb("sql_config"), sqlConfig: jsonb("sql_config").$type<SqlConfig>(),
sqlDisplay: jsonb("sql_display"), sqlDisplay: jsonb("sql_display").$type<SqlDisplay>(),
}, (table) => [ }, (table) => [
index("problem_contest_visible_idx").using("btree", table.contestId.asc().nullsLast().op("bool_ops"), table.visible.asc().nullsLast().op("int4_ops")), index("problem_contest_visible_idx").using("btree", table.contestId.asc().nullsLast().op("bool_ops"), table.visible.asc().nullsLast().op("int4_ops")),
index("problem_created_by_id_cb362143").using("btree", table.createdById.asc().nullsLast().op("int4_ops")), index("problem_created_by_id_cb362143").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
@@ -439,9 +469,9 @@ export const submission = pgTable("submission", {
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
userId: integer("user_id").notNull(), userId: integer("user_id").notNull(),
code: text().notNull(), code: text().notNull(),
result: integer().default(6).notNull(), result: integer().default(6).notNull().$type<JudgeStatus>(),
info: jsonb().default({}).notNull(), info: jsonb().default({}).notNull(),
language: text().notNull(), language: text().notNull().$type<ProblemLanguage>(),
/** /**
* 已停用,见 problem.share_submission 的说明。历史上 12.3 万条提交里有 40 条 * 已停用,见 problem.share_submission 的说明。历史上 12.3 万条提交里有 40 条
* 为真2022 年 39 条、2023 年 1 条入口在更早的那版前端上ojnext 和 OJ2 * 为真2022 年 39 条、2023 年 1 条入口在更早的那版前端上ojnext 和 OJ2
@@ -560,7 +590,7 @@ export const tutorial = pgTable("tutorial", {
order: integer().notNull(), order: integer().notNull(),
createdById: integer("created_by_id").notNull(), createdById: integer("created_by_id").notNull(),
code: text(), code: text(),
type: varchar({ length: 10 }).notNull(), type: varchar({ length: 10 }).notNull().$type<TutorialType>(),
}, (table) => [ }, (table) => [
index("tutorial_created_by_id_07973cab").using("btree", table.createdById.asc().nullsLast().op("int4_ops")), index("tutorial_created_by_id_07973cab").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
foreignKey({ foreignKey({
@@ -635,7 +665,7 @@ export const userBadge = pgTable("user_badge", {
export const userProfile = pgTable("user_profile", { export const userProfile = pgTable("user_profile", {
id: serial().primaryKey().notNull(), id: serial().primaryKey().notNull(),
acmProblemsStatus: jsonb("acm_problems_status").default({}).notNull(), acmProblemsStatus: jsonb("acm_problems_status").default({}).notNull().$type<Record<string, unknown>>(),
avatar: text().notNull(), avatar: text().notNull(),
mood: text(), mood: text(),
acceptedNumber: integer("accepted_number").default(0).notNull(), acceptedNumber: integer("accepted_number").default(0).notNull(),
@@ -656,7 +686,7 @@ export const acmContestRank = pgTable("acm_contest_rank", {
submissionNumber: integer("submission_number").default(0).notNull(), submissionNumber: integer("submission_number").default(0).notNull(),
acceptedNumber: integer("accepted_number").default(0).notNull(), acceptedNumber: integer("accepted_number").default(0).notNull(),
totalTime: integer("total_time").default(0).notNull(), totalTime: integer("total_time").default(0).notNull(),
submissionInfo: jsonb("submission_info").default({}).notNull(), submissionInfo: jsonb("submission_info").default({}).notNull().$type<Record<string, ContestSubmissionInfo>>(),
contestId: integer("contest_id").notNull(), contestId: integer("contest_id").notNull(),
userId: integer("user_id").notNull(), userId: integer("user_id").notNull(),
}, (table) => [ }, (table) => [
@@ -705,7 +735,7 @@ export const problemsetBadge = pgTable("problemset_badge", {
name: text().notNull(), name: text().notNull(),
description: text().notNull(), description: text().notNull(),
icon: text().notNull(), icon: text().notNull(),
conditionType: text("condition_type").notNull(), conditionType: text("condition_type").notNull().$type<BadgeConditionType>(),
conditionValue: integer("condition_value").notNull(), conditionValue: integer("condition_value").notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations // You can use { mode: "bigint" } if numbers are exceeding js number limitations
problemsetId: bigint("problemset_id", { mode: "number" }).notNull(), problemsetId: bigint("problemset_id", { mode: "number" }).notNull(),

View File

@@ -1,4 +1,4 @@
import { flowchartUpdateSchema, type FlowchartUpdate } from "@oj2/contract" import type { FlowchartUpdate } from "@oj2/contract"
import { redis } from "./redis" import { redis } from "./redis"
@@ -72,7 +72,7 @@ export function userEventTopic(userId: number) {
} }
export async function publishFlowchartUpdate(userId: number, data: FlowchartUpdate) { export async function publishFlowchartUpdate(userId: number, data: FlowchartUpdate) {
await redis.publish(userEventChannel, JSON.stringify({ userId, data: flowchartUpdateSchema.parse(data) })) await redis.publish(userEventChannel, JSON.stringify({ userId, data }))
} }
export async function publishAchievementNotification( export async function publishAchievementNotification(

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { createHash } from "node:crypto" import { createHash } from "node:crypto"
import { astRuleSchema } from "@oj2/contract" import { astRuleSchema, type ContestSubmissionInfo } from "@oj2/contract"
import { and, eq, inArray } from "drizzle-orm" import { and, eq, inArray } from "drizzle-orm"
import { config } from "../config" import { config } from "../config"
@@ -248,15 +248,12 @@ async function persistResult(
.for("update") .for("update")
if (!rank) throw new Error("Contest rank could not be created") if (!rank) throw new Error("Contest rank could not be created")
const rankInfo = objectValue(rank.submissionInfo) const rankInfo = rank.submissionInfo
const previousInfo = objectValue(rankInfo[String(problemId)]) const previousInfo = rankInfo[String(problemId)]
const alreadyAccepted = previousInfo.is_ac === true const alreadyAccepted = previousInfo?.is_ac === true
if (!alreadyAccepted) { if (!alreadyAccepted) {
const errorNumber = const errorNumber = previousInfo?.error_number ?? 0
typeof previousInfo.error_number === "number" const nextInfo: ContestSubmissionInfo = {
? previousInfo.error_number
: 0
const nextInfo: Record<string, unknown> = {
is_ac: acceptedNow, is_ac: acceptedNow,
ac_time: 0, ac_time: 0,
error_number: error_number:

View File

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

View File

@@ -48,14 +48,14 @@ export function judgeStatusName(result: number) {
* 它们从分母里摘掉 —— 否则全班同时交卷的那几秒,分母涨了分子没涨,正确率凭空掉一截。 * 它们从分母里摘掉 —— 否则全班同时交卷的那几秒,分母涨了分子没涨,正确率凭空掉一截。
* 人数口径不受影响:交了但还在判的学生仍然算「交过」,不该被点名成「没做」。 * 人数口径不受影响:交了但还在判的学生仍然算「交过」,不该被点名成「没做」。
*/ */
export const UNJUDGED_RESULTS: number[] = [JudgeStatus.PENDING, JudgeStatus.JUDGING] export const UNJUDGED_RESULTS: JudgeStatusValue[] = [JudgeStatus.PENDING, JudgeStatus.JUDGING]
/** /**
* **不**计入「这道题失败了几次」的状态。除了通过(含 AST_CHECK_FAILED那也是答案对了 * **不**计入「这道题失败了几次」的状态。除了通过(含 AST_CHECK_FAILED那也是答案对了
* 和还没判完的两个,还排掉 SYSTEM_ERROR —— 判题机自己崩了不是学生的问题, * 和还没判完的两个,还排掉 SYSTEM_ERROR —— 判题机自己崩了不是学生的问题,
* 不该推着 AI 提示的解锁进度往前走。 * 不该推着 AI 提示的解锁进度往前走。
*/ */
export const NON_FAILURE_RESULTS: number[] = [ export const NON_FAILURE_RESULTS: JudgeStatusValue[] = [
JudgeStatus.ACCEPTED, JudgeStatus.ACCEPTED,
JudgeStatus.AST_CHECK_FAILED, JudgeStatus.AST_CHECK_FAILED,
JudgeStatus.PENDING, JudgeStatus.PENDING,

View File

@@ -2,15 +2,16 @@ import { randomBytes } from "node:crypto"
import { extname, resolve } from "node:path" import { extname, resolve } from "node:path"
import { import {
STUDENT_ROLES,
activityRankItemSchema,
metricsSchema,
problemRankSchema,
myRankSchema,
rankProfileSchema, rankProfileSchema,
registerRequestSchema, registerRequestSchema,
STUDENT_ROLES,
updateProfileRequestSchema, updateProfileRequestSchema,
userRankSchema, type ActivityRankItem,
type Metrics,
type MyRank,
type ProblemRank,
type RankProfile,
type UserRank,
} from "@oj2/contract" } from "@oj2/contract"
import { import {
and, and,
@@ -144,7 +145,7 @@ accountRoutes.get("/users/:id/metrics", async (c) => {
.from(schema.submission) .from(schema.submission)
.where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId))) .where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId)))
if (!row?.total || !row.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交") if (!row?.total || !row.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交")
return success(c, metricsSchema.parse({ now: new Date().toISOString(), first: row.first, latest: row.latest })) return success(c, { now: new Date().toISOString(), first: row.first, latest: row.latest } satisfies Metrics)
}) })
/** /**
@@ -199,25 +200,25 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => {
isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null, isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null,
]) ])
return success(c, userRankSchema.parse({ return success(c, {
results: rows.map((row) => serializeRankRow(row, online)), results: rows.map((row) => serializeRankRow(row, online)),
total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE), total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE),
me, me,
})) } satisfies UserRank)
}) })
function serializeRankRow({ profile, user }: { function serializeRankRow({ profile, user }: {
profile: typeof schema.userProfile.$inferSelect profile: typeof schema.userProfile.$inferSelect
user: typeof schema.user.$inferSelect user: typeof schema.user.$inferSelect
}, online: Set<number> | null = null) { }, online: Set<number> | null = null) {
return rankProfileSchema.parse({ return {
id: profile.id, id: profile.id,
user: sampleUser(user, profile.realName), user: sampleUser(user, profile.realName),
acceptedNumber: profile.acceptedNumber, acceptedNumber: profile.acceptedNumber,
submissionNumber: profile.submissionNumber, submissionNumber: profile.submissionNumber,
mood: profile.mood, mood: profile.mood,
isOnline: online ? online.has(user.id) : null, isOnline: online ? online.has(user.id) : null,
}) } satisfies RankProfile
} }
/** /**
@@ -251,10 +252,10 @@ async function myLeaderboardRank(userId: number | undefined) {
), ),
))) )))
return myRankSchema.parse({ return {
...serializeRankRow(mine), ...serializeRankRow(mine),
rank: (ahead?.value ?? 0) + 1, rank: (ahead?.value ?? 0) + 1,
}) } satisfies MyRank
} }
accountRoutes.get("/rankings/activity", async (c) => { accountRoutes.get("/rankings/activity", async (c) => {
@@ -279,7 +280,7 @@ accountRoutes.get("/rankings/activity", async (c) => {
)) ))
.groupBy(schema.submission.userId, schema.user.username) .groupBy(schema.submission.userId, schema.user.username)
.orderBy(desc(countDistinct(schema.submission.problemId))).limit(10) .orderBy(desc(countDistinct(schema.submission.problemId))).limit(10)
return success(c, rows.map((row) => activityRankItemSchema.parse({ username: row.username, count: row.value }))) return success(c, rows.map((row) => ({ username: row.username, count: row.value } satisfies ActivityRankItem)))
}) })
accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => { accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
@@ -303,7 +304,7 @@ accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => {
const [rankRow] = await db.select({ value: count() }).from(schema.submission).where(and(classWhere, lte(schema.submission.createTime, first.value))) const [rankRow] = await db.select({ value: count() }).from(schema.submission).where(and(classWhere, lte(schema.submission.createTime, first.value)))
rank = rankRow?.value ?? -1 rank = rankRow?.value ?? -1
} }
return success(c, problemRankSchema.parse({ className, rank, classAcCount: classCount?.value ?? 0, allAcCount: all?.value ?? 0 })) return success(c, { className, rank, classAcCount: classCount?.value ?? 0, allAcCount: all?.value ?? 0 } satisfies ProblemRank)
}) })
/** /**

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
import { import type {
adminAiReportListSchema, AdminAiReport,
adminAiReportListItemSchema, AdminAiReportList,
adminAiReportSchema, AdminAiReportListItem,
toggleAiReportPinResponseSchema, ToggleAiReportPinResponse,
} from "@oj2/contract" } from "@oj2/contract"
import { and, count, desc, eq, ilike } from "drizzle-orm" import { and, count, desc, eq, ilike } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -22,13 +22,13 @@ function excerpt(analysis: string | null) {
} }
function listItem(row: { id: number; username: string; createTime: string; analysis: string; isPinned: boolean }) { function listItem(row: { id: number; username: string; createTime: string; analysis: string; isPinned: boolean }) {
return adminAiReportListItemSchema.parse({ return {
id: row.id, id: row.id,
username: row.username, username: row.username,
createTime: row.createTime, createTime: row.createTime,
analysisExcerpt: excerpt(row.analysis), analysisExcerpt: excerpt(row.analysis),
isPinned: row.isPinned, isPinned: row.isPinned,
}) } satisfies AdminAiReportListItem
} }
const listColumns = { const listColumns = {
@@ -53,10 +53,10 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)) .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(and(eq(schema.aiAnalysis.isPinned, true), where)) .where(and(eq(schema.aiAnalysis.isPinned, true), where))
.orderBy(desc(schema.aiAnalysis.createTime)) .orderBy(desc(schema.aiAnalysis.createTime))
return success(c, adminAiReportListSchema.parse({ return success(c, {
results: rows.map(listItem), results: rows.map(listItem),
total: rows.length, total: rows.length,
})) } satisfies AdminAiReportList)
} }
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
@@ -68,10 +68,10 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => {
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where) .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where)
.orderBy(desc(schema.aiAnalysis.createTime)).limit(limit).offset(offset), .orderBy(desc(schema.aiAnalysis.createTime)).limit(limit).offset(offset),
]) ])
return success(c, adminAiReportListSchema.parse({ return success(c, {
results: rows.map(listItem), results: rows.map(listItem),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
})) } satisfies AdminAiReportList)
}) })
adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => { adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
@@ -86,7 +86,7 @@ adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => {
.where(eq(schema.aiAnalysis.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1) .where(eq(schema.aiAnalysis.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1)
if (!row) return failure(c, 404, "report-not-found", "AIAnalysis not found") if (!row) return failure(c, 404, "report-not-found", "AIAnalysis not found")
// data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词 // data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词
return success(c, adminAiReportSchema.parse(row)) return success(c, row satisfies AdminAiReport)
}) })
adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => { adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
@@ -104,5 +104,5 @@ adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => {
} }
await tx.update(schema.aiAnalysis).set({ isPinned: next }).where(eq(schema.aiAnalysis.id, id)) await tx.update(schema.aiAnalysis).set({ isPinned: next }).where(eq(schema.aiAnalysis.id, id))
}) })
return success(c, toggleAiReportPinResponseSchema.parse({ isPinned: next })) return success(c, { isPinned: next } satisfies ToggleAiReportPinResponse)
}) })

View File

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

View File

@@ -1,12 +1,12 @@
import { import {
dashboardInfoSchema,
judgeServerListSchema,
judgeServerSchema,
orphanTestCaseSchema,
updateJudgeServerRequestSchema, updateJudgeServerRequestSchema,
updateWebsiteConfigRequestSchema, updateWebsiteConfigRequestSchema,
uploadImageResponseSchema, type DashboardInfo,
websiteConfigSchema, type JudgeServer,
type JudgeServerList,
type OrphanTestCase,
type UploadImageResponse,
type WebsiteConfig,
} from "@oj2/contract" } from "@oj2/contract"
import { randomInt } from "node:crypto" import { randomInt } from "node:crypto"
import { mkdir, readdir, rm, stat } from "node:fs/promises" import { mkdir, readdir, rm, stat } from "node:fs/promises"
@@ -57,7 +57,7 @@ const OPTION_KEYS = {
adminConfRoutes.get("/website", requireSuperAdmin, async (c) => { adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
const options = await getWebsiteOptions() const options = await getWebsiteOptions()
return success(c, websiteConfigSchema.parse({ return success(c, {
websiteBaseUrl: options.website_base_url, websiteBaseUrl: options.website_base_url,
websiteName: options.website_name, websiteName: options.website_name,
websiteNameShortcut: options.website_name_shortcut, websiteNameShortcut: options.website_name_shortcut,
@@ -66,7 +66,7 @@ adminConfRoutes.get("/website", requireSuperAdmin, async (c) => {
submissionListShowAll: options.submission_list_show_all, submissionListShowAll: options.submission_list_show_all,
classList: options.class_list, classList: options.class_list,
enableMaxkb: options.enable_maxkb, enableMaxkb: options.enable_maxkb,
})) } satisfies WebsiteConfig)
}) })
adminConfRoutes.post("/website", requireSuperAdmin, async (c) => { adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
@@ -96,14 +96,14 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => {
adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => { adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => {
const rows = await db.select().from(schema.judgeServer).orderBy(desc(schema.judgeServer.lastHeartbeat)) const rows = await db.select().from(schema.judgeServer).orderBy(desc(schema.judgeServer.lastHeartbeat))
return success(c, judgeServerListSchema.parse({ return success(c, {
// 后台要显示 token 才能拿去配判题机。这个接口是超管专属的 // 后台要显示 token 才能拿去配判题机。这个接口是超管专属的
token: config.judgeServerToken, token: config.judgeServerToken,
servers: rows.map((row) => judgeServerSchema.parse({ servers: rows.map((row) => ({
...row, ...row,
status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal", status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal",
})), } satisfies JudgeServer)),
})) } satisfies JudgeServerList)
}) })
adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => { adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => {
@@ -146,7 +146,7 @@ adminConfRoutes.get("/orphan-test-cases", requireSuperAdmin, async (c) => {
const ids = await orphanTestCaseIds() const ids = await orphanTestCaseIds()
const rows = await Promise.all(ids.map(async (id) => { const rows = await Promise.all(ids.map(async (id) => {
const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null) const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null)
return orphanTestCaseSchema.parse({ id, createTime: info ? info.mtimeMs / 1000 : 0 }) return { id, createTime: info ? info.mtimeMs / 1000 : 0 } satisfies OrphanTestCase
})) }))
return success(c, rows) return success(c, rows)
}) })
@@ -180,12 +180,12 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => {
.where(gte(schema.judgeServer.lastHeartbeat, aliveSince())), .where(gte(schema.judgeServer.lastHeartbeat, aliveSince())),
]) ])
// 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST前端从未读过不再下发 // 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST前端从未读过不再下发
return success(c, dashboardInfoSchema.parse({ return success(c, {
userCount: users?.value ?? 0, userCount: users?.value ?? 0,
todaySubmissionCount: submissions?.value ?? 0, todaySubmissionCount: submissions?.value ?? 0,
recentContestCount: contests?.value ?? 0, recentContestCount: contests?.value ?? 0,
judgeServerCount: servers?.value ?? 0, judgeServerCount: servers?.value ?? 0,
})) } satisfies DashboardInfo)
}) })
adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => { adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => {
@@ -218,16 +218,16 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
const form = await c.req.formData().catch(() => null) const form = await c.req.formData().catch(() => null)
const image = form?.get("image") const image = form?.get("image")
if (!(image instanceof File)) { if (!(image instanceof File)) {
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Upload failed", filePath: "" })) return success(c, { success: false, msg: "Upload failed", filePath: "" } satisfies UploadImageResponse)
} }
const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase() const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase()
if (!IMAGE_SUFFIXES.includes(suffix)) { if (!IMAGE_SUFFIXES.includes(suffix)) {
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Unsupported file format", filePath: "" })) return success(c, { success: false, msg: "Unsupported file format", filePath: "" } satisfies UploadImageResponse)
} }
// 旧后端没有大小限制,靠 nginx 兜。这里显式限一道:文件写在本地磁盘上, // 旧后端没有大小限制,靠 nginx 兜。这里显式限一道:文件写在本地磁盘上,
// 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂 // 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂
if (image.size > MAX_IMAGE_BYTES) { if (image.size > MAX_IMAGE_BYTES) {
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "图片不能超过 10MB", filePath: "" })) return success(c, { success: false, msg: "图片不能超过 10MB", filePath: "" } satisfies UploadImageResponse)
} }
// 文件名完全由服务端生成,不带用户提供的任何一段 —— 原名里的 ../ 或空字节都进不来 // 文件名完全由服务端生成,不带用户提供的任何一段 —— 原名里的 ../ 或空字节都进不来
const name = `${randomFileName()}${suffix}` const name = `${randomFileName()}${suffix}`
@@ -236,13 +236,13 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => {
await Bun.write(resolve(config.uploadDirectory, name), image) await Bun.write(resolve(config.uploadDirectory, name), image)
} catch (error) { } catch (error) {
console.error("Failed to save uploaded image", error) console.error("Failed to save uploaded image", error)
return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Upload Error", filePath: "" })) return success(c, { success: false, msg: "Upload Error", filePath: "" } satisfies UploadImageResponse)
} }
return success(c, uploadImageResponseSchema.parse({ return success(c, {
success: true, success: true,
msg: "Success", msg: "Success",
filePath: `${config.uploadUriPrefix}/${name}`, filePath: `${config.uploadUriPrefix}/${name}`,
})) } satisfies UploadImageResponse)
}) })
function randomFileName() { function randomFileName() {

View File

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

View File

@@ -1,13 +1,13 @@
import { import {
STUDENT_ROLES, STUDENT_ROLES,
TUTORIAL_READ_SECONDS, TUTORIAL_READ_SECONDS,
learnExerciseAttemptSchema, type LearnExerciseAttempt,
learnExerciseProgressListSchema, type LearnExerciseProgress,
learnExerciseProgressSchema, type LearnExerciseProgressList,
learnStudentProgressListSchema, type LearnStudentProgress,
learnStudentProgressSchema, type LearnStudentProgressList,
learnTutorialProgressListSchema, type LearnTutorialProgress,
learnTutorialProgressSchema, type LearnTutorialProgressList,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm" import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -120,16 +120,16 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => {
.where(inArray(schema.exercise.tutorialId, tutorialIds)) .where(inArray(schema.exercise.tutorialId, tutorialIds))
: [{ value: 0 }] : [{ value: 0 }]
return success(c, learnStudentProgressListSchema.parse({ return success(c, {
tutorialCount: tutorialIds.length, tutorialCount: tutorialIds.length,
exerciseCount: exerciseCountRow?.value ?? 0, exerciseCount: exerciseCountRow?.value ?? 0,
results: rows.map((row) => learnStudentProgressSchema.parse({ results: rows.map((row) => ({
...row, ...row,
exerciseTried: attempts.get(row.userId)?.tried ?? 0, exerciseTried: attempts.get(row.userId)?.tried ?? 0,
exerciseSolved: attempts.get(row.userId)?.solved ?? 0, exerciseSolved: attempts.get(row.userId)?.solved ?? 0,
exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0, exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0,
})), } satisfies LearnStudentProgress)),
})) } satisfies LearnStudentProgressList)
}) })
adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => { adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => {
@@ -166,13 +166,13 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) =>
.groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order) .groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order)) .orderBy(asc(schema.tutorial.order))
return success(c, learnTutorialProgressListSchema.parse({ return success(c, {
studentCount, studentCount,
results: rows.map(({ readSeconds, ...row }) => learnTutorialProgressSchema.parse({ results: rows.map(({ readSeconds, ...row }) => ({
...row, ...row,
avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0, avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0,
})), } satisfies LearnTutorialProgress)),
})) } satisfies LearnTutorialProgressList)
}) })
/** /**
@@ -216,13 +216,13 @@ adminLearnRoutes.get("/learn-analytics/exercises", requireTeacher, async (c) =>
.groupBy(schema.exercise.id, schema.tutorial.id, schema.tutorial.title, schema.tutorial.order) .groupBy(schema.exercise.id, schema.tutorial.id, schema.tutorial.title, schema.tutorial.order)
.orderBy(asc(schema.tutorial.order), asc(schema.exercise.order)) .orderBy(asc(schema.tutorial.order), asc(schema.exercise.order))
return success(c, learnExerciseProgressListSchema.parse({ return success(c, {
studentCount: studentCountRow?.value ?? 0, studentCount: studentCountRow?.value ?? 0,
results: rows.map((row) => learnExerciseProgressSchema.parse({ results: rows.map((row) => ({
...row, ...row,
avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1), avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1),
})), } satisfies LearnExerciseProgress)),
})) } satisfies LearnExerciseProgressList)
}) })
/** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */ /** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */
@@ -249,5 +249,5 @@ adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher,
// 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的 // 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的
.orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts)) .orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts))
return success(c, rows.map((row) => learnExerciseAttemptSchema.parse(row))) return success(c, rows satisfies LearnExerciseAttempt[])
}) })

View File

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

View File

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

View File

@@ -1,13 +1,13 @@
import { import {
acTrendSchema,
adminTagSchema,
batchProblemTagRequestSchema, batchProblemTagRequestSchema,
batchProblemTagResponseSchema,
generateFlowchartRequestSchema, generateFlowchartRequestSchema,
generateFlowchartResponseSchema,
renameTagRequestSchema, renameTagRequestSchema,
renameTagResponseSchema, type AcTrend,
stuckProblemSchema, type AdminTag,
type BatchProblemTagResponse,
type GenerateFlowchartResponse,
type RenameTagResponse,
type StuckProblem,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, countDistinct, count, desc, eq, gte, ilike, inArray, isNull, lte, ne, sql } from "drizzle-orm" import { and, asc, countDistinct, count, desc, eq, gte, ilike, inArray, isNull, lte, ne, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -46,7 +46,7 @@ adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些), // 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0 // 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0
.orderBy(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name)) .orderBy(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name))
return success(c, rows.map((row) => adminTagSchema.parse(row))) return success(c, rows satisfies AdminTag[])
}) })
adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => { adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
@@ -63,7 +63,7 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
if (!target) { if (!target) {
await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id)) await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id))
return success(c, renameTagResponseSchema.parse({ merged: false, id, name, affectedCount: 0 })) return success(c, { merged: false, id, name, affectedCount: 0 } satisfies RenameTagResponse)
} }
// 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除 // 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除
@@ -86,9 +86,9 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
await tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id)) await tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id))
return links.length return links.length
}) })
return success(c, renameTagResponseSchema.parse({ return success(c, {
merged: true, id: target.id, name: target.name, affectedCount: affected, merged: true, id: target.id, name: target.name, affectedCount: affected,
})) } satisfies RenameTagResponse)
}) })
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => { adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => {
@@ -152,10 +152,10 @@ adminTagRoutes.post("/problems/batch-tag", requireProblemPermission, async (c) =
if (rows.length) await tx.insert(schema.problemTags).values(rows) if (rows.length) await tx.insert(schema.problemTags).values(rows)
}) })
return success(c, batchProblemTagResponseSchema.parse({ return success(c, {
problemCount: problems.length, problemCount: problems.length,
tagCount: tagIds.length, tagCount: tagIds.length,
})) } satisfies BatchProblemTagResponse)
}) })
// ---------------------------------------------------------------- 题目可见性 // ---------------------------------------------------------------- 题目可见性
@@ -209,14 +209,14 @@ adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
.having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`) .having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`)
.orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`)) .orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`))
.limit(40) .limit(40)
return success(c, rows.map((row) => stuckProblemSchema.parse({ return success(c, rows.map((row) => ({
problemId: row.displayId, problemId: row.displayId,
problemTitle: row.title, problemTitle: row.title,
total: row.total, total: row.total,
failed: row.failed, failed: row.failed,
failedUsers: row.failedUsers, failedUsers: row.failedUsers,
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0, acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
}))) } satisfies StuckProblem)))
}) })
adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => { adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
@@ -263,7 +263,7 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
// 每一年都得有数据,且每年提交量都超过门槛 —— 否则趋势没有可比性 // 每一年都得有数据,且每年提交量都超过门槛 —— 否则趋势没有可比性
if (![...required].every((y) => years.has(y))) continue if (![...required].every((y) => years.has(y))) continue
if (!entry.yearly.every((row) => row.total > minPerYear)) continue if (!entry.yearly.every((row) => row.total > minPerYear)) continue
result.push(acTrendSchema.parse({ result.push({
problemId: entry.displayId, problemId: entry.displayId,
problemTitle: entry.title, problemTitle: entry.title,
yearly: entry.yearly yearly: entry.yearly
@@ -274,7 +274,7 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => {
acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0, acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0,
})) }))
.sort((left, right) => left.year - right.year), .sort((left, right) => left.year - right.year),
})) } satisfies AcTrend)
} }
return success(c, result) return success(c, result)
}) })
@@ -292,7 +292,7 @@ adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) =
请只返回 mermaid 代码,连 \`\`\` 都不需要。`, 请只返回 mermaid 代码,连 \`\`\` 都不需要。`,
parsed.data.python, parsed.data.python,
) )
return success(c, generateFlowchartResponseSchema.parse({ flowchart })) return success(c, { flowchart } satisfies GenerateFlowchartResponse)
} catch (error) { } catch (error) {
console.error("Flowchart generation failed", error) console.error("Flowchart generation failed", error)
return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试") return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试")

View File

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

View File

@@ -1,16 +1,17 @@
import { import {
HINT_MIN_FAILURES,
aiAnalysisRecordSchema,
aiAnalysisRequestSchema, aiAnalysisRequestSchema,
aiDetailSchema,
aiHintRequestSchema, aiHintRequestSchema,
classAnalysisRequestSchema, classAnalysisRequestSchema,
classPkAnalysisRequestSchema, classPkAnalysisRequestSchema,
durationDataSchema, HINT_MIN_FAILURES,
heatmapItemSchema, type AiAnalysisRecord,
loginSummarySchema, type AiDetail,
solvedListSchema, type DurationData,
solvedProblemSchema, type Grade,
type HeatmapItem,
type LoginSummary,
type SolvedList,
type SolvedProblem,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, count, countDistinct, eq, gte, inArray, isNull, lte, min, sql } from "drizzle-orm" import { and, asc, count, countDistinct, eq, gte, inArray, isNull, lte, min, sql } from "drizzle-orm"
import { Hono, type Context } from "hono" import { Hono, type Context } from "hono"
@@ -19,7 +20,7 @@ import { requireAuth, type AppEnv } from "../auth/middleware"
import { getPreviousLogin, type AuthUser } from "../auth/session" import { getPreviousLogin, type AuthUser } from "../auth/session"
import { config } from "../config" import { config } from "../config"
import { db, schema } from "../db" import { db, schema } from "../db"
import { JudgeStatus, judgeStatusName } from "../judge/status" import { JudgeStatus, judgeStatusName, type JudgeStatusValue } from "../judge/status"
import { failure, success } from "../http" import { failure, success } from "../http"
import { completeChat, streamChat } from "../services/ai" import { completeChat, streamChat } from "../services/ai"
import { consumeToken } from "../services/throttling" import { consumeToken } from "../services/throttling"
@@ -27,7 +28,7 @@ import { countFailedSubmissions, isTeacherOrAbove, objectValue, queryInteger, ro
export const aiRoutes = new Hono<AppEnv>() export const aiRoutes = new Hono<AppEnv>()
const accepted = [0, 10] const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
const difficultyNames: Record<string, string> = { Low: "简单", Mid: "中等", High: "困难" } const difficultyNames: Record<string, string> = { Low: "简单", Mid: "中等", High: "困难" }
/** /**
@@ -61,15 +62,15 @@ const calendarDay = new Intl.DateTimeFormat("en-CA", {
timeZone: CALENDAR_TZ, year: "numeric", month: "2-digit", day: "2-digit", timeZone: CALENDAR_TZ, year: "numeric", month: "2-digit", day: "2-digit",
}) })
function grade(rank: number | null, count: number, reference = count) { function grade(rank: number | null, count: number, reference = count): Grade {
if (!rank || count <= 0) return "C" if (!rank || count <= 0) return "C"
const percentile = (rank - 1) / count * 100 const percentile = (rank - 1) / count * 100
let value = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C" let value: Grade = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C"
if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value
return value return value
} }
function averageGrade(grades: string[]) { function averageGrade(grades: Grade[]): Grade {
const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 } const weights: Record<string, number> = { S: 4, A: 3, B: 2, C: 1 }
const values = grades.flatMap((item) => weights[item] ?? []) const values = grades.flatMap((item) => weights[item] ?? [])
if (!values.length) return "" if (!values.length) return ""
@@ -149,12 +150,12 @@ async function buildSolved(user: AuthUser, start: string, end: string, firstAc:
const period = ranks(periodRows, item.problemId) const period = ranks(periodRows, item.problemId)
const rank = all.findIndex((row) => row.userId === user.id) + 1 || null const rank = all.findIndex((row) => row.userId === user.id) + 1 || null
const periodRank = period.findIndex((row) => row.userId === user.id) + 1 || null const periodRank = period.findIndex((row) => row.userId === user.id) + 1 || null
return solvedProblemSchema.parse({ return {
problem: { title: problem.problem.title, displayId: problem.problem.displayId, contestTitle: problem.contestTitle ?? "", contestId: problem.problem.contestId }, problem: { title: problem.problem.title, displayId: problem.problem.displayId, contestTitle: problem.contestTitle ?? "", contestId: problem.problem.contestId },
acTime: item.first, rank, acCount: all.length, grade: grade(periodRank, period.length, all.length), periodRank, periodAcCount: period.length, acTime: item.first, rank, acCount: all.length, grade: grade(periodRank, period.length, all.length), periodRank, periodAcCount: period.length,
difficulty: difficultyNames[problem.problem.difficulty] ?? "中等", difficulty: difficultyNames[problem.problem.difficulty] ?? "中等",
attempts: attemptsByProblem.get(item.problemId) ?? 1, attempts: attemptsByProblem.get(item.problemId) ?? 1,
}) } satisfies SolvedProblem
}).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime)) }).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime))
return { solved, problems, scopeIds } return { solved, problems, scopeIds }
} }
@@ -169,7 +170,7 @@ async function listSolved(user: AuthUser, start: string, end: string, limit: num
)), )),
]) ])
const { solved } = await buildSolved(user, start, end, firstAc) const { solved } = await buildSolved(user, start, end, firstAc)
return solvedListSchema.parse({ results: solved, total: totalRows[0]?.value ?? 0 }) return { results: solved, total: totalRows[0]?.value ?? 0 } satisfies SolvedList
} }
async function buildDetail(user: AuthUser, start: string, end: string) { async function buildDetail(user: AuthUser, start: string, end: string) {
@@ -195,7 +196,7 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
eq(schema.submission.userId, user.id), eq(schema.submission.userId, user.id),
gte(schema.submission.createTime, start), lte(schema.submission.createTime, end), gte(schema.submission.createTime, start), lte(schema.submission.createTime, end),
)) ))
const settledFail = (result: number) => const settledFail = (result: JudgeStatusValue) =>
!accepted.includes(result) && result !== JudgeStatus.PENDING && result !== JudgeStatus.JUDGING !accepted.includes(result) && result !== JudgeStatus.PENDING && result !== JudgeStatus.JUDGING
const errorCounts = new Map<number, number>() const errorCounts = new Map<number, number>()
for (const row of submissions) { for (const row of submissions) {
@@ -207,10 +208,10 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
.sort((a, b) => b.count - a.count || a.result - b.result) .sort((a, b) => b.count - a.count || a.result - b.result)
const firstAc = await firstAcQuery(user, start, end) const firstAc = await firstAcQuery(user, start, end)
const problemIds = firstAc.map((item) => item.problemId) const problemIds = firstAc.map((item) => item.problemId)
if (!problemIds.length) return aiDetailSchema.parse({ if (!problemIds.length) return {
user: user.username, className: user.className, start, end, solvedCount: 0, attempts: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0, user: user.username, className: user.className, start, end, solvedCount: 0, attempts: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0,
activity, errors, rankScope: "global", activity, errors, rankScope: "global",
}) } satisfies AiDetail
const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all([ const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all([
buildSolved(user, start, end, firstAc), buildSolved(user, start, end, firstAc),
db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags) db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags)
@@ -244,13 +245,13 @@ async function buildDetail(user: AuthUser, start: string, end: string) {
avgScore: rounded(scores.length ? scores.reduce((sum, value) => sum + value, 0) / scores.length : 0, 0), avgScore: rounded(scores.length ? scores.reduce((sum, value) => sum + value, 0) / scores.length : 0, 0),
} }
}).sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime)) }).sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime))
return aiDetailSchema.parse({ return {
user: user.username, className: user.className, start, end, flowcharts, user: user.username, className: user.className, start, end, flowcharts,
solvedCount: solved.length, attempts: solved.map((item) => item.attempts), solvedCount: solved.length, attempts: solved.map((item) => item.attempts),
grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty, grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty,
contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size, contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size,
activity, errors, rankScope: scopeIds ? "class" : "global", activity, errors, rankScope: scopeIds ? "class" : "global",
}) } satisfies AiDetail
} }
aiRoutes.get("/ai/detail", requireAuth, async (c) => { aiRoutes.get("/ai/detail", requireAuth, async (c) => {
@@ -357,7 +358,7 @@ async function buildDuration(user: AuthUser, endText: string, duration: string)
const inRange = rows.filter((row) => row.time >= from && row.time <= to) const inRange = rows.filter((row) => row.time >= from && row.time <= to)
const acceptedRows = inRange.filter((row) => accepted.includes(row.result)) const acceptedRows = inRange.filter((row) => accepted.includes(row.result))
const solved = [...new Set(acceptedRows.map((row) => row.problemId))] const solved = [...new Set(acceptedRows.map((row) => row.problemId))]
return durationDataSchema.parse({ return {
unit: config.unit, unit: config.unit,
index: config.count - 1 - index, index: config.count - 1 - index,
start: bucket.start.toISOString(), start: bucket.start.toISOString(),
@@ -366,7 +367,7 @@ async function buildDuration(user: AuthUser, endText: string, duration: string)
problemCount: solved.length, problemCount: solved.length,
acceptedCount: acceptedRows.length, acceptedCount: acceptedRows.length,
submissionCount: inRange.length, submissionCount: inRange.length,
}) } satisfies DurationData
}) })
} }
@@ -407,7 +408,7 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => {
const day = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + offset) const day = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + offset)
value += counts.get(dateKey(day)) ?? 0 value += counts.get(dateKey(day)) ?? 0
} }
return heatmapItemSchema.parse({ timestamp: monday.getTime(), value }) return { timestamp: monday.getTime(), value } satisfies HeatmapItem
})) }))
}) })
@@ -442,7 +443,7 @@ aiRoutes.get("/ai/login-summary", requireAuth, async (c) => {
analysisError = error instanceof Error ? error.message : String(error) analysisError = error instanceof Error ? error.message : String(error)
} }
} }
return success(c, loginSummarySchema.parse({ summary, analysis, analysisError })) return success(c, { summary, analysis, analysisError } satisfies LoginSummary)
}) })
aiRoutes.get("/ai/pinned", requireAuth, async (c) => { aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
@@ -450,10 +451,10 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => {
.innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)) .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id))
.where(and(eq(schema.aiAnalysis.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true))).limit(1) .where(and(eq(schema.aiAnalysis.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true))).limit(1)
if (!row) return success(c, null) if (!row) return success(c, null)
return success(c, aiAnalysisRecordSchema.parse({ return success(c, {
id: row.analysis.id, provider: row.analysis.provider, model: row.analysis.model, data: objectValue(row.analysis.data), id: row.analysis.id, provider: row.analysis.provider, model: row.analysis.model, data: objectValue(row.analysis.data),
analysis: row.analysis.analysis, createTime: row.analysis.createTime, isPinned: row.analysis.isPinned, username: row.username, analysis: row.analysis.analysis, createTime: row.analysis.createTime, isPinned: row.analysis.isPinned, username: row.username,
})) } satisfies AiAnalysisRecord)
}) })
aiRoutes.post("/ai/analysis", requireAuth, async (c) => { aiRoutes.post("/ai/analysis", requireAuth, async (c) => {

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,13 @@ import { randomBytes } from "node:crypto"
import { import {
createFlowchartRequestSchema, createFlowchartRequestSchema,
createFlowchartResponseSchema, type CreateFlowchartResponse,
flowchartCurrentSchema, type FlowchartCurrent,
flowchartDetailSchema, type FlowchartDetail,
flowchartListItemSchema, type FlowchartList,
flowchartListSchema, type FlowchartListItem,
flowchartStatisticsSchema, type FlowchartStatistics,
flowchartSubmissionSchema, type FlowchartSubmission,
} from "@oj2/contract" } from "@oj2/contract"
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm" import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
@@ -45,7 +45,7 @@ function flowchartData(
flowchart: typeof schema.flowchartSubmission.$inferSelect, flowchart: typeof schema.flowchartSubmission.$inferSelect,
username: string, username: string,
) { ) {
return flowchartSubmissionSchema.parse({ return {
id: flowchart.id, id: flowchart.id,
username, username,
problemId: flowchart.problemId, problemId: flowchart.problemId,
@@ -62,7 +62,7 @@ function flowchartData(
aiModel: flowchart.aiModel, aiModel: flowchart.aiModel,
processingTime: flowchart.processingTime, processingTime: flowchart.processingTime,
evaluationTime: flowchart.evaluationTime, evaluationTime: flowchart.evaluationTime,
}) } satisfies FlowchartSubmission
} }
flowchartRoutes.post("/flowcharts", requireAuth, async (c) => { flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
@@ -106,7 +106,7 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => {
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, id)) await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, id))
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable") return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
} }
return success(c, createFlowchartResponseSchema.parse({ submissionId: id, status: "pending" }), 201) return success(c, { submissionId: id, status: "pending" } satisfies CreateFlowchartResponse, 201)
}) })
flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
@@ -121,7 +121,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
// submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门, // submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门,
// 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。 // 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) { if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
return success(c, flowchartListSchema.parse({ results: [], total: 0 })) return success(c, { results: [], total: 0 } satisfies FlowchartList)
} }
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`) if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) filters.push(eq(schema.flowchartSubmission.userId, user.id)) if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) filters.push(eq(schema.flowchartSubmission.userId, user.id))
@@ -136,8 +136,8 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
.innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)).where(where) .innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)).where(where)
.orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset), .orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset),
]) ])
return success(c, flowchartListSchema.parse({ return success(c, {
results: rows.map(({ flowchart, username, problem }) => flowchartListItemSchema.parse({ results: rows.map(({ flowchart, username, problem }) => ({
id: flowchart.id, id: flowchart.id,
username, username,
problem: problem.displayId, problem: problem.displayId,
@@ -151,9 +151,9 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
processingTime: flowchart.processingTime, processingTime: flowchart.processingTime,
evaluationTime: flowchart.evaluationTime, evaluationTime: flowchart.evaluationTime,
showLink: canView(user, flowchart, problem), showLink: canView(user, flowchart, problem),
})), } satisfies FlowchartListItem)),
total: totalRows[0]?.value ?? 0, total: totalRows[0]?.value ?? 0,
})) } satisfies FlowchartList)
}) })
const FLOWCHART_COMPLETED = 2 const FLOWCHART_COMPLETED = 2
@@ -242,7 +242,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
realName: stripClassPrefix(row.username, row.className), realName: stripClassPrefix(row.username, row.className),
})), })),
} }
if (rows.length === 0) return success(c, flowchartStatisticsSchema.parse(empty)) if (rows.length === 0) return success(c, empty satisfies FlowchartStatistics)
const gradeDistribution: Record<string, number> = {} const gradeDistribution: Record<string, number> = {}
const criteriaTotals = new Map<string, { sum: number; count: number; max: number }>() const criteriaTotals = new Map<string, { sum: number; count: number; max: number }>()
@@ -289,7 +289,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
criteriaAverages[key] = { avg: rounded(bucket.sum / bucket.count, 1), max: bucket.max } criteriaAverages[key] = { avg: rounded(bucket.sum / bucket.count, 1), max: bucket.max }
} }
return success(c, flowchartStatisticsSchema.parse({ return success(c, {
totalCount: rows.length, totalCount: rows.length,
// 分母是有分数的条数,不是总条数 —— 对齐 Django 的 Avg(),它跳过 NULL // 分母是有分数的条数,不是总条数 —— 对齐 Django 的 Avg(),它跳过 NULL
avgScore: scoreCount ? rounded(scoreSum / scoreCount, 1) : 0, avgScore: scoreCount ? rounded(scoreSum / scoreCount, 1) : 0,
@@ -304,7 +304,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
username: row.username, username: row.username,
realName: stripClassPrefix(row.username, row.className), realName: stripClassPrefix(row.username, row.className),
})), })),
})) } satisfies FlowchartStatistics)
}) })
flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => { flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => {
@@ -351,7 +351,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => {
await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id)) await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id))
return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable") return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable")
} }
return success(c, createFlowchartResponseSchema.parse({ submissionId: row.flowchart.id, status: "pending" })) return success(c, { submissionId: row.flowchart.id, status: "pending" } satisfies CreateFlowchartResponse)
}) })
flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => { flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => {
@@ -359,7 +359,7 @@ flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) =>
const rows = await db.select({ score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade }) const rows = await db.select({ score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade })
.from(schema.flowchartSubmission).where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2))) .from(schema.flowchartSubmission).where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2)))
.orderBy(desc(schema.flowchartSubmission.createTime)) .orderBy(desc(schema.flowchartSubmission.createTime))
return success(c, flowchartCurrentSchema.parse({ count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" })) return success(c, { count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" } satisfies FlowchartCurrent)
}) })
flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => { flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => {
@@ -371,5 +371,5 @@ flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) =>
.orderBy(asc(schema.flowchartSubmission.createTime)) .orderBy(asc(schema.flowchartSubmission.createTime))
const selected = page === 0 ? rows.at(-1) : rows[page - 1] const selected = page === 0 ? rows.at(-1) : rows[page - 1]
if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range") if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range")
return success(c, flowchartDetailSchema.parse({ submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length })) return success(c, { submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length } satisfies FlowchartDetail)
}) })

View File

@@ -1,9 +1,4 @@
import { import { ADMIN_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract"
ADMIN_ROLES,
TEACHER_ROLES,
sampleUserSchema,
type SampleUser,
} from "@oj2/contract"
import { and, count, eq, notInArray } from "drizzle-orm" import { and, count, eq, notInArray } from "drizzle-orm"
@@ -26,11 +21,11 @@ export function sampleUser(
realName: string | null | undefined, realName: string | null | undefined,
options: { includeRealName?: boolean } = {}, options: { includeRealName?: boolean } = {},
): SampleUser { ): SampleUser {
return sampleUserSchema.parse({ return {
id: source.id, id: source.id,
username: source.username, username: source.username,
realName: options.includeRealName === true ? (realName ?? null) : null, realName: options.includeRealName === true ? (realName ?? null) : null,
}) } satisfies SampleUser
} }
/** /**
@@ -49,18 +44,23 @@ export function stripClassPrefix(
return username.startsWith(prefix) ? username.slice(prefix.length) : username return username.startsWith(prefix) ? username.slice(prefix.length) : username
} }
/**
* 拿 query 里的筛选值去比对 `$type` 收窄过的列(`submission.result`、`problem.difficulty` 这些)。
*
* 值来自 URL不受控前端下拉框以外的任何字符串都可能进来。对不上枚举时 SQL 一行都匹配不到,
* 和列没收窄之前的行为完全一致 —— 所以这里只做类型上的交接,**不加校验**
* 在这儿拦一道会把「筛出空列表」变成「筛条件被忽略、返回全部」,那是另一种行为。
*/
export function asFilterValue<T extends string | number>(value: string | number): T {
return value as T
}
export function objectValue(value: unknown): Record<string, unknown> { export function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>) ? (value as Record<string, unknown>)
: {} : {}
} }
export function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: []
}
export function queryInteger( export function queryInteger(
value: string | undefined, value: string | undefined,
fallback: number, fallback: number,

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { onlineCountSchema, quoteSchema, websiteConfigSchema } from "@oj2/contract" import type { OnlineCount, Quote, WebsiteConfig } from "@oj2/contract"
import { asc, desc, eq } from "drizzle-orm" import { asc, desc, eq } from "drizzle-orm"
import { Hono } from "hono" import { Hono } from "hono"
import { resolve } from "node:path" import { resolve } from "node:path"
@@ -14,7 +14,7 @@ export const siteRoutes = new Hono()
siteRoutes.get("/site", async (c) => { siteRoutes.get("/site", async (c) => {
const options = await getWebsiteOptions() const options = await getWebsiteOptions()
return success(c, websiteConfigSchema.parse({ return success(c, {
websiteBaseUrl: options.website_base_url, websiteBaseUrl: options.website_base_url,
websiteName: options.website_name, websiteName: options.website_name,
websiteNameShortcut: options.website_name_shortcut, websiteNameShortcut: options.website_name_shortcut,
@@ -23,7 +23,7 @@ siteRoutes.get("/site", async (c) => {
submissionListShowAll: options.submission_list_show_all, submissionListShowAll: options.submission_list_show_all,
classList: options.class_list, classList: options.class_list,
enableMaxkb: options.enable_maxkb, enableMaxkb: options.enable_maxkb,
})) } satisfies WebsiteConfig)
}) })
/** /**
@@ -31,7 +31,7 @@ siteRoutes.get("/site", async (c) => {
* 而榜单页本身就允许匿名看。谁在线是另一回事,只在 /rankings/users 里对老师下发。 * 而榜单页本身就允许匿名看。谁在线是另一回事,只在 /rankings/users 里对老师下发。
*/ */
siteRoutes.get("/site/online", async (c) => { siteRoutes.get("/site/online", async (c) => {
return success(c, onlineCountSchema.parse({ count: await onlineCount() })) return success(c, { count: await onlineCount() } satisfies OnlineCount)
}) })
// 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里) // 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里)
@@ -47,11 +47,6 @@ const fallbackQuotes = [
let categoryPaths: string[] | null = null let categoryPaths: string[] | null = null
const sentenceCache = new Map<string, Quote[]>() const sentenceCache = new Map<string, Quote[]>()
interface Quote {
hitokoto: string
from: string
}
async function loadSentences(path: string) { async function loadSentences(path: string) {
const cached = sentenceCache.get(path) const cached = sentenceCache.get(path)
if (cached) return cached if (cached) return cached
@@ -78,10 +73,10 @@ async function randomQuote() {
siteRoutes.get("/quotes/random", async (c) => { siteRoutes.get("/quotes/random", async (c) => {
try { try {
return success(c, quoteSchema.parse(await randomQuote())) return success(c, (await randomQuote()) satisfies Quote)
} catch { } catch {
const item = fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]! const item = fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]!
return success(c, quoteSchema.parse(item)) return success(c, item satisfies Quote)
} }
}) })

View File

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

View File

@@ -1,4 +1,4 @@
import { sessionUserSchema, userProfileSchema } from "@oj2/contract" import type { SessionUser, UserProfile } from "@oj2/contract"
import { and, eq } from "drizzle-orm" import { and, eq } from "drizzle-orm"
import { db, schema } from "../db" import { db, schema } from "../db"
@@ -12,9 +12,9 @@ export async function getUserProfileById(userId: number, showRealName: boolean)
.limit(1) .limit(1)
if (!row) return null if (!row) return null
return userProfileSchema.parse({ return {
id: row.profile.id, id: row.profile.id,
user: sessionUserSchema.parse({ user: {
id: row.user.id, id: row.user.id,
username: row.user.username, username: row.user.username,
email: row.user.email, email: row.user.email,
@@ -24,12 +24,12 @@ export async function getUserProfileById(userId: number, showRealName: boolean)
lastLogin: row.user.lastLogin, lastLogin: row.user.lastLogin,
isDisabled: row.user.isDisabled, isDisabled: row.user.isDisabled,
className: row.user.className, className: row.user.className,
}), } satisfies SessionUser,
realName: showRealName ? row.profile.realName : null, realName: showRealName ? row.profile.realName : null,
acmProblemsStatus: row.profile.acmProblemsStatus, acmProblemsStatus: row.profile.acmProblemsStatus,
avatar: row.profile.avatar, avatar: row.profile.avatar,
mood: row.profile.mood, mood: row.profile.mood,
acceptedNumber: row.profile.acceptedNumber, acceptedNumber: row.profile.acceptedNumber,
submissionNumber: row.profile.submissionNumber, submissionNumber: row.profile.submissionNumber,
}) } satisfies UserProfile
} }

View File

@@ -1,4 +1,4 @@
import { flowchartUpdateSchema, submissionUpdateSchema } from "@oj2/contract" import { submissionUpdateSchema, type FlowchartUpdate } from "@oj2/contract"
import { and, eq } from "drizzle-orm" import { and, eq } from "drizzle-orm"
import { touchSession } from "./auth/session" import { touchSession } from "./auth/session"
@@ -345,11 +345,11 @@ async function handleMessage(
return return
} }
const replay = flowchart.status === 2 const replay = flowchart.status === 2
? { type: "flowchart_evaluation_completed", submissionId: flowchart.id, score: flowchart.score ?? undefined, grade: flowchart.grade ?? undefined } ? { type: "flowchart_evaluation_completed" as const, submissionId: flowchart.id, score: flowchart.score ?? undefined, grade: flowchart.grade ?? undefined }
: flowchart.status === 3 : flowchart.status === 3
? { type: "flowchart_evaluation_failed", submissionId: flowchart.id } ? { type: "flowchart_evaluation_failed" as const, submissionId: flowchart.id }
: { type: "flowchart_evaluation_update", submissionId: flowchart.id } : { type: "flowchart_evaluation_update" as const, submissionId: flowchart.id }
ws.send(JSON.stringify(flowchartUpdateSchema.parse(replay))) ws.send(JSON.stringify(replay satisfies FlowchartUpdate))
return return
} }

View File

@@ -120,9 +120,12 @@ return contract("GET /problems/:id", problemDetailSchema, value)
- `exercise.data` 按题型收紧后,后端读路径(`routes/content.ts` 硬 parse变成 - `exercise.data` 按题型收紧后,后端读路径(`routes/content.ts` 硬 parse变成
一道闸,一行脏数据能让整条练习列表 500。 一道闸,一行脏数据能让整条练习列表 500。
所以:**同一个 schema 后端也在 `parse`**`submissionDetailSchema` / **后端出参已经不 `parse` 了**(原来 136 处,全部改成 `satisfies`;撤的时候炸出两个
`exerciseSchema` / `contestRankItemSchema` 都是),收紧任何字段之前,拿根目录 一直存在的线上 500`../CLAUDE.md` 的「出参不 `parse`,用 `satisfies`」)。
那份生产备份把全量数据跑一遍,尤其要看**空值**而不只是键集合。 所以现在收紧一个字段的直接后果落在 **`tsc` 编译期**而不再是运行时 500 —— 这是好事,
但别因此就放心大胆收:契约里的形状仍然要对得上库里的存量数据,前端拿到对不上的值
一样会渲染错。收紧任何字段之前,拿根目录那份生产备份把全量数据跑一遍,
尤其要看**空值**而不只是键集合。
### Key Utilities ### Key Utilities

View File

@@ -6,6 +6,7 @@ import { achievementRaritySchema } from "./achievement"
import { rankProfileSchema } from "./account" import { rankProfileSchema } from "./account"
import { paginatedSchema, sampleUserSchema } from "./common" import { paginatedSchema, sampleUserSchema } from "./common"
import { reactionKeySchema } from "./content" import { reactionKeySchema } from "./content"
import { problemLanguageSchema } from "./language"
import { import {
astRulesSchema, astRulesSchema,
problemDifficultySchema, problemDifficultySchema,
@@ -671,7 +672,12 @@ export const adminProblemSchema = z.object({
languages: z.array(z.string()), languages: z.array(z.string()),
template: z.record(z.string(), z.string()), template: z.record(z.string(), z.string()),
createTime: z.string(), createTime: z.string(),
lastUpdateTime: z.string(), /**
* `problem.last_update_time` 是全库唯一可空的那一列961 道题里 470 道是 NULL
* 从来没被编辑过的老题)。公共的 problemDetailSchema 早就是 nullable这里漏了
* 于是后台打开任何一道没编辑过的题都会 500 在 parse 上。
*/
lastUpdateTime: z.string().nullable(),
timeLimit: z.number().int(), timeLimit: z.number().int(),
memoryLimit: z.number().int(), memoryLimit: z.number().int(),
visible: z.boolean(), visible: z.boolean(),
@@ -718,7 +724,9 @@ export const createProblemRequestSchema = z.object({
testCaseScore: z.array(problemTestCaseScoreSchema), testCaseScore: z.array(problemTestCaseScoreSchema),
timeLimit: z.number().int().min(1).max(1000 * 60), timeLimit: z.number().int().min(1).max(1000 * 60),
memoryLimit: z.number().int().min(1).max(1024), memoryLimit: z.number().int().min(1).max(1024),
languages: z.array(z.string()).min(1), // 收窄到语言联合而不是裸 string[]`problem.languages` 列上挂着
// `$type<ProblemLanguage[]>()`,那个断言得有人兑现 —— 闸就设在这里(写入侧)。
languages: z.array(problemLanguageSchema).min(1),
template: z.record(z.string(), z.string()), template: z.record(z.string(), z.string()),
visible: z.boolean(), visible: z.boolean(),
difficulty: z.enum(["Low", "Mid", "High"]), difficulty: z.enum(["Low", "Mid", "High"]),

View File

@@ -149,7 +149,10 @@ export const submissionDetailSchema = z.object({
* 将来有人「顺手」把空值改成真值就不会变成泄露,因为这里压根没有这些字段。 * 将来有人「顺手」把空值改成真值就不会变成泄露,因为这里压根没有这些字段。
*/ */
export const embeddedSubmissionSchema = submissionDetailSchema export const embeddedSubmissionSchema = submissionDetailSchema
.omit({ info: true, contestId: true, problemId: true }) // problemDisplayId 也要去掉:下面的 problem 就是它,同一个值留两份,
// 而路由只填了 problem —— 这里漏 omit 的那阵子,凡是收到过站内信的人
// 打开消息页都是 500parse 抛在缺失的 problemDisplayId 上,列表为空时才碰巧不炸)。
.omit({ info: true, contestId: true, problemId: true, problemDisplayId: true })
// 旧 SubmissionSafeModelSerializer 里 problem 是 // 旧 SubmissionSafeModelSerializer 里 problem 是
// `SlugRelatedField(slug_field="_id")`,即**展示用题号**而非数字主键。 // `SlugRelatedField(slug_field="_id")`,即**展示用题号**而非数字主键。
// 站内信页面拿它拼 `/problem/<题号>` 链接,给数字 id 会拼出打不开的地址。 // 站内信页面拿它拼 `/problem/<题号>` 链接,给数字 id 会拼出打不开的地址。