diff --git a/CLAUDE.md b/CLAUDE.md index d4b191c..b8fedcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,26 +106,46 @@ dev 直接起不来。 这些整数是**落库的值**:12 万条历史提交的 `submission.result` 就是它们,判题沙箱回的也是 这套编码,所以只能新增、不能改已有的含义。题目表情 reaction 的语义 key 同理。 -### 契约收紧要挑地方:闸在写入侧,不在读出侧 +### 出参不 `parse`,用 `satisfies` -`packages/contract` 的 schema 前后端共用,而且**后端在读路径上 `parse`** -(`submissionDetailSchema` / `exerciseSchema` / `contestRankItemSchema` 都是)。 -所以收紧一个字段不只是「类型更准」,是给全部历史数据加了一道闸: +**后端的响应一律 `satisfies XxxType`,不要写 `xxxSchema.parse({...})`。** +出参是后端自己刚拼出来的字面量,TS 已经在编译期校验过;再 `parse` 一遍拿不到任何新 +信息,唯一可能失败的输入是**库里的历史数据**,而失败的代价是 500。这一层原来有 136 处, +已经全部撤掉,撤的时候当场炸出两个一直存在的线上 500: -- 对不上就 500 —— `exerciseSchema` 按题型收紧过,库里一行脏数据能让整条学生练习 - 列表打不开,坏的不是那一道题; -- 更坏的是**不 500**:`info` 当时写成 `union([完整形状, z.object({})])`,对不上的 - 一律落进空对象那支且 parse 成功,管理员详情页的测试点表格**静默消失**。全量核出来 - 9163/124192 条中招,RE 8480/8480、TLE 338/338、MLE 1/1 全中 —— 沙箱在非正常退出 - 的测试点上写 `output_md5: null`,而契约写的是 `z.string()`。 +- `adminProblemSchema.lastUpdateTime` 写的是 `z.string()`,但 `problem.last_update_time` + 是全库唯一可空的列(961 道题里 470 道是 NULL)——**后台打开任何一道没编辑过的老题都是 500**; +- `embeddedSubmissionSchema` 从 `submissionDetailSchema` 继承了 `problemDisplayId` 却没 + omit,而路由只填了同义的 `problem`——**凡是收到过站内信的人,消息页都打不开**(列表为空 + 时才碰巧不炸,所以一直没人报)。 -所以:**JSONB 原文(`submission.info` / `statistic_info` / `exercise.data`)的形状 -真相在写入侧** —— 判题机、`services/exercise.ts` 的 `exerciseDataError` —— 闸就设在 -那里,读出侧放行。下一条 AST 规则的 `astRulesError()` 是同一个道理的另一个实例。 +两个都是「读出侧校验」自己造出来的故障,不是它拦住的故障。历史上还有两次同类: +`exerciseSchema` 按题型收紧后一行脏数据让整条练习列表 500;`info` 写成 +`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 代码规则有两张表,必须同增同减 diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 51acabb..0217f9c 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -15,6 +15,17 @@ // 详见 CLAUDE.md「改 schema 走 drizzle migration」): // * bigint identity 的 maxValue 用字符串,不能写成 JS number 字面量(会丢精度)。 // * 索引不写 `.desc()`,生成 SQL 时方向会被丢掉。 +// * `.$type<...>()` 的收窄(见下)—— `pull` 只会吐出 text/integer/jsonb,重新 pull +// 会把这些断言全抹掉,之后出参的 `satisfies` 会当场编译不过(这是好事,别拿 +// `as` 糊过去,把断言补回来)。 +// +// 2026-09-10:出参不再 `xxxSchema.parse()` 而是 `satisfies`(原来 136 处), +// 收窄的责任因此挪到了列上:枚举型的列和几个形状确定的 JSONB 都挂了 `.$type<>()`。 +// `$type` 只是 TS 层的断言、不产生任何 SQL,所以它成立与否得靠数据说话 —— +// 下面每一处都拿根目录那份生产备份逐列核过(12.4 万条提交的 result 全在 -2..6,10、 +// 961 道题的 languages 全是合法数组、10050 条榜单条目形状全对,无一例外)。 +// **再给别的列加 $type 之前,照样先核一遍全量数据。** 兑现这些断言的是写入侧的 +// `safeParse`,不是读出侧。前因后果见 CLAUDE.md「出参不 `parse`,用 `satisfies`」。 // // 关于 10 张表的 bigint id(problemset*、achievement、user_achievement、user_stat、 // user_badge、ai_analysis):这是历史巧合不是设计——这些 app 的 0001_initial 生成时 @@ -22,7 +33,26 @@ // problem、contest、submission)都是 int4。现存最大 id 一万出头,确实都用不上 bigint, // 但 2026-08-26 评估后决定**不改**:省 4 字节/行毫无意义,ALTER TYPE 要重写整表并拿 // ACCESS EXCLUSIVE 锁,而且其中 6 处 id 被外键绑着得连坐。别再提这件事了。 -import type { AdminType, ProblemPermission } from "@oj2/contract" +import type { + AchievementOperator, + AchievementRarity, + AdminType, + AstRules, + BadgeConditionType, + ContestSubmissionInfo, + ExerciseType, + FlowchartStatus, + JudgeStatus, + ProblemDifficulty, + ProblemLanguage, + ProblemPermission, + ProblemSetDifficulty, + ProblemSetStatus, + ReactionKey, + SqlConfig, + SqlDisplay, + TutorialType, +} from "@oj2/contract" import { pgTable, index, foreignKey, primaryKey, bigint, text, jsonb, timestamp, integer, boolean, serial, doublePrecision, varchar, unique, uniqueIndex } from "drizzle-orm/pg-core" import { sql } from "drizzle-orm" @@ -75,10 +105,10 @@ export const achievement = pgTable("achievement", { name: text().notNull(), description: text().notNull(), icon: text().notNull(), - rarity: text().notNull(), + rarity: text().notNull().$type(), hidden: boolean().default(false).notNull(), metric: text().notNull(), - operator: text().notNull(), + operator: text().notNull().$type(), threshold: integer().notNull(), visible: boolean().default(true).notNull(), unlockCount: integer("unlock_count").default(0).notNull(), @@ -111,7 +141,7 @@ export const flowchartSubmission = pgTable("flowchart_submission", { id: text().primaryKey().notNull(), mermaidCode: text("mermaid_code").notNull(), flowchartData: jsonb("flowchart_data").notNull(), - status: integer().notNull(), + status: integer().notNull().$type(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), aiScore: doublePrecision("ai_score"), aiGrade: varchar("ai_grade", { length: 10 }), @@ -189,7 +219,7 @@ export const judgeServer = pgTable("judge_server", { export const exercise = pgTable("exercise", { id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "exercise_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }), - type: varchar({ length: 16 }).notNull(), + type: varchar({ length: 16 }).notNull().$type(), data: jsonb().notNull(), order: integer().notNull(), createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).notNull(), @@ -219,8 +249,8 @@ export const problemset = pgTable("problemset", { createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }).notNull(), visible: boolean().notNull(), - difficulty: text().notNull(), - status: text().notNull(), + difficulty: text().notNull().$type(), + status: text().notNull().$type(), createdById: integer("created_by_id").notNull(), endTime: timestamp("end_time", { withTimezone: true, mode: 'string' }), }, (table) => [ @@ -324,7 +354,7 @@ export const problemsetSubmission = pgTable("problemset_submission", { export const reaction = pgTable("reaction", { id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "reaction_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }), - type: varchar({ length: 20 }).notNull(), + type: varchar({ length: 20 }).notNull().$type(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), problemId: integer("problem_id").notNull(), userId: integer("user_id").notNull(), @@ -354,14 +384,14 @@ export const problem = pgTable("problem", { testCaseId: text("test_case_id").notNull(), testCaseScore: jsonb("test_case_score").notNull(), hint: text(), - languages: jsonb().notNull(), - template: jsonb().notNull(), + languages: jsonb().notNull().$type(), + template: jsonb().notNull().$type>(), createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }), timeLimit: integer("time_limit").notNull(), memoryLimit: integer("memory_limit").notNull(), visible: boolean().default(true).notNull(), - difficulty: text().notNull(), + difficulty: text().notNull().$type(), source: text(), // You can use { mode: "bigint" } if numbers are exceeding js number limitations submissionNumber: bigint("submission_number", { mode: "number" }).default(0).notNull(), @@ -386,9 +416,9 @@ export const problem = pgTable("problem", { flowchartHint: text("flowchart_hint"), mermaidCode: text("mermaid_code"), showFlowchart: boolean("show_flowchart").default(false).notNull(), - astRules: jsonb("ast_rules"), - sqlConfig: jsonb("sql_config"), - sqlDisplay: jsonb("sql_display"), + astRules: jsonb("ast_rules").$type(), + sqlConfig: jsonb("sql_config").$type(), + sqlDisplay: jsonb("sql_display").$type(), }, (table) => [ index("problem_contest_visible_idx").using("btree", table.contestId.asc().nullsLast().op("bool_ops"), table.visible.asc().nullsLast().op("int4_ops")), index("problem_created_by_id_cb362143").using("btree", table.createdById.asc().nullsLast().op("int4_ops")), @@ -439,9 +469,9 @@ export const submission = pgTable("submission", { createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(), userId: integer("user_id").notNull(), code: text().notNull(), - result: integer().default(6).notNull(), + result: integer().default(6).notNull().$type(), info: jsonb().default({}).notNull(), - language: text().notNull(), + language: text().notNull().$type(), /** * 已停用,见 problem.share_submission 的说明。历史上 12.3 万条提交里有 40 条 * 为真(2022 年 39 条、2023 年 1 条),入口在更早的那版前端上,ojnext 和 OJ2 @@ -560,7 +590,7 @@ export const tutorial = pgTable("tutorial", { order: integer().notNull(), createdById: integer("created_by_id").notNull(), code: text(), - type: varchar({ length: 10 }).notNull(), + type: varchar({ length: 10 }).notNull().$type(), }, (table) => [ index("tutorial_created_by_id_07973cab").using("btree", table.createdById.asc().nullsLast().op("int4_ops")), foreignKey({ @@ -635,7 +665,7 @@ export const userBadge = pgTable("user_badge", { export const userProfile = pgTable("user_profile", { id: serial().primaryKey().notNull(), - acmProblemsStatus: jsonb("acm_problems_status").default({}).notNull(), + acmProblemsStatus: jsonb("acm_problems_status").default({}).notNull().$type>(), avatar: text().notNull(), mood: text(), acceptedNumber: integer("accepted_number").default(0).notNull(), @@ -656,7 +686,7 @@ export const acmContestRank = pgTable("acm_contest_rank", { submissionNumber: integer("submission_number").default(0).notNull(), acceptedNumber: integer("accepted_number").default(0).notNull(), totalTime: integer("total_time").default(0).notNull(), - submissionInfo: jsonb("submission_info").default({}).notNull(), + submissionInfo: jsonb("submission_info").default({}).notNull().$type>(), contestId: integer("contest_id").notNull(), userId: integer("user_id").notNull(), }, (table) => [ @@ -705,7 +735,7 @@ export const problemsetBadge = pgTable("problemset_badge", { name: text().notNull(), description: text().notNull(), icon: text().notNull(), - conditionType: text("condition_type").notNull(), + conditionType: text("condition_type").notNull().$type(), conditionValue: integer("condition_value").notNull(), // You can use { mode: "bigint" } if numbers are exceeding js number limitations problemsetId: bigint("problemset_id", { mode: "number" }).notNull(), diff --git a/apps/api/src/events.ts b/apps/api/src/events.ts index 3db2d5a..f05eed2 100644 --- a/apps/api/src/events.ts +++ b/apps/api/src/events.ts @@ -1,4 +1,4 @@ -import { flowchartUpdateSchema, type FlowchartUpdate } from "@oj2/contract" +import type { FlowchartUpdate } from "@oj2/contract" import { redis } from "./redis" @@ -72,7 +72,7 @@ export function userEventTopic(userId: number) { } export async function publishFlowchartUpdate(userId: number, data: FlowchartUpdate) { - await redis.publish(userEventChannel, JSON.stringify({ userId, data: flowchartUpdateSchema.parse(data) })) + await redis.publish(userEventChannel, JSON.stringify({ userId, data })) } export async function publishAchievementNotification( diff --git a/apps/api/src/flowchart/run.ts b/apps/api/src/flowchart/run.ts index 59874f4..a269b23 100644 --- a/apps/api/src/flowchart/run.ts +++ b/apps/api/src/flowchart/run.ts @@ -1,4 +1,4 @@ -import { flowchartUpdateSchema } from "@oj2/contract" +import type { FlowchartUpdate } from "@oj2/contract" import { eq } from "drizzle-orm" import { db, schema } from "../db" @@ -71,7 +71,7 @@ export async function evaluateFlowchart( processingTime: (performance.now() - started) / 1000, evaluationTime: new Date().toISOString(), }).where(eq(schema.flowchartSubmission.id, row.flowchart.id)) - await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({ + await publishFlowchartUpdate(row.flowchart.userId, { type: "flowchart_evaluation_completed", submissionId: row.flowchart.id, score: result.score, @@ -79,7 +79,7 @@ export async function evaluateFlowchart( feedback: result.feedback, suggestions: result.suggestions, criteriaDetails: result.criteria, - })) + } satisfies FlowchartUpdate) } catch (error) { // 原来这里把 error.message 原样推给学生、前端还直接 message.error 弹出来 —— // AI provider 的地址、内部报错就这么进了浏览器。真实原因留在服务端日志里, @@ -91,10 +91,10 @@ export async function evaluateFlowchart( // 就算成功,AI 侧的偶发失败(限流、超时、网络抖动)永远等不到重试。 if (!isFinalAttempt) throw error await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id)) - await publishFlowchartUpdate(row.flowchart.userId, flowchartUpdateSchema.parse({ + await publishFlowchartUpdate(row.flowchart.userId, { type: "flowchart_evaluation_failed", submissionId: row.flowchart.id, - })) + } satisfies FlowchartUpdate) throw error } } diff --git a/apps/api/src/judge/events.ts b/apps/api/src/judge/events.ts index ba4bec7..d45a865 100644 --- a/apps/api/src/judge/events.ts +++ b/apps/api/src/judge/events.ts @@ -1,7 +1,4 @@ -import { - submissionUpdateSchema, - type SubmissionUpdate, -} from "@oj2/contract" +import { submissionUpdateSchema, type SubmissionUpdate } from "@oj2/contract" import { redis } from "../redis" @@ -20,10 +17,7 @@ export async function publishSubmissionUpdate( userId: number, data: SubmissionUpdate, ) { - const event: SubmissionEvent = { - userId, - data: submissionUpdateSchema.parse(data), - } + const event: SubmissionEvent = { userId, data } await redis.publish(submissionUpdateChannel, JSON.stringify(event)) } diff --git a/apps/api/src/judge/run.ts b/apps/api/src/judge/run.ts index 62a974e..f5a7142 100644 --- a/apps/api/src/judge/run.ts +++ b/apps/api/src/judge/run.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto" -import { astRuleSchema } from "@oj2/contract" +import { astRuleSchema, type ContestSubmissionInfo } from "@oj2/contract" import { and, eq, inArray } from "drizzle-orm" import { config } from "../config" @@ -248,15 +248,12 @@ async function persistResult( .for("update") if (!rank) throw new Error("Contest rank could not be created") - const rankInfo = objectValue(rank.submissionInfo) - const previousInfo = objectValue(rankInfo[String(problemId)]) - const alreadyAccepted = previousInfo.is_ac === true + const rankInfo = rank.submissionInfo + const previousInfo = rankInfo[String(problemId)] + const alreadyAccepted = previousInfo?.is_ac === true if (!alreadyAccepted) { - const errorNumber = - typeof previousInfo.error_number === "number" - ? previousInfo.error_number - : 0 - const nextInfo: Record = { + const errorNumber = previousInfo?.error_number ?? 0 + const nextInfo: ContestSubmissionInfo = { is_ac: acceptedNow, ac_time: 0, error_number: diff --git a/apps/api/src/judge/sql/index.ts b/apps/api/src/judge/sql/index.ts index 53e46e0..1963cba 100644 --- a/apps/api/src/judge/sql/index.ts +++ b/apps/api/src/judge/sql/index.ts @@ -1,3 +1,5 @@ +import type { SqlDisplay } from "@oj2/contract" + import { selfCommand } from "../../runtime" import { JudgeStatus, type JudgeStatusValue } from "../status" import { DISPLAY_BUDGET_MS, trustedBudgetMs, type CaseResult } from "./engine" @@ -175,7 +177,9 @@ export function runSqlCase(job: Extract) { } export function buildSqlDisplay(initSql: string, refSql: string, mode: "query" | "modify") { - return runJob<{ tables: unknown[]; expected: unknown }>( + // 子进程产出的形状由 engine.ts 的 dumpDisplayTables / runDisplay 决定,就是契约里的 + // SqlDisplay —— 同一个仓库里的两端,不在这儿再 parse 一遍 + return runJob( { kind: "display", initSql, refSql, mode }, { trustedMs: DISPLAY_BUDGET_MS, studentMs: null }, ) diff --git a/apps/api/src/judge/status.ts b/apps/api/src/judge/status.ts index cf22c9b..553c0b8 100644 --- a/apps/api/src/judge/status.ts +++ b/apps/api/src/judge/status.ts @@ -48,14 +48,14 @@ export function judgeStatusName(result: number) { * 它们从分母里摘掉 —— 否则全班同时交卷的那几秒,分母涨了分子没涨,正确率凭空掉一截。 * 人数口径不受影响:交了但还在判的学生仍然算「交过」,不该被点名成「没做」。 */ -export const UNJUDGED_RESULTS: number[] = [JudgeStatus.PENDING, JudgeStatus.JUDGING] +export const UNJUDGED_RESULTS: JudgeStatusValue[] = [JudgeStatus.PENDING, JudgeStatus.JUDGING] /** * **不**计入「这道题失败了几次」的状态。除了通过(含 AST_CHECK_FAILED,那也是答案对了) * 和还没判完的两个,还排掉 SYSTEM_ERROR —— 判题机自己崩了不是学生的问题, * 不该推着 AI 提示的解锁进度往前走。 */ -export const NON_FAILURE_RESULTS: number[] = [ +export const NON_FAILURE_RESULTS: JudgeStatusValue[] = [ JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED, JudgeStatus.PENDING, diff --git a/apps/api/src/routes/account.ts b/apps/api/src/routes/account.ts index 78a5c16..ceb4fb9 100644 --- a/apps/api/src/routes/account.ts +++ b/apps/api/src/routes/account.ts @@ -2,15 +2,16 @@ import { randomBytes } from "node:crypto" import { extname, resolve } from "node:path" import { - STUDENT_ROLES, - activityRankItemSchema, - metricsSchema, - problemRankSchema, - myRankSchema, rankProfileSchema, registerRequestSchema, + STUDENT_ROLES, updateProfileRequestSchema, - userRankSchema, + type ActivityRankItem, + type Metrics, + type MyRank, + type ProblemRank, + type RankProfile, + type UserRank, } from "@oj2/contract" import { and, @@ -144,7 +145,7 @@ accountRoutes.get("/users/:id/metrics", async (c) => { .from(schema.submission) .where(and(eq(schema.submission.userId, userId), isNull(schema.submission.contestId))) if (!row?.total || !row.first || !row.latest) return failure(c, 404, "no-submissions", "暂无提交") - return success(c, metricsSchema.parse({ now: new Date().toISOString(), first: row.first, latest: row.latest })) + return success(c, { now: new Date().toISOString(), first: row.first, latest: row.latest } satisfies Metrics) }) /** @@ -199,25 +200,25 @@ accountRoutes.get("/rankings/users", optionalAuth, async (c) => { isTeacherOrAbove(c.get("user")) ? onlineUserIds() : null, ]) - return success(c, userRankSchema.parse({ + return success(c, { results: rows.map((row) => serializeRankRow(row, online)), total: Math.min(totalRow?.value ?? 0, LEADERBOARD_SIZE), me, - })) + } satisfies UserRank) }) function serializeRankRow({ profile, user }: { profile: typeof schema.userProfile.$inferSelect user: typeof schema.user.$inferSelect }, online: Set | null = null) { - return rankProfileSchema.parse({ + return { id: profile.id, user: sampleUser(user, profile.realName), acceptedNumber: profile.acceptedNumber, submissionNumber: profile.submissionNumber, mood: profile.mood, isOnline: online ? online.has(user.id) : null, - }) + } satisfies RankProfile } /** @@ -251,10 +252,10 @@ async function myLeaderboardRank(userId: number | undefined) { ), ))) - return myRankSchema.parse({ + return { ...serializeRankRow(mine), rank: (ahead?.value ?? 0) + 1, - }) + } satisfies MyRank } accountRoutes.get("/rankings/activity", async (c) => { @@ -279,7 +280,7 @@ accountRoutes.get("/rankings/activity", async (c) => { )) .groupBy(schema.submission.userId, schema.user.username) .orderBy(desc(countDistinct(schema.submission.problemId))).limit(10) - return success(c, rows.map((row) => activityRankItemSchema.parse({ username: row.username, count: row.value }))) + return success(c, rows.map((row) => ({ username: row.username, count: row.value } satisfies ActivityRankItem))) }) accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => { @@ -303,7 +304,7 @@ accountRoutes.get("/problems/:displayId/rank", requireAuth, async (c) => { const [rankRow] = await db.select({ value: count() }).from(schema.submission).where(and(classWhere, lte(schema.submission.createTime, first.value))) rank = rankRow?.value ?? -1 } - return success(c, problemRankSchema.parse({ className, rank, classAcCount: classCount?.value ?? 0, allAcCount: all?.value ?? 0 })) + return success(c, { className, rank, classAcCount: classCount?.value ?? 0, allAcCount: all?.value ?? 0 } satisfies ProblemRank) }) /** diff --git a/apps/api/src/routes/achievement.ts b/apps/api/src/routes/achievement.ts index 82db138..29dd3ec 100644 --- a/apps/api/src/routes/achievement.ts +++ b/apps/api/src/routes/achievement.ts @@ -1,9 +1,9 @@ import { - achievementListSchema, - achievementSchema, - achievementSummarySchema, markAchievementsReadSchema, - pendingAchievementSchema, + type Achievement, + type AchievementList, + type AchievementSummary, + type PendingAchievement, } from "@oj2/contract" import { and, asc, count, desc, eq, inArray } from "drizzle-orm" import { Hono } from "hono" @@ -27,13 +27,13 @@ async function resolveUser(requested: string | undefined, currentId: number) { } function pendingData(row: { achievement: typeof schema.achievement.$inferSelect }) { - return pendingAchievementSchema.parse({ + return { id: row.achievement.id, name: row.achievement.name, description: row.achievement.description, icon: row.achievement.icon, rarity: row.achievement.rarity, - }) + } satisfies PendingAchievement } achievementRoutes.get("/achievements", requireAuth, async (c) => { @@ -52,7 +52,7 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => { const record = unlocked.get(achievement.id) const masked = achievement.hidden && !record const progress = metrics[achievement.metric] - return achievementSchema.parse({ + return { id: achievement.id, name: masked ? "???" : achievement.name, description: masked ? "达成条件保密" : achievement.description, @@ -67,9 +67,9 @@ achievementRoutes.get("/achievements", requireAuth, async (c) => { backfilled: record?.backfilled ?? false, progress: masked ? null : typeof progress === "number" ? progress : 0, unlockRate: active > 0 ? Math.round(achievement.unlockCount / active * 1000) / 10 : 0, - }) + } satisfies Achievement }) - return success(c, achievementListSchema.parse({ username: target.username, achievements: result })) + return success(c, { username: target.username, achievements: result } satisfies AchievementList) }) achievementRoutes.get("/achievements/summary", requireAuth, async (c) => { @@ -85,7 +85,7 @@ achievementRoutes.get("/achievements/summary", requireAuth, async (c) => { const rarities = ["bronze", "silver", "gold", "platinum"] as const const total = achievements.length const unlocked = unlockedRows.length - return success(c, achievementSummarySchema.parse({ + return success(c, { username: target.username, total, unlocked, @@ -97,7 +97,7 @@ achievementRoutes.get("/achievements/summary", requireAuth, async (c) => { unlocked: unlockedRows.filter((item) => item.achievement.rarity === rarity).length, })), recent: unlockedRows.slice(0, 10).map(pendingData), - })) + } satisfies AchievementSummary) }) achievementRoutes.get("/achievements/pending", requireAuth, async (c) => { diff --git a/apps/api/src/routes/admin/account.ts b/apps/api/src/routes/admin/account.ts index f42c1f1..537b2fb 100644 --- a/apps/api/src/routes/admin/account.ts +++ b/apps/api/src/routes/admin/account.ts @@ -1,16 +1,16 @@ import { - STUDENT_ROLES, adminTypeSchema, - adminUserListSchema, - adminUserRankSchema, - adminUserSchema, deleteUsersRequestSchema, importUsersRequestSchema, - rankProfileSchema, - resetPasswordResponseSchema, + STUDENT_ROLES, updateUserRequestSchema, type AdminType, + type AdminUser, + type AdminUserList, + type AdminUserRank, type ProblemPermission, + type RankProfile, + type ResetPasswordResponse, } from "@oj2/contract" import { randomInt } from "node:crypto" import { z } from "zod" @@ -66,7 +66,7 @@ function serialize(row: { user: typeof schema.user.$inferSelect realName: string | null }, isOnline: boolean) { - return adminUserSchema.parse({ + return { id: row.user.id, username: row.user.username, email: row.user.email, @@ -79,7 +79,7 @@ function serialize(row: { isOnline, rawPassword: row.user.rawPassword, className: row.user.className, - }) + } satisfies AdminUser } function selectUser(id: number) { @@ -122,16 +122,19 @@ adminAccountRoutes.get("/rankings/users", requireSuperAdmin, async (c) => { .limit(limit).offset(offset), ]) - return success(c, adminUserRankSchema.parse({ - results: rows.map(({ profile, user }) => rankProfileSchema.parse({ + return success(c, { + results: rows.map(({ profile, user }) => ({ id: profile.id, user: sampleUser(user, profile.realName), acceptedNumber: profile.acceptedNumber, submissionNumber: profile.submissionNumber, mood: profile.mood, - })), + // 这张榜不下发在线状态(null = 「调用方不该知道」,见契约里 isOnline 的注释)。 + // 原来是靠 schema 的 .default(null) 填出来的,改成显式写死。 + isOnline: null, + } satisfies RankProfile)), total: totalRows[0]?.value ?? 0, - })) + } satisfies AdminUserRank) }) adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => { @@ -181,10 +184,10 @@ adminAccountRoutes.get("/users", requireSuperAdmin, async (c) => { .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)).where(where) .orderBy(...order, asc(schema.user.id)).limit(limit).offset(offset), ]) - return success(c, adminUserListSchema.parse({ + return success(c, { results: rows.map((row) => serialize(row, online.has(row.user.id))), total: totalRows[0]?.value ?? 0, - })) + } satisfies AdminUserList) }) adminAccountRoutes.get("/users/:id", requireSuperAdmin, async (c) => { @@ -447,5 +450,5 @@ adminAccountRoutes.post("/users/:id/reset-password", requireSuperAdmin, async (c }).where(eq(schema.user.id, id)) // 旧密码登出来的会话立刻作废,理由同 PUT /users/:id await revokeUserSessions(id, "session-ended") - return success(c, resetPasswordResponseSchema.parse({ password })) + return success(c, { password } satisfies ResetPasswordResponse) }) diff --git a/apps/api/src/routes/admin/achievement.ts b/apps/api/src/routes/admin/achievement.ts index 454656a..4c6d431 100644 --- a/apps/api/src/routes/admin/achievement.ts +++ b/apps/api/src/routes/admin/achievement.ts @@ -1,8 +1,8 @@ import { - achievementMetricSchema, - adminAchievementSchema, createAchievementRequestSchema, updateAchievementRequestSchema, + type AchievementMetric, + type AdminAchievement, } from "@oj2/contract" import { asc, eq } from "drizzle-orm" import { Hono } from "hono" @@ -17,7 +17,7 @@ import { queryInteger } from "../helpers" export const adminAchievementRoutes = new Hono() function serialize(row: typeof schema.achievement.$inferSelect) { - return adminAchievementSchema.parse({ + return { id: row.id, name: row.name, description: row.description, @@ -32,12 +32,12 @@ function serialize(row: typeof schema.achievement.$inferSelect) { unlockCount: row.unlockCount, order: row.order, createTime: row.createTime, - }) + } satisfies AdminAchievement } /** 下拉框的可选项就是代码里注册了什么,见 services/achievement-metrics.ts 的说明 */ adminAchievementRoutes.get("/achievement-metrics", requireSuperAdmin, (c) => - success(c, ACHIEVEMENT_METRICS.map((item) => achievementMetricSchema.parse(item)))) + success(c, ACHIEVEMENT_METRICS satisfies AchievementMetric[])) adminAchievementRoutes.get("/achievements", requireSuperAdmin, async (c) => { const rows = await db.select().from(schema.achievement) diff --git a/apps/api/src/routes/admin/ai.ts b/apps/api/src/routes/admin/ai.ts index 1acb996..b2360bb 100644 --- a/apps/api/src/routes/admin/ai.ts +++ b/apps/api/src/routes/admin/ai.ts @@ -1,8 +1,8 @@ -import { - adminAiReportListSchema, - adminAiReportListItemSchema, - adminAiReportSchema, - toggleAiReportPinResponseSchema, +import type { + AdminAiReport, + AdminAiReportList, + AdminAiReportListItem, + ToggleAiReportPinResponse, } from "@oj2/contract" import { and, count, desc, eq, ilike } from "drizzle-orm" import { Hono } from "hono" @@ -22,13 +22,13 @@ function excerpt(analysis: string | null) { } function listItem(row: { id: number; username: string; createTime: string; analysis: string; isPinned: boolean }) { - return adminAiReportListItemSchema.parse({ + return { id: row.id, username: row.username, createTime: row.createTime, analysisExcerpt: excerpt(row.analysis), isPinned: row.isPinned, - }) + } satisfies AdminAiReportListItem } const listColumns = { @@ -53,10 +53,10 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => { .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)) .where(and(eq(schema.aiAnalysis.isPinned, true), where)) .orderBy(desc(schema.aiAnalysis.createTime)) - return success(c, adminAiReportListSchema.parse({ + return success(c, { results: rows.map(listItem), total: rows.length, - })) + } satisfies AdminAiReportList) } const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) @@ -68,10 +68,10 @@ adminAiRoutes.get("/ai/reports", requireTeacher, async (c) => { .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)).where(where) .orderBy(desc(schema.aiAnalysis.createTime)).limit(limit).offset(offset), ]) - return success(c, adminAiReportListSchema.parse({ + return success(c, { results: rows.map(listItem), total: totalRows[0]?.value ?? 0, - })) + } satisfies AdminAiReportList) }) adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => { @@ -86,7 +86,7 @@ adminAiRoutes.get("/ai/reports/:id", requireTeacher, async (c) => { .where(eq(schema.aiAnalysis.id, queryInteger(c.req.param("id"), 0, { min: 1 }))).limit(1) if (!row) return failure(c, 404, "report-not-found", "AIAnalysis not found") // data / systemPrompt / userPrompt 一律不下发:里面是喂给模型的原始学情数据与提示词 - return success(c, adminAiReportSchema.parse(row)) + return success(c, row satisfies AdminAiReport) }) adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => { @@ -104,5 +104,5 @@ adminAiRoutes.post("/ai/reports/:id/pin", requireTeacher, async (c) => { } await tx.update(schema.aiAnalysis).set({ isPinned: next }).where(eq(schema.aiAnalysis.id, id)) }) - return success(c, toggleAiReportPinResponseSchema.parse({ isPinned: next })) + return success(c, { isPinned: next } satisfies ToggleAiReportPinResponse) }) diff --git a/apps/api/src/routes/admin/announcement.ts b/apps/api/src/routes/admin/announcement.ts index a975991..630542e 100644 --- a/apps/api/src/routes/admin/announcement.ts +++ b/apps/api/src/routes/admin/announcement.ts @@ -1,8 +1,8 @@ import { - adminAnnouncementListSchema, - adminAnnouncementSchema, createAnnouncementRequestSchema, updateAnnouncementRequestSchema, + type AdminAnnouncement, + type AdminAnnouncementList, } from "@oj2/contract" import { count, desc, eq } from "drizzle-orm" import { Hono } from "hono" @@ -19,7 +19,7 @@ function serialize(row: { user: typeof schema.user.$inferSelect realName: string | null }) { - return adminAnnouncementSchema.parse({ + return { id: row.announcement.id, title: row.announcement.title, tag: row.announcement.tag, @@ -29,7 +29,7 @@ function serialize(row: { createdBy: sampleUser(row.user, row.realName), createTime: row.announcement.createTime, lastUpdateTime: row.announcement.lastUpdateTime, - }) + } satisfies AdminAnnouncement } function selectOne(id: number) { @@ -55,11 +55,11 @@ adminAnnouncementRoutes.get("/announcements", requireSuperAdmin, async (c) => { .limit(limit) .offset(offset), ]) - return success(c, adminAnnouncementListSchema.parse({ + return success(c, { // 列表 schema omit 掉了 content,Zod 会 strip 掉多出来的键,这里不必手工再挑一遍 results: rows.map(serialize), total: totalRows[0]?.value ?? 0, - })) + } satisfies AdminAnnouncementList) }) adminAnnouncementRoutes.post("/announcements", requireSuperAdmin, async (c) => { diff --git a/apps/api/src/routes/admin/conf.ts b/apps/api/src/routes/admin/conf.ts index 2ad8337..6d3a6a5 100644 --- a/apps/api/src/routes/admin/conf.ts +++ b/apps/api/src/routes/admin/conf.ts @@ -1,12 +1,12 @@ import { - dashboardInfoSchema, - judgeServerListSchema, - judgeServerSchema, - orphanTestCaseSchema, updateJudgeServerRequestSchema, updateWebsiteConfigRequestSchema, - uploadImageResponseSchema, - websiteConfigSchema, + type DashboardInfo, + type JudgeServer, + type JudgeServerList, + type OrphanTestCase, + type UploadImageResponse, + type WebsiteConfig, } from "@oj2/contract" import { randomInt } from "node:crypto" import { mkdir, readdir, rm, stat } from "node:fs/promises" @@ -57,7 +57,7 @@ const OPTION_KEYS = { adminConfRoutes.get("/website", requireSuperAdmin, async (c) => { const options = await getWebsiteOptions() - return success(c, websiteConfigSchema.parse({ + return success(c, { websiteBaseUrl: options.website_base_url, websiteName: options.website_name, websiteNameShortcut: options.website_name_shortcut, @@ -66,7 +66,7 @@ adminConfRoutes.get("/website", requireSuperAdmin, async (c) => { submissionListShowAll: options.submission_list_show_all, classList: options.class_list, enableMaxkb: options.enable_maxkb, - })) + } satisfies WebsiteConfig) }) adminConfRoutes.post("/website", requireSuperAdmin, async (c) => { @@ -96,14 +96,14 @@ adminConfRoutes.post("/website", requireSuperAdmin, async (c) => { adminConfRoutes.get("/judge-servers", requireSuperAdmin, async (c) => { const rows = await db.select().from(schema.judgeServer).orderBy(desc(schema.judgeServer.lastHeartbeat)) - return success(c, judgeServerListSchema.parse({ + return success(c, { // 后台要显示 token 才能拿去配判题机。这个接口是超管专属的 token: config.judgeServerToken, - servers: rows.map((row) => judgeServerSchema.parse({ + servers: rows.map((row) => ({ ...row, status: isAlive(row.lastHeartbeat) ? "normal" : "abnormal", - })), - })) + } satisfies JudgeServer)), + } satisfies JudgeServerList) }) adminConfRoutes.put("/judge-servers/:id", requireSuperAdmin, async (c) => { @@ -146,7 +146,7 @@ adminConfRoutes.get("/orphan-test-cases", requireSuperAdmin, async (c) => { const ids = await orphanTestCaseIds() const rows = await Promise.all(ids.map(async (id) => { const info = await stat(resolve(config.testCaseDirectory, id)).catch(() => null) - return orphanTestCaseSchema.parse({ id, createTime: info ? info.mtimeMs / 1000 : 0 }) + return { id, createTime: info ? info.mtimeMs / 1000 : 0 } satisfies OrphanTestCase })) return success(c, rows) }) @@ -180,12 +180,12 @@ adminConfRoutes.get("/dashboard", requireSuperAdmin, async (c) => { .where(gte(schema.judgeServer.lastHeartbeat, aliveSince())), ]) // 旧接口还回了 env.FORCE_HTTPS / STATIC_CDN_HOST,前端从未读过,不再下发 - return success(c, dashboardInfoSchema.parse({ + return success(c, { userCount: users?.value ?? 0, todaySubmissionCount: submissions?.value ?? 0, recentContestCount: contests?.value ?? 0, judgeServerCount: servers?.value ?? 0, - })) + } satisfies DashboardInfo) }) adminConfRoutes.get("/random-usernames", requireSuperAdmin, async (c) => { @@ -218,16 +218,16 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => { const form = await c.req.formData().catch(() => null) const image = form?.get("image") if (!(image instanceof File)) { - return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Upload failed", filePath: "" })) + return success(c, { success: false, msg: "Upload failed", filePath: "" } satisfies UploadImageResponse) } const suffix = image.name.slice(image.name.lastIndexOf(".")).toLowerCase() if (!IMAGE_SUFFIXES.includes(suffix)) { - return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Unsupported file format", filePath: "" })) + return success(c, { success: false, msg: "Unsupported file format", filePath: "" } satisfies UploadImageResponse) } // 旧后端没有大小限制,靠 nginx 兜。这里显式限一道:文件写在本地磁盘上, // 一个超大文件就能把机房那台机器的盘写满,而写满之后判题也一起挂 if (image.size > MAX_IMAGE_BYTES) { - return success(c, uploadImageResponseSchema.parse({ success: false, msg: "图片不能超过 10MB", filePath: "" })) + return success(c, { success: false, msg: "图片不能超过 10MB", filePath: "" } satisfies UploadImageResponse) } // 文件名完全由服务端生成,不带用户提供的任何一段 —— 原名里的 ../ 或空字节都进不来 const name = `${randomFileName()}${suffix}` @@ -236,13 +236,13 @@ adminConfRoutes.post("/upload-image", requireAdmin, async (c) => { await Bun.write(resolve(config.uploadDirectory, name), image) } catch (error) { console.error("Failed to save uploaded image", error) - return success(c, uploadImageResponseSchema.parse({ success: false, msg: "Upload Error", filePath: "" })) + return success(c, { success: false, msg: "Upload Error", filePath: "" } satisfies UploadImageResponse) } - return success(c, uploadImageResponseSchema.parse({ + return success(c, { success: true, msg: "Success", filePath: `${config.uploadUriPrefix}/${name}`, - })) + } satisfies UploadImageResponse) }) function randomFileName() { diff --git a/apps/api/src/routes/admin/contest.ts b/apps/api/src/routes/admin/contest.ts index 5624068..3c68452 100644 --- a/apps/api/src/routes/admin/contest.ts +++ b/apps/api/src/routes/admin/contest.ts @@ -1,10 +1,10 @@ import { - acmHelperItemSchema, - adminContestListSchema, - adminContestSchema, createContestRequestSchema, updateAcmHelperRequestSchema, updateContestRequestSchema, + type AcmHelperItem, + type AdminContest, + type AdminContestList, } from "@oj2/contract" import { and, count, desc, eq, ilike, inArray } from "drizzle-orm" import { Hono } from "hono" @@ -31,7 +31,7 @@ async function serialize(row: { user: typeof schema.user.$inferSelect realName: string | null }) { - return adminContestSchema.parse({ + return { id: row.contest.id, title: row.contest.title, description: row.contest.description, @@ -45,7 +45,7 @@ async function serialize(row: { createdBy: sampleUser(row.user, row.realName), status: contestStatus(row.contest), contestType: row.contest.password ? "Password Protected" : "Public", - }) + } satisfies AdminContest } function selectContest(id: number) { @@ -84,10 +84,10 @@ adminContestRoutes.get("/contests", requireTeacher, async (c) => { .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .where(where).orderBy(desc(schema.contest.createTime)).limit(limit).offset(offset), ]) - return success(c, adminContestListSchema.parse({ + return success(c, { results: await Promise.all(rows.map(serialize)), total: totalRows[0]?.value ?? 0, - })) + } satisfies AdminContestList) }) adminContestRoutes.get("/contests/:id", requireTeacher, async (c) => { @@ -266,8 +266,7 @@ adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => const results = [] for (const rank of ranks) { if (rank.acceptedNumber <= 0) continue - for (const [problemId, raw] of Object.entries(objectValue(rank.submissionInfo))) { - const info = objectValue(raw) + for (const [problemId, info] of Object.entries(rank.submissionInfo)) { if (info.is_ac !== true) continue results.push({ id: rank.id, @@ -285,7 +284,7 @@ adminContestRoutes.get("/contests/:id/acm-helper", requireTeacher, async (c) => } // 按 AC 用时倒序:最后才做出来的排前面,那是最值得看的 results.sort((left, right) => right._acTime - left._acTime) - return success(c, results.map(({ _acTime, ...item }) => acmHelperItemSchema.parse(item))) + return success(c, results.map(({ _acTime, ...item }) => item) satisfies AcmHelperItem[]) }) adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => { @@ -306,9 +305,9 @@ adminContestRoutes.put("/contests/:id/acm-helper", requireTeacher, async (c) => )).limit(1) if (!rank) return failure(c, 404, "rank-not-found", "Rank id does not exist") - const info = objectValue(rank.submissionInfo) - const entry = objectValue(info[parsed.data.problemId]) - if (!info[parsed.data.problemId]) { + const info = rank.submissionInfo + const entry = info[parsed.data.problemId] + if (!entry) { return failure(c, 404, "problem-not-in-rank", "Problem id does not exist") } entry.checked = parsed.data.checked diff --git a/apps/api/src/routes/admin/learn.ts b/apps/api/src/routes/admin/learn.ts index 7a21f33..fc514bd 100644 --- a/apps/api/src/routes/admin/learn.ts +++ b/apps/api/src/routes/admin/learn.ts @@ -1,13 +1,13 @@ import { STUDENT_ROLES, TUTORIAL_READ_SECONDS, - learnExerciseAttemptSchema, - learnExerciseProgressListSchema, - learnExerciseProgressSchema, - learnStudentProgressListSchema, - learnStudentProgressSchema, - learnTutorialProgressListSchema, - learnTutorialProgressSchema, + type LearnExerciseAttempt, + type LearnExerciseProgress, + type LearnExerciseProgressList, + type LearnStudentProgress, + type LearnStudentProgressList, + type LearnTutorialProgress, + type LearnTutorialProgressList, } from "@oj2/contract" import { and, asc, count, desc, eq, inArray, like, sql } from "drizzle-orm" import { Hono } from "hono" @@ -120,16 +120,16 @@ adminLearnRoutes.get("/learn-analytics/students", requireTeacher, async (c) => { .where(inArray(schema.exercise.tutorialId, tutorialIds)) : [{ value: 0 }] - return success(c, learnStudentProgressListSchema.parse({ + return success(c, { tutorialCount: tutorialIds.length, exerciseCount: exerciseCountRow?.value ?? 0, - results: rows.map((row) => learnStudentProgressSchema.parse({ + results: rows.map((row) => ({ ...row, exerciseTried: attempts.get(row.userId)?.tried ?? 0, exerciseSolved: attempts.get(row.userId)?.solved ?? 0, exerciseAttempts: attempts.get(row.userId)?.attempts ?? 0, - })), - })) + } satisfies LearnStudentProgress)), + } satisfies LearnStudentProgressList) }) adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => { @@ -166,13 +166,13 @@ adminLearnRoutes.get("/learn-analytics/tutorials", requireTeacher, async (c) => .groupBy(schema.tutorial.id, schema.tutorial.title, schema.tutorial.order) .orderBy(asc(schema.tutorial.order)) - return success(c, learnTutorialProgressListSchema.parse({ + return success(c, { studentCount, - results: rows.map(({ readSeconds, ...row }) => learnTutorialProgressSchema.parse({ + results: rows.map(({ readSeconds, ...row }) => ({ ...row, avgSeconds: row.readers ? Math.round(readSeconds / row.readers) : 0, - })), - })) + } satisfies LearnTutorialProgress)), + } satisfies LearnTutorialProgressList) }) /** @@ -216,13 +216,13 @@ adminLearnRoutes.get("/learn-analytics/exercises", requireTeacher, async (c) => .groupBy(schema.exercise.id, schema.tutorial.id, schema.tutorial.title, schema.tutorial.order) .orderBy(asc(schema.tutorial.order), asc(schema.exercise.order)) - return success(c, learnExerciseProgressListSchema.parse({ + return success(c, { studentCount: studentCountRow?.value ?? 0, - results: rows.map((row) => learnExerciseProgressSchema.parse({ + results: rows.map((row) => ({ ...row, avgAttemptsToSolve: rounded(Number(row.avgAttemptsToSolve), 1), - })), - })) + } satisfies LearnExerciseProgress)), + } satisfies LearnExerciseProgressList) }) /** 单道练习的逐人明细。后台表格展开某一行时才拉,不跟着列表一起下发 */ @@ -249,5 +249,5 @@ adminLearnRoutes.get("/learn-analytics/exercises/:id/attempts", requireTeacher, // 没做对的排前面,错得最多的最前 —— 展开这一行的人是来找卡住的学生的 .orderBy(asc(schema.exerciseAttempt.solved), desc(schema.exerciseAttempt.wrongAttempts)) - return success(c, rows.map((row) => learnExerciseAttemptSchema.parse(row))) + return success(c, rows satisfies LearnExerciseAttempt[]) }) diff --git a/apps/api/src/routes/admin/problem.ts b/apps/api/src/routes/admin/problem.ts index 451a606..11a4fa9 100644 --- a/apps/api/src/routes/admin/problem.ts +++ b/apps/api/src/routes/admin/problem.ts @@ -1,18 +1,19 @@ import { addContestProblemRequestSchema, - adminProblemListItemSchema, - adminProblemListSchema, - adminProblemSchema, createProblemRequestSchema, - makeProblemPublicRequestSchema, - updateProblemRequestSchema, generateSqlTestCaseRequestSchema, - generateSqlTestCaseResponseSchema, + makeProblemPublicRequestSchema, sqlPreviewRequestSchema, - sqlTestCaseScriptSchema, - uploadTestCaseResponseSchema, + updateProblemRequestSchema, + type AdminProblem, + type AdminProblemList, + type AdminProblemListItem, type AstRules, + type GenerateSqlTestCaseResponse, type SqlConfig, + type SqlDisplay, + type SqlTestCaseScript, + type UploadTestCaseResponse, } from "@oj2/contract" import { and, count, desc, eq, ilike, inArray, isNull, ne, or, sql } from "drizzle-orm" import { Hono } from "hono" @@ -30,7 +31,7 @@ import { config } from "../../config" import { readFile } from "node:fs/promises" import { resolve } from "node:path" import { getTopReactions } from "../../services/reaction" -import { objectValue, queryInteger, sampleUser, stringArray } from "../helpers" +import { objectValue, queryInteger, sampleUser } from "../helpers" export const adminProblemRoutes = new Hono() @@ -130,7 +131,7 @@ async function serialize(row: ProblemRow) { .where(eq(schema.user.id, row.createdById)).limit(1), tagNames(row.id), ]) - return adminProblemSchema.parse({ + return { id: row.id, _id: row.displayId, title: row.title, @@ -141,8 +142,8 @@ async function serialize(row: ProblemRow) { testCaseId: row.testCaseId, testCaseScore: Array.isArray(row.testCaseScore) ? row.testCaseScore : [], hint: row.hint, - languages: stringArray(row.languages), - template: objectValue(row.template), + languages: row.languages, + template: row.template, createTime: row.createTime, lastUpdateTime: row.lastUpdateTime, timeLimit: row.timeLimit, @@ -164,9 +165,9 @@ async function serialize(row: ProblemRow) { astRules: row.astRules, answers: Array.isArray(row.answers) ? row.answers : [], prompt: row.prompt, - sqlConfig: row.sqlConfig ? objectValue(row.sqlConfig) : null, - sqlDisplay: row.sqlDisplay ? objectValue(row.sqlDisplay) : null, - }) + sqlConfig: row.sqlConfig, + sqlDisplay: row.sqlDisplay, + } satisfies AdminProblem } /** 公共校验,对齐旧 `ProblemBase.common_checks` */ @@ -205,7 +206,7 @@ async function generateSqlDisplay( testCaseId: string, answers: Record[], sqlConfig: SqlConfig, -): Promise<{ error: string } | { display: unknown }> { +): Promise<{ error: string } | { display: SqlDisplay }> { const info = await readInfo(testCaseId) if (!info) return { error: "测试点信息读取失败,请重新上传测试点" } if (!info.sql) return { error: "测试点不是 SQL 类型,请重新上传 SQL 测试点压缩包" } @@ -294,9 +295,9 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => { // 只有公开题列表下发最高票评价,比赛题列表不下发 —— 与旧后端一致 const problemIds = rows.map(({ problem }) => problem.id) const [topReactions, tags] = await Promise.all([getTopReactions(problemIds), tagNamesFor(problemIds)]) - return success(c, adminProblemListSchema.parse({ + return success(c, { results: rows.map(({ problem, user: creator, realName }) => - adminProblemListItemSchema.parse({ + ({ id: problem.id, _id: problem.displayId, title: problem.title, @@ -309,9 +310,9 @@ adminProblemRoutes.get("/problems", requireProblemPermission, async (c) => { allowFlowchart: problem.allowFlowchart, showFlowchart: problem.showFlowchart, topReaction: topReactions.get(problem.id) ?? null, - })), + } satisfies AdminProblemListItem)), total: totalRow[0]?.value ?? 0, - })) + } satisfies AdminProblemList) }) adminProblemRoutes.get("/problems/:id", requireProblemPermission, async (c) => { @@ -331,7 +332,7 @@ adminProblemRoutes.post("/problems", requireProblemPermission, async (c) => { } const checked = commonChecks(parsed.data) if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error) - let sqlDisplay: unknown = null + let sqlDisplay: SqlDisplay | null = null if (checked.sql) { const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!) if ("error" in built) return failure(c, 400, "invalid-problem", built.error) @@ -388,7 +389,7 @@ adminProblemRoutes.put("/problems/:id", requireProblemPermission, async (c) => { if (duplicate) return failure(c, 409, "display-id-exists", "Display ID already exists") // SQL 题每次保存都重算展示数据:测试点或标准答案可能刚改过,留着旧的就会和判题结果对不上 - let sqlDisplay: unknown = null + let sqlDisplay: SqlDisplay | null = null if (checked.sql) { const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!) if ("error" in built) return failure(c, 400, "invalid-problem", built.error) @@ -467,9 +468,9 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission .where(where).orderBy(desc(schema.problem.createTime)).limit(limit).offset(offset), ]) const tags = await tagNamesFor(rows.map(({ problem }) => problem.id)) - return success(c, adminProblemListSchema.parse({ + return success(c, { results: rows.map(({ problem, user: creator, realName }) => - adminProblemListItemSchema.parse({ + ({ id: problem.id, _id: problem.displayId, title: problem.title, @@ -482,9 +483,9 @@ adminProblemRoutes.get("/contests/:contestId/problems", requireProblemPermission allowFlowchart: problem.allowFlowchart, showFlowchart: problem.showFlowchart, topReaction: null, - })), + } satisfies AdminProblemListItem)), total: totalRow[0]?.value ?? 0, - })) + } satisfies AdminProblemList) }) adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermission, async (c) => { @@ -500,7 +501,7 @@ adminProblemRoutes.post("/contests/:contestId/problems", requireProblemPermissio } const checked = commonChecks(parsed.data) if ("error" in checked) return failure(c, 400, "invalid-problem", checked.error) - let sqlDisplay: unknown = null + let sqlDisplay: SqlDisplay | null = null if (checked.sql) { const built = await generateSqlDisplay(parsed.data.testCaseId, parsed.data.answers, parsed.data.sqlConfig!) if ("error" in built) return failure(c, 400, "invalid-problem", built.error) @@ -652,10 +653,10 @@ adminProblemRoutes.post("/test-cases", requireProblemPermission, async (c) => { const sql = ["1", "true", "True"].includes(String(form?.get("sql") ?? "")) try { const result = await processTestCaseZip(new Uint8Array(await file.arrayBuffer()), { sql }) - return success(c, uploadTestCaseResponseSchema.parse({ + return success(c, { id: result.testCaseId, info: result.info, - }), 201) + } satisfies UploadTestCaseResponse, 201) } catch (error) { if (error instanceof TestCaseError) return failure(c, 400, "invalid-test-case", error.message) console.error("Failed to process test case zip", error) @@ -701,7 +702,7 @@ adminProblemRoutes.get("/problems/:id/sql-scripts", requireProblemPermission, as if (!info.sql) return failure(c, 409, "not-sql-test-case", "该题的测试点不是 SQL 类型") try { const scripts = await readSqlScripts(problem.testCaseId) - return success(c, scripts.map((script) => sqlTestCaseScriptSchema.parse(script))) + return success(c, scripts satisfies SqlTestCaseScript[]) } catch (error) { console.error("Failed to read SQL test case scripts", error) return failure(c, 500, "test-case-error", "测试点脚本读取失败") @@ -735,7 +736,7 @@ SELECT 语句,或增删改题的 UPDATE/DELETE/INSERT 语句)和题型。 请只返回 SQL 脚本本身,连 \`\`\` 都不需要,不要任何解释文字。`, `题型:${parsed.data.mode}\n标准答案:\n${parsed.data.refSql}`, ) - return success(c, generateSqlTestCaseResponseSchema.parse({ sql })) + return success(c, { sql } satisfies GenerateSqlTestCaseResponse) } catch (error) { console.error("SQL test case generation failed", error) return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试") diff --git a/apps/api/src/routes/admin/problemset.ts b/apps/api/src/routes/admin/problemset.ts index 567ded3..991c41c 100644 --- a/apps/api/src/routes/admin/problemset.ts +++ b/apps/api/src/routes/admin/problemset.ts @@ -1,16 +1,16 @@ import { addProblemToSetRequestSchema, - adminProblemSetBadgeSchema, - adminProblemSetListSchema, - adminProblemSetProblemSchema, - adminProblemSetProgressSchema, - adminProblemSetSchema, createProblemSetBadgeRequestSchema, createProblemSetRequestSchema, updateProblemInSetRequestSchema, updateProblemSetBadgeRequestSchema, updateProblemSetRequestSchema, updateProblemSetStatusRequestSchema, + type AdminProblemSet, + type AdminProblemSetBadge, + type AdminProblemSetList, + type AdminProblemSetProblem, + type AdminProblemSetProgress, } from "@oj2/contract" import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm" import { Hono } from "hono" @@ -20,7 +20,7 @@ import type { AuthUser } from "../../auth/session" import { db, schema } from "../../db" import { failure, success } from "../../http" import { recalculateBadge, resyncProgress } from "../../services/problemset" -import { queryInteger, sampleUser } from "../helpers" +import { asFilterValue, queryInteger, sampleUser } from "../helpers" export const adminProblemSetRoutes = new Hono() @@ -65,7 +65,7 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) { const creatorById = new Map(creators.map((item) => [item.id, item])) return rows.map((row) => { const creator = creatorById.get(row.createdById) - return adminProblemSetSchema.parse({ + return { id: row.id, title: row.title, description: row.description, @@ -78,7 +78,7 @@ async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) { lastUpdateTime: row.lastUpdateTime, problemsCount: problemsBySet.get(row.id) ?? 0, participantCount: participantsBySet.get(row.id) ?? 0, - }) + } satisfies AdminProblemSet }) } @@ -102,8 +102,8 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => { ilike(schema.problemset.description, `%${keyword}%`), )!) } - if (difficulty) filters.push(eq(schema.problemset.difficulty, difficulty)) - if (status) filters.push(eq(schema.problemset.status, status)) + if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty))) + if (status) filters.push(eq(schema.problemset.status, asFilterValue(status))) const where = filters.length ? and(...filters) : undefined const [totalRows, rows] = await Promise.all([ @@ -111,10 +111,10 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => { db.select().from(schema.problemset).where(where) .orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset), ]) - return success(c, adminProblemSetListSchema.parse({ + return success(c, { results: await serializeMany(rows), total: totalRows[0]?.value ?? 0, - })) + } satisfies AdminProblemSetList) }) adminProblemSetRoutes.post("/problem-sets", requireTeacher, async (c) => { @@ -194,7 +194,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c .innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id)) .where(eq(schema.problemsetProblem.problemsetId, row.id)) .orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)) - return success(c, rows.map(({ item, problem }) => adminProblemSetProblemSchema.parse({ + return success(c, rows.map(({ item, problem }) => ({ id: item.id, problemsetId: item.problemsetId, problemId: item.problemId, @@ -205,7 +205,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/problems", requireTeacher, async (c isRequired: item.isRequired, score: item.score, hint: item.hint, - }))) + } satisfies AdminProblemSetProblem))) }) adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (c) => { @@ -289,7 +289,7 @@ async function badgesWithCount(badges: BadgeRow[]) { .from(schema.userBadge).where(inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id))) .groupBy(schema.userBadge.badgeId) const countByBadge = new Map(earned.map((item) => [item.badgeId, item.value])) - return badges.map((badge) => adminProblemSetBadgeSchema.parse({ + return badges.map((badge) => ({ id: badge.id, problemsetId: badge.problemsetId, name: badge.name, @@ -298,7 +298,7 @@ async function badgesWithCount(badges: BadgeRow[]) { conditionType: badge.conditionType, conditionValue: badge.conditionValue, earnedCount: countByBadge.get(badge.id) ?? 0, - })) + } satisfies AdminProblemSetBadge)) } adminProblemSetRoutes.get("/problem-sets/:id/badges", requireTeacher, async (c) => { @@ -375,7 +375,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c .where(eq(schema.problemsetProgress.problemsetId, row.id)) .orderBy(desc(schema.problemsetProgress.joinTime)) return success(c, rows.map(({ progress, username, realName }) => - adminProblemSetProgressSchema.parse({ + ({ id: progress.id, userId: progress.userId, username, @@ -388,7 +388,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c completedProblemsCount: progress.completedProblemsCount, totalProblemsCount: progress.totalProblemsCount, totalScore: progress.totalScore, - }))) + } satisfies AdminProblemSetProgress))) }) adminProblemSetRoutes.delete("/problem-sets/:id/progress/:userId", requireTeacher, async (c) => { diff --git a/apps/api/src/routes/admin/tag.ts b/apps/api/src/routes/admin/tag.ts index 5ce53b9..6052f3a 100644 --- a/apps/api/src/routes/admin/tag.ts +++ b/apps/api/src/routes/admin/tag.ts @@ -1,13 +1,13 @@ import { - acTrendSchema, - adminTagSchema, batchProblemTagRequestSchema, - batchProblemTagResponseSchema, generateFlowchartRequestSchema, - generateFlowchartResponseSchema, renameTagRequestSchema, - renameTagResponseSchema, - stuckProblemSchema, + type AcTrend, + type AdminTag, + type BatchProblemTagResponse, + type GenerateFlowchartResponse, + type RenameTagResponse, + type StuckProblem, } from "@oj2/contract" import { and, asc, countDistinct, count, desc, eq, gte, ilike, inArray, isNull, lte, ne, sql } from "drizzle-orm" import { Hono } from "hono" @@ -46,7 +46,7 @@ adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => { // 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些), // 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0 .orderBy(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name)) - return success(c, rows.map((row) => adminTagSchema.parse(row))) + return success(c, rows satisfies AdminTag[]) }) adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => { @@ -63,7 +63,7 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => { if (!target) { await db.update(schema.problemTag).set({ name }).where(eq(schema.problemTag.id, id)) - return success(c, renameTagResponseSchema.parse({ merged: false, id, name, affectedCount: 0 })) + return success(c, { merged: false, id, name, affectedCount: 0 } satisfies RenameTagResponse) } // 改名撞上已有标签,视为合并:题目关系转移过去,原标签删除 @@ -86,9 +86,9 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => { await tx.delete(schema.problemTag).where(eq(schema.problemTag.id, id)) return links.length }) - return success(c, renameTagResponseSchema.parse({ + return success(c, { merged: true, id: target.id, name: target.name, affectedCount: affected, - })) + } satisfies RenameTagResponse) }) adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => { @@ -152,10 +152,10 @@ adminTagRoutes.post("/problems/batch-tag", requireProblemPermission, async (c) = if (rows.length) await tx.insert(schema.problemTags).values(rows) }) - return success(c, batchProblemTagResponseSchema.parse({ + return success(c, { problemCount: problems.length, tagCount: tagIds.length, - })) + } satisfies BatchProblemTagResponse) }) // ---------------------------------------------------------------- 题目可见性 @@ -209,14 +209,14 @@ adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => { .having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`) .orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`)) .limit(40) - return success(c, rows.map((row) => stuckProblemSchema.parse({ + return success(c, rows.map((row) => ({ problemId: row.displayId, problemTitle: row.title, total: row.total, failed: row.failed, failedUsers: row.failedUsers, acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0, - }))) + } satisfies StuckProblem))) }) adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => { @@ -263,7 +263,7 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => { // 每一年都得有数据,且每年提交量都超过门槛 —— 否则趋势没有可比性 if (![...required].every((y) => years.has(y))) continue if (!entry.yearly.every((row) => row.total > minPerYear)) continue - result.push(acTrendSchema.parse({ + result.push({ problemId: entry.displayId, problemTitle: entry.title, yearly: entry.yearly @@ -274,7 +274,7 @@ adminTagRoutes.get("/problem-analytics/ac-trend", requireTeacher, async (c) => { acRate: row.total ? rounded((row.accepted / row.total) * 100, 1) : 0, })) .sort((left, right) => left.year - right.year), - })) + } satisfies AcTrend) } return success(c, result) }) @@ -292,7 +292,7 @@ adminTagRoutes.post("/problems/flowchart", requireProblemPermission, async (c) = 请只返回 mermaid 代码,连 \`\`\` 都不需要。`, parsed.data.python, ) - return success(c, generateFlowchartResponseSchema.parse({ flowchart })) + return success(c, { flowchart } satisfies GenerateFlowchartResponse) } catch (error) { console.error("Flowchart generation failed", error) return failure(c, 502, "ai-unavailable", "生成失败,请稍后再试") diff --git a/apps/api/src/routes/admin/tutorial.ts b/apps/api/src/routes/admin/tutorial.ts index f65a36f..da4ef56 100644 --- a/apps/api/src/routes/admin/tutorial.ts +++ b/apps/api/src/routes/admin/tutorial.ts @@ -1,12 +1,12 @@ import { - adminExerciseSchema, - adminTutorialGroupsSchema, - adminTutorialSchema, createExerciseRequestSchema, createTutorialRequestSchema, setTutorialVisibilityRequestSchema, updateExerciseRequestSchema, updateTutorialRequestSchema, + type AdminExercise, + type AdminTutorial, + type AdminTutorialGroups, } from "@oj2/contract" import { asc, desc, eq } from "drizzle-orm" import { Hono } from "hono" @@ -24,7 +24,7 @@ function serializeTutorial(row: { user: typeof schema.user.$inferSelect realName: string | null }) { - return adminTutorialSchema.parse({ + return { id: row.tutorial.id, title: row.tutorial.title, content: row.tutorial.content, @@ -35,7 +35,7 @@ function serializeTutorial(row: { createdBy: sampleUser(row.user, row.realName), createdAt: row.tutorial.createdAt, updatedAt: row.tutorial.updatedAt, - }) + } satisfies AdminTutorial } function selectTutorial(id: number) { @@ -57,10 +57,10 @@ adminTutorialRoutes.get("/tutorials", requireSuperAdmin, async (c) => { .orderBy(asc(schema.tutorial.order), desc(schema.tutorial.createdAt)) const all = rows.map(serializeTutorial) // 分组返回,形状对齐旧 TutorialAdminAPI.get;列表 schema omit 掉了 content/code,Zod 会 strip - return success(c, adminTutorialGroupsSchema.parse({ + return success(c, { python: all.filter((item) => item.type === "python"), c: all.filter((item) => item.type === "c"), - })) + } satisfies AdminTutorialGroups) }) adminTutorialRoutes.post("/tutorials", requireSuperAdmin, async (c) => { @@ -126,12 +126,12 @@ adminTutorialRoutes.delete("/tutorials/:id", requireSuperAdmin, async (c) => { // ---------------------------------------------------------------- 练习 function serializeExercise(row: typeof schema.exercise.$inferSelect) { - return adminExerciseSchema.parse({ + return { id: row.id, type: row.type, data: objectValue(row.data), order: row.order, - }) + } satisfies AdminExercise } // 练习挂在教程下,路径嵌套 —— 旧后端是 ?tutorial_id= 查询参数, diff --git a/apps/api/src/routes/ai.ts b/apps/api/src/routes/ai.ts index e83bc8e..57fdb98 100644 --- a/apps/api/src/routes/ai.ts +++ b/apps/api/src/routes/ai.ts @@ -1,16 +1,17 @@ import { - HINT_MIN_FAILURES, - aiAnalysisRecordSchema, aiAnalysisRequestSchema, - aiDetailSchema, aiHintRequestSchema, classAnalysisRequestSchema, classPkAnalysisRequestSchema, - durationDataSchema, - heatmapItemSchema, - loginSummarySchema, - solvedListSchema, - solvedProblemSchema, + HINT_MIN_FAILURES, + type AiAnalysisRecord, + type AiDetail, + type DurationData, + type Grade, + type HeatmapItem, + type LoginSummary, + type SolvedList, + type SolvedProblem, } from "@oj2/contract" import { and, asc, count, countDistinct, eq, gte, inArray, isNull, lte, min, sql } from "drizzle-orm" import { Hono, type Context } from "hono" @@ -19,7 +20,7 @@ import { requireAuth, type AppEnv } from "../auth/middleware" import { getPreviousLogin, type AuthUser } from "../auth/session" import { config } from "../config" import { db, schema } from "../db" -import { JudgeStatus, judgeStatusName } from "../judge/status" +import { JudgeStatus, judgeStatusName, type JudgeStatusValue } from "../judge/status" import { failure, success } from "../http" import { completeChat, streamChat } from "../services/ai" import { consumeToken } from "../services/throttling" @@ -27,7 +28,7 @@ import { countFailedSubmissions, isTeacherOrAbove, objectValue, queryInteger, ro export const aiRoutes = new Hono() -const accepted = [0, 10] +const accepted: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED] const difficultyNames: Record = { Low: "简单", Mid: "中等", High: "困难" } /** @@ -61,15 +62,15 @@ const calendarDay = new Intl.DateTimeFormat("en-CA", { timeZone: CALENDAR_TZ, year: "numeric", month: "2-digit", day: "2-digit", }) -function grade(rank: number | null, count: number, reference = count) { +function grade(rank: number | null, count: number, reference = count): Grade { if (!rank || count <= 0) return "C" const percentile = (rank - 1) / count * 100 - let value = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C" + let value: Grade = percentile < 10 ? "S" : percentile < 35 ? "A" : percentile < 75 ? "B" : "C" if (reference < 10) value = value === "S" ? "A" : value === "A" ? "B" : value return value } -function averageGrade(grades: string[]) { +function averageGrade(grades: Grade[]): Grade { const weights: Record = { S: 4, A: 3, B: 2, C: 1 } const values = grades.flatMap((item) => weights[item] ?? []) if (!values.length) return "" @@ -149,12 +150,12 @@ async function buildSolved(user: AuthUser, start: string, end: string, firstAc: const period = ranks(periodRows, item.problemId) const rank = all.findIndex((row) => row.userId === user.id) + 1 || null const periodRank = period.findIndex((row) => row.userId === user.id) + 1 || null - return solvedProblemSchema.parse({ + return { problem: { title: problem.problem.title, displayId: problem.problem.displayId, contestTitle: problem.contestTitle ?? "", contestId: problem.problem.contestId }, acTime: item.first, rank, acCount: all.length, grade: grade(periodRank, period.length, all.length), periodRank, periodAcCount: period.length, difficulty: difficultyNames[problem.problem.difficulty] ?? "中等", attempts: attemptsByProblem.get(item.problemId) ?? 1, - }) + } satisfies SolvedProblem }).sort((a, b) => Date.parse(a.acTime) - Date.parse(b.acTime)) return { solved, problems, scopeIds } } @@ -169,7 +170,7 @@ async function listSolved(user: AuthUser, start: string, end: string, limit: num )), ]) const { solved } = await buildSolved(user, start, end, firstAc) - return solvedListSchema.parse({ results: solved, total: totalRows[0]?.value ?? 0 }) + return { results: solved, total: totalRows[0]?.value ?? 0 } satisfies SolvedList } async function buildDetail(user: AuthUser, start: string, end: string) { @@ -195,7 +196,7 @@ async function buildDetail(user: AuthUser, start: string, end: string) { eq(schema.submission.userId, user.id), gte(schema.submission.createTime, start), lte(schema.submission.createTime, end), )) - const settledFail = (result: number) => + const settledFail = (result: JudgeStatusValue) => !accepted.includes(result) && result !== JudgeStatus.PENDING && result !== JudgeStatus.JUDGING const errorCounts = new Map() for (const row of submissions) { @@ -207,10 +208,10 @@ async function buildDetail(user: AuthUser, start: string, end: string) { .sort((a, b) => b.count - a.count || a.result - b.result) const firstAc = await firstAcQuery(user, start, end) const problemIds = firstAc.map((item) => item.problemId) - if (!problemIds.length) return aiDetailSchema.parse({ + if (!problemIds.length) return { user: user.username, className: user.className, start, end, solvedCount: 0, attempts: [], flowcharts: [], grade: "", tags: {}, difficulty: {}, contestCount: 0, activity, errors, rankScope: "global", - }) + } satisfies AiDetail const [{ solved, problems, scopeIds }, tagRows, flowRows] = await Promise.all([ buildSolved(user, start, end, firstAc), db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags) @@ -244,13 +245,13 @@ async function buildDetail(user: AuthUser, start: string, end: string) { avgScore: rounded(scores.length ? scores.reduce((sum, value) => sum + value, 0) / scores.length : 0, 0), } }).sort((a, b) => b.latestSubmissionTime.localeCompare(a.latestSubmissionTime)) - return aiDetailSchema.parse({ + return { user: user.username, className: user.className, start, end, flowcharts, solvedCount: solved.length, attempts: solved.map((item) => item.attempts), grade: averageGrade(solved.map((item) => item.grade)), tags: topTags, difficulty, contestCount: new Set(solved.flatMap((item) => item.problem.contestId ?? [])).size, activity, errors, rankScope: scopeIds ? "class" : "global", - }) + } satisfies AiDetail } aiRoutes.get("/ai/detail", requireAuth, async (c) => { @@ -357,7 +358,7 @@ async function buildDuration(user: AuthUser, endText: string, duration: string) const inRange = rows.filter((row) => row.time >= from && row.time <= to) const acceptedRows = inRange.filter((row) => accepted.includes(row.result)) const solved = [...new Set(acceptedRows.map((row) => row.problemId))] - return durationDataSchema.parse({ + return { unit: config.unit, index: config.count - 1 - index, start: bucket.start.toISOString(), @@ -366,7 +367,7 @@ async function buildDuration(user: AuthUser, endText: string, duration: string) problemCount: solved.length, acceptedCount: acceptedRows.length, submissionCount: inRange.length, - }) + } satisfies DurationData }) } @@ -407,7 +408,7 @@ aiRoutes.get("/ai/heatmap", requireAuth, async (c) => { const day = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + offset) value += counts.get(dateKey(day)) ?? 0 } - return heatmapItemSchema.parse({ timestamp: monday.getTime(), value }) + return { timestamp: monday.getTime(), value } satisfies HeatmapItem })) }) @@ -442,7 +443,7 @@ aiRoutes.get("/ai/login-summary", requireAuth, async (c) => { analysisError = error instanceof Error ? error.message : String(error) } } - return success(c, loginSummarySchema.parse({ summary, analysis, analysisError })) + return success(c, { summary, analysis, analysisError } satisfies LoginSummary) }) aiRoutes.get("/ai/pinned", requireAuth, async (c) => { @@ -450,10 +451,10 @@ aiRoutes.get("/ai/pinned", requireAuth, async (c) => { .innerJoin(schema.user, eq(schema.aiAnalysis.userId, schema.user.id)) .where(and(eq(schema.aiAnalysis.userId, c.get("user")!.id), eq(schema.aiAnalysis.isPinned, true))).limit(1) if (!row) return success(c, null) - return success(c, aiAnalysisRecordSchema.parse({ + return success(c, { id: row.analysis.id, provider: row.analysis.provider, model: row.analysis.model, data: objectValue(row.analysis.data), analysis: row.analysis.analysis, createTime: row.analysis.createTime, isPinned: row.analysis.isPinned, username: row.username, - })) + } satisfies AiAnalysisRecord) }) aiRoutes.post("/ai/analysis", requireAuth, async (c) => { diff --git a/apps/api/src/routes/classroom.ts b/apps/api/src/routes/classroom.ts index 08d59b3..d8d7022 100644 --- a/apps/api/src/routes/classroom.ts +++ b/apps/api/src/routes/classroom.ts @@ -1,10 +1,10 @@ import { - STUDENT_ROLES, classComparisonRequestSchema, - classComparisonResponseSchema, - classComparisonSchema, - classRankItemSchema, - classUserRankSchema, + STUDENT_ROLES, + type ClassComparison, + type ClassComparisonResponse, + type ClassRankItem, + type ClassUserRank, } from "@oj2/contract" import { and, eq, gte, inArray, like, lte, sql } from "drizzle-orm" import { Hono } from "hono" @@ -94,7 +94,7 @@ classroomRoutes.get("/rankings/classes", async (c) => { acRate: totalSubmission > 0 ? rounded(totalAc / totalSubmission * 100) : 0, } }).sort((a, b) => b.totalAc - a.totalAc || a.totalSubmission - b.totalSubmission) - return success(c, result.map((item, index) => classRankItemSchema.parse({ ...item, rank: index + 1 }))) + return success(c, result.map((item, index) => ({ ...item, rank: index + 1 } satisfies ClassRankItem))) }) classroomRoutes.get("/me/class-rank", requireAuth, async (c) => { @@ -121,7 +121,7 @@ classroomRoutes.get("/me/class-rank", requireAuth, async (c) => { const start = Math.min(Math.max(0, myRank - 6), ranks.length - 10) selected = ranks.slice(start, start + 10) } - return success(c, classUserRankSchema.parse({ className: user.className, myRank, total: ranks.length, ranks: selected })) + return success(c, { className: user.className, myRank, total: ranks.length, ranks: selected } satisfies ClassUserRank) }) classroomRoutes.post("/classes/comparison", async (c) => { @@ -166,7 +166,7 @@ classroomRoutes.post("/classes/comparison", async (c) => { const middle = topCount + bottomCount < userCount ? ac.slice(topCount, -bottomCount) : ac const totalAc = ac.reduce((sum, value) => sum + value, 0) const totalSubmission = submissions.reduce((sum, value) => sum + value, 0) - const base: Record = { + const base: ClassComparison = { className, userCount, totalAc, @@ -197,21 +197,18 @@ classroomRoutes.post("/classes/comparison", async (c) => { } return base }) - const maxMedian = Math.max(1, ...comparisons.map((item) => Number(item.medianAc))) - const maxMiddle = Math.max(1, ...comparisons.map((item) => Number(item.middle80Avg))) + const maxMedian = Math.max(1, ...comparisons.map((item) => item.medianAc)) + const maxMiddle = Math.max(1, ...comparisons.map((item) => item.middle80Avg)) for (const item of comparisons) { item.compositeScore = rounded( - 0.4 * (Number(item.medianAc) / maxMedian * 100) + - 0.15 * (Number(item.middle80Avg) / maxMiddle * 100) + - 0.2 * Number(item.activeRate) + - 0.15 * Number(item.passRate) + - 0.1 * Number(item.excellentRate), + 0.4 * (item.medianAc / maxMedian * 100) + + 0.15 * (item.middle80Avg / maxMiddle * 100) + + 0.2 * item.activeRate + + 0.15 * item.passRate + + 0.1 * item.excellentRate, 1, ) } - comparisons.sort((a, b) => Number(b.compositeScore) - Number(a.compositeScore) || Number(b.medianAc) - Number(a.medianAc)) - return success(c, classComparisonResponseSchema.parse({ - comparisons: comparisons.map((item) => classComparisonSchema.parse(item)), - hasTimeRange, - })) + comparisons.sort((a, b) => b.compositeScore - a.compositeScore || b.medianAc - a.medianAc) + return success(c, { comparisons, hasTimeRange } satisfies ClassComparisonResponse) }) diff --git a/apps/api/src/routes/content.ts b/apps/api/src/routes/content.ts index bb85b7d..aba4337 100644 --- a/apps/api/src/routes/content.ts +++ b/apps/api/src/routes/content.ts @@ -1,20 +1,22 @@ import { - announcementListItemSchema, - announcementListSchema, - announcementSchema, createMessageRequestSchema, - exerciseSchema, - messageListSchema, - messageSchema, - reactionKeySchema, - reactionStateSchema, - setReactionRequestSchema, embeddedSubmissionSchema, exerciseAttemptRequestSchema, + reactionKeySchema, + setReactionRequestSchema, tutorialProgressPingSchema, - tutorialProgressSchema, - tutorialSchema, - tutorialSummarySchema, + type Announcement, + type AnnouncementList, + type AnnouncementListItem, + type EmbeddedSubmission, + type Exercise, + type Message, + type MessageList, + type ReactionCounts, + type ReactionState, + type Tutorial, + type TutorialProgress, + type TutorialSummary, } from "@oj2/contract" import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm" import { Hono } from "hono" @@ -38,8 +40,8 @@ contentRoutes.get("/announcements", async (c) => { .where(eq(schema.announcement.visible, true)) .orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset), ]) - return success(c, announcementListSchema.parse({ - results: rows.map(({ announcement, user, realName }) => announcementListItemSchema.parse({ + return success(c, { + results: rows.map(({ announcement, user, realName }) => ({ id: announcement.id, title: announcement.title, tag: announcement.tag, @@ -47,9 +49,9 @@ contentRoutes.get("/announcements", async (c) => { createdBy: sampleUser(user, realName), createTime: announcement.createTime, lastUpdateTime: announcement.lastUpdateTime, - })), + } satisfies AnnouncementListItem)), total: totalRows[0]?.value ?? 0, - })) + } satisfies AnnouncementList) }) contentRoutes.get("/announcements/:id", async (c) => { @@ -59,7 +61,7 @@ contentRoutes.get("/announcements/:id", async (c) => { .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .where(and(eq(schema.announcement.id, id), eq(schema.announcement.visible, true))).limit(1) if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist") - return success(c, announcementSchema.parse({ + return success(c, { id: row.announcement.id, title: row.announcement.title, tag: row.announcement.tag, @@ -68,7 +70,7 @@ contentRoutes.get("/announcements/:id", async (c) => { createdBy: sampleUser(row.user, row.realName), createTime: row.announcement.createTime, lastUpdateTime: row.announcement.lastUpdateTime, - })) + } satisfies Announcement) }) contentRoutes.get("/messages", requireAuth, async (c) => { @@ -84,13 +86,13 @@ contentRoutes.get("/messages", requireAuth, async (c) => { .innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) .where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset), ]) - return success(c, messageListSchema.parse({ - results: rows.map(({ message, sender, realName, submission, displayId }) => messageSchema.parse({ + return success(c, { + results: rows.map(({ message, sender, realName, submission, displayId }) => ({ id: message.id, sender: sampleUser(sender, realName), createTime: message.createTime, message: message.message, - submission: embeddedSubmissionSchema.parse({ + submission: { id: submission.id, createTime: submission.createTime, userId: submission.userId, @@ -104,10 +106,10 @@ contentRoutes.get("/messages", requireAuth, async (c) => { // 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号> problem: displayId, showLink: true, - }), - })), + } satisfies EmbeddedSubmission, + } satisfies Message)), total: totalRows[0]?.value ?? 0, - })) + } satisfies MessageList) }) /** @@ -140,15 +142,16 @@ contentRoutes.post("/messages", requireSuperAdmin, async (c) => { async function reactionState(problemId: number, userId: number) { const [mine] = await db.select({ type: schema.reaction.type }).from(schema.reaction) .where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1) - if (!mine) return reactionStateSchema.parse({ mine: null, counts: null }) + if (!mine) return { mine: null, counts: null } satisfies ReactionState const rows = await db.select({ type: schema.reaction.type, value: count() }).from(schema.reaction) .where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type) - const counts = Object.fromEntries(reactionKeySchema.options.map((key) => [key, 0])) - for (const row of rows) { - const key = reactionKeySchema.safeParse(row.type) - if (key.success) counts[key.data] = row.value - } - return reactionStateSchema.parse({ mine: mine.type, counts }) + // fromEntries 推不出这个键集,但 options 就是 ReactionKey 的全集,断言是成立的。 + // row.type 不必再 safeParse:reaction.type 列上挂着 $type() + const counts = Object.fromEntries( + reactionKeySchema.options.map((key) => [key, 0]), + ) as ReactionCounts + for (const row of rows) counts[row.type] = row.value + return { mine: mine.type, counts } satisfies ReactionState } contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => { @@ -183,7 +186,7 @@ contentRoutes.get("/tutorials", async (c) => { const type = c.req.query("type") === "c" ? "c" : "python" const rows = await db.select({ id: schema.tutorial.id, title: schema.tutorial.title }).from(schema.tutorial) .where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order)) - return success(c, rows.map((row) => tutorialSummarySchema.parse(row))) + return success(c, rows satisfies TutorialSummary[]) }) contentRoutes.get("/tutorials/:id", async (c) => { @@ -193,7 +196,7 @@ contentRoutes.get("/tutorials/:id", async (c) => { .leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id)) .where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1) if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") - return success(c, tutorialSchema.parse({ + return success(c, { id: row.tutorial.id, title: row.tutorial.title, content: row.tutorial.content, @@ -204,7 +207,7 @@ contentRoutes.get("/tutorials/:id", async (c) => { createdBy: sampleUser(row.user, row.realName), createdAt: row.tutorial.createdAt, updatedAt: row.tutorial.updatedAt, - })) + } satisfies Tutorial) }) // ---------------------------------------------------------------- 自学留痕 @@ -252,7 +255,7 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => { ]) const exercises = new Map(exerciseRows.map((row) => [row.tutorialId, row])) - return success(c, rows.map((row) => tutorialProgressSchema.parse({ + return success(c, rows.map((row) => ({ tutorialId: row.tutorialId, viewCount: row.viewCount ?? 0, totalSeconds: row.totalSeconds ?? 0, @@ -260,7 +263,7 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => { lastViewedAt: row.lastViewedAt, exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0, exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0, - }))) + } satisfies TutorialProgress))) }) /** @@ -369,5 +372,5 @@ contentRoutes.get("/tutorials/:id/exercises", async (c) => { .where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1) if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist") const rows = await db.select().from(schema.exercise).where(eq(schema.exercise.tutorialId, id)).orderBy(asc(schema.exercise.order)) - return success(c, rows.map((row) => exerciseSchema.parse({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order }))) + return success(c, rows.map((row) => ({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order } satisfies Exercise))) }) diff --git a/apps/api/src/routes/contest.ts b/apps/api/src/routes/contest.ts index 76d509d..2be88a4 100644 --- a/apps/api/src/routes/contest.ts +++ b/apps/api/src/routes/contest.ts @@ -1,13 +1,13 @@ import { - STUDENT_ROLES, - contestAccessSchema, - contestListSchema, contestPasswordRequestSchema, - contestRankItemSchema, - contestRankSchema, - contestSchema, - problemDetailSchema, - problemListItemSchema, + STUDENT_ROLES, + type Contest, + type ContestAccess, + type ContestList, + type ContestRank, + type ContestRankItem, + type ProblemDetail, + type ProblemListItem, } from "@oj2/contract" import { and, asc, count, desc, eq, gte, ilike, inArray, lte, sql } from "drizzle-orm" import { Hono } from "hono" @@ -27,7 +27,7 @@ import { requireContestAccess, type ContestEnv, } from "../services/contest" -import { objectValue, publicTemplates, queryInteger, sampleUser, stringArray } from "./helpers" +import { objectValue, publicTemplates, queryInteger, sampleUser } from "./helpers" export const contestRoutes = new Hono() @@ -47,7 +47,7 @@ function serializeContest( createdBy: ReturnType, includeNow = false, ) { - return contestSchema.parse({ + return { id: contest.id, title: contest.title, description: contest.description, @@ -60,7 +60,7 @@ function serializeContest( status: contestStatus(contest), contestType: contest.password ? "Password Protected" : "Public", now: includeNow ? new Date().toISOString() : undefined, - }) + } satisfies Contest } contestRoutes.get("/contests", async (c) => { @@ -82,13 +82,13 @@ contestRoutes.get("/contests", async (c) => { db.select().from(schema.contest).where(where).orderBy(desc(schema.contest.startTime)).limit(limit).offset(offset), ]) const byId = await creators([...new Set(rows.map((row) => row.createdById))]) - return success(c, contestListSchema.parse({ + return success(c, { results: rows.map((row) => serializeContest( row, byId.get(row.createdById) ?? sampleUser({ id: row.createdById, username: "" }, null), )), total: totalRow[0]?.value ?? 0, - })) + } satisfies ContestList) }) // optionalAuth 是为了下面那句 findAccessibleContest 认得出「这是出题人自己」—— @@ -120,7 +120,7 @@ contestRoutes.get("/contests/:id/access", requireAuth, async (c) => { const contest = await findAccessibleContest(c.get("user"), queryInteger(c.req.param("id"), 0, { min: 1 })) if (!contest || !contest.password) return failure(c, 404, "contest-not-found", "Contest does not exist") const access = await canAccessContest(c, contest, "details") - return success(c, contestAccessSchema.parse({ access: access.ok })) + return success(c, { access: access.ok } satisfies ContestAccess) }) /** @@ -165,7 +165,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess(" const tags = await contestProblemTags(rows.map((row) => row.problem.id)) const allowed = contestDetailsAllowed(c.get("user"), contest) const statuses = await contestProblemStatuses(c.get("user")?.id) - return success(c, rows.map(({ problem, user, realName }) => problemListItemSchema.parse({ + return success(c, rows.map(({ problem, user, realName }) => ({ id: problem.id, _id: problem.displayId, title: problem.title, @@ -179,7 +179,7 @@ contestRoutes.get("/contests/:id/problems", optionalAuth, requireContestAccess(" showFlowchart: problem.showFlowchart, hasAstRules: problem.astRules !== null, myStatus: myStatusOf(statuses, problem.id), - }))) + } satisfies ProblemListItem))) }) contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireContestAccess("problems"), async (c) => { @@ -192,7 +192,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont const tags = await contestProblemTags([row.problem.id]) const allowed = contestDetailsAllowed(c.get("user"), contest) const statuses = await contestProblemStatuses(c.get("user")?.id) - return success(c, problemDetailSchema.parse({ + return success(c, { id: row.problem.id, _id: row.problem.displayId, title: row.problem.title, @@ -201,7 +201,7 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont outputDescription: row.problem.outputDescription, samples: Array.isArray(row.problem.samples) ? row.problem.samples : [], hint: row.problem.hint, - languages: stringArray(row.problem.languages), + languages: row.problem.languages, template: publicTemplates(row.problem.template), createTime: row.problem.createTime, lastUpdateTime: row.problem.lastUpdateTime, @@ -224,11 +224,11 @@ contestRoutes.get("/contests/:id/problems/:displayId", optionalAuth, requireCont mermaidCode: row.problem.allowFlowchart ? null : row.problem.mermaidCode, flowchartData: row.problem.allowFlowchart ? null : objectValue(row.problem.flowchartData), flowchartHint: row.problem.flowchartHint, - sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null, - sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null, + sqlConfig: row.problem.sqlConfig, + sqlDisplay: row.problem.sqlDisplay, // 代码要求:只给渲染好的文案,规则原文不下发给学生 astRequirements: astRequirements(row.problem.astRules), - })) + } satisfies ProblemDetail) }) contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("ranks"), async (c) => { @@ -247,8 +247,8 @@ contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("rank .orderBy(desc(schema.acmContestRank.acceptedNumber), asc(schema.acmContestRank.totalTime), asc(schema.acmContestRank.id)).limit(limit).offset(offset), ]) const admin = isContestAdmin(c.get("user"), contest) - return success(c, contestRankSchema.parse({ - results: rows.map(({ rank, user, realName }) => contestRankItemSchema.parse({ + return success(c, { + results: rows.map(({ rank, user, realName }) => ({ id: rank.id, // 唯一显式打开真名的地方,对齐旧后端 contest/serializers.py:84 // `UsernameSerializer(obj.user, need_real_name=self.is_contest_admin)` @@ -256,9 +256,9 @@ contestRoutes.get("/contests/:id/rank", optionalAuth, requireContestAccess("rank submissionNumber: rank.submissionNumber, acceptedNumber: rank.acceptedNumber, totalTime: rank.totalTime, - submissionInfo: objectValue(rank.submissionInfo), + submissionInfo: rank.submissionInfo, contestId: rank.contestId, - })), + } satisfies ContestRankItem)), total: totalRows[0]?.value ?? 0, - })) + } satisfies ContestRank) }) diff --git a/apps/api/src/routes/flowchart.ts b/apps/api/src/routes/flowchart.ts index a440748..1565c87 100644 --- a/apps/api/src/routes/flowchart.ts +++ b/apps/api/src/routes/flowchart.ts @@ -2,13 +2,13 @@ import { randomBytes } from "node:crypto" import { createFlowchartRequestSchema, - createFlowchartResponseSchema, - flowchartCurrentSchema, - flowchartDetailSchema, - flowchartListItemSchema, - flowchartListSchema, - flowchartStatisticsSchema, - flowchartSubmissionSchema, + type CreateFlowchartResponse, + type FlowchartCurrent, + type FlowchartDetail, + type FlowchartList, + type FlowchartListItem, + type FlowchartStatistics, + type FlowchartSubmission, } from "@oj2/contract" import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm" import { Hono } from "hono" @@ -45,7 +45,7 @@ function flowchartData( flowchart: typeof schema.flowchartSubmission.$inferSelect, username: string, ) { - return flowchartSubmissionSchema.parse({ + return { id: flowchart.id, username, problemId: flowchart.problemId, @@ -62,7 +62,7 @@ function flowchartData( aiModel: flowchart.aiModel, processingTime: flowchart.processingTime, evaluationTime: flowchart.evaluationTime, - }) + } satisfies FlowchartSubmission } flowchartRoutes.post("/flowcharts", requireAuth, async (c) => { @@ -106,7 +106,7 @@ flowchartRoutes.post("/flowcharts", requireAuth, async (c) => { await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, id)) return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable") } - return success(c, createFlowchartResponseSchema.parse({ submissionId: id, status: "pending" }), 201) + return success(c, { submissionId: id, status: "pending" } satisfies CreateFlowchartResponse, 201) }) flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { @@ -121,7 +121,7 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { // submission_list_show_all 时非管理员看不到列表。流程图这边一直漏了这道门, // 学生把语言切成「流程图」、用户名随便填一个字就能翻出全班的 AI 评分。 if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) { - return success(c, flowchartListSchema.parse({ results: [], total: 0 })) + return success(c, { results: [], total: 0 } satisfies FlowchartList) } if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`) if (c.req.query("myself") === "1" || (!username && user.adminType === "Regular User")) filters.push(eq(schema.flowchartSubmission.userId, user.id)) @@ -136,8 +136,8 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { .innerJoin(schema.problem, eq(schema.flowchartSubmission.problemId, schema.problem.id)).where(where) .orderBy(desc(schema.flowchartSubmission.createTime)).limit(limit).offset(offset), ]) - return success(c, flowchartListSchema.parse({ - results: rows.map(({ flowchart, username, problem }) => flowchartListItemSchema.parse({ + return success(c, { + results: rows.map(({ flowchart, username, problem }) => ({ id: flowchart.id, username, problem: problem.displayId, @@ -151,9 +151,9 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => { processingTime: flowchart.processingTime, evaluationTime: flowchart.evaluationTime, showLink: canView(user, flowchart, problem), - })), + } satisfies FlowchartListItem)), total: totalRows[0]?.value ?? 0, - })) + } satisfies FlowchartList) }) const FLOWCHART_COMPLETED = 2 @@ -242,7 +242,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { realName: stripClassPrefix(row.username, row.className), })), } - if (rows.length === 0) return success(c, flowchartStatisticsSchema.parse(empty)) + if (rows.length === 0) return success(c, empty satisfies FlowchartStatistics) const gradeDistribution: Record = {} const criteriaTotals = new Map() @@ -289,7 +289,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { criteriaAverages[key] = { avg: rounded(bucket.sum / bucket.count, 1), max: bucket.max } } - return success(c, flowchartStatisticsSchema.parse({ + return success(c, { totalCount: rows.length, // 分母是有分数的条数,不是总条数 —— 对齐 Django 的 Avg(),它跳过 NULL avgScore: scoreCount ? rounded(scoreSum / scoreCount, 1) : 0, @@ -304,7 +304,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => { username: row.username, realName: stripClassPrefix(row.username, row.className), })), - })) + } satisfies FlowchartStatistics) }) flowchartRoutes.get("/flowcharts/:id", requireAuth, async (c) => { @@ -351,7 +351,7 @@ flowchartRoutes.post("/flowcharts/:id/retry", requireAuth, async (c) => { await db.update(schema.flowchartSubmission).set({ status: 3 }).where(eq(schema.flowchartSubmission.id, row.flowchart.id)) return failure(c, 502, "queue-unavailable", "Evaluation queue is unavailable") } - return success(c, createFlowchartResponseSchema.parse({ submissionId: row.flowchart.id, status: "pending" })) + return success(c, { submissionId: row.flowchart.id, status: "pending" } satisfies CreateFlowchartResponse) }) flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => { @@ -359,7 +359,7 @@ flowchartRoutes.get("/problems/:id/flowchart/current", requireAuth, async (c) => const rows = await db.select({ score: schema.flowchartSubmission.aiScore, grade: schema.flowchartSubmission.aiGrade }) .from(schema.flowchartSubmission).where(and(eq(schema.flowchartSubmission.userId, c.get("user")!.id), eq(schema.flowchartSubmission.problemId, problemId), eq(schema.flowchartSubmission.status, 2))) .orderBy(desc(schema.flowchartSubmission.createTime)) - return success(c, flowchartCurrentSchema.parse({ count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" })) + return success(c, { count: rows.length, score: rows[0]?.score ?? 0, grade: rows[0]?.grade ?? "" } satisfies FlowchartCurrent) }) flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => { @@ -371,5 +371,5 @@ flowchartRoutes.get("/problems/:id/flowchart/history", requireAuth, async (c) => .orderBy(asc(schema.flowchartSubmission.createTime)) const selected = page === 0 ? rows.at(-1) : rows[page - 1] if (page > rows.length) return failure(c, 400, "page-out-of-range", "Page out of range") - return success(c, flowchartDetailSchema.parse({ submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length })) + return success(c, { submission: selected ? flowchartData(selected.flowchart, selected.username) : null, count: rows.length } satisfies FlowchartDetail) }) diff --git a/apps/api/src/routes/helpers.ts b/apps/api/src/routes/helpers.ts index 256fa1d..f46f286 100644 --- a/apps/api/src/routes/helpers.ts +++ b/apps/api/src/routes/helpers.ts @@ -1,9 +1,4 @@ -import { - ADMIN_ROLES, - TEACHER_ROLES, - sampleUserSchema, - type SampleUser, -} from "@oj2/contract" +import { ADMIN_ROLES, TEACHER_ROLES, type SampleUser } from "@oj2/contract" import { and, count, eq, notInArray } from "drizzle-orm" @@ -26,11 +21,11 @@ export function sampleUser( realName: string | null | undefined, options: { includeRealName?: boolean } = {}, ): SampleUser { - return sampleUserSchema.parse({ + return { id: source.id, username: source.username, realName: options.includeRealName === true ? (realName ?? null) : null, - }) + } satisfies SampleUser } /** @@ -49,18 +44,23 @@ export function stripClassPrefix( return username.startsWith(prefix) ? username.slice(prefix.length) : username } +/** + * 拿 query 里的筛选值去比对 `$type` 收窄过的列(`submission.result`、`problem.difficulty` 这些)。 + * + * 值来自 URL,不受控:前端下拉框以外的任何字符串都可能进来。对不上枚举时 SQL 一行都匹配不到, + * 和列没收窄之前的行为完全一致 —— 所以这里只做类型上的交接,**不加校验**: + * 在这儿拦一道会把「筛出空列表」变成「筛条件被忽略、返回全部」,那是另一种行为。 + */ +export function asFilterValue(value: string | number): T { + return value as T +} + export function objectValue(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {} } -export function stringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : [] -} - export function queryInteger( value: string | undefined, fallback: number, diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index bed20ee..9a99dab 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -1,11 +1,4 @@ -import { - problemAuthorSchema, - problemDetailSchema, - problemListItemSchema, - problemListSchema, - tagSchema, - yearlyAcSchema, -} from "@oj2/contract" +import type { ProblemAuthor, ProblemDetail, ProblemList, ProblemListItem, Tag, YearlyAc } from "@oj2/contract" import { and, asc, @@ -28,7 +21,7 @@ import { db, schema } from "../db" import { astRequirements } from "../judge/ast" import { failure, success } from "../http" import { JudgeStatus } from "../judge/status" -import { countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers" +import { asFilterValue, countFailedSubmissions, objectValue as toObject, queryInteger, sampleUser } from "./helpers" export const problemRoutes = new Hono() @@ -38,10 +31,6 @@ function objectValue(value: unknown): Record { : {} } -function stringArray(value: unknown): string[] { - return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [] -} - function publicTemplates(value: unknown) { const templates: Record = {} for (const [language, raw] of Object.entries(objectValue(value))) { @@ -76,7 +65,7 @@ function listItem( statuses: Record, ) { const status = toObject(statuses[String(row.problem.id)]).status - return problemListItemSchema.parse({ + return { id: row.problem.id, _id: row.problem.displayId, title: row.problem.title, @@ -90,7 +79,7 @@ function listItem( showFlowchart: row.problem.showFlowchart, hasAstRules: row.problem.astRules !== null, myStatus: typeof status === "number" ? status : null, - }) + } satisfies ProblemListItem } problemRoutes.get("/problems", optionalAuth, async (c) => { @@ -103,7 +92,7 @@ problemRoutes.get("/problems", optionalAuth, async (c) => { const tag = c.req.query("tag")?.trim() if (author) filters.push(eq(schema.user.username, author)) if (keyword) filters.push(or(ilike(schema.problem.title, `%${keyword}%`), ilike(schema.problem.displayId, `%${keyword}%`))!) - if (difficulty) filters.push(eq(schema.problem.difficulty, difficulty)) + if (difficulty) filters.push(eq(schema.problem.difficulty, asFilterValue(difficulty))) if (tag) { filters.push(inArray(schema.problem.id, db.select({ id: schema.problemTags.problemId }).from(schema.problemTags) .innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)) @@ -140,10 +129,10 @@ problemRoutes.get("/problems", optionalAuth, async (c) => { getProblemTags(rows.map((row) => row.problem.id)), getProblemStatuses(c.get("user")?.id), ]) - return success(c, problemListSchema.parse({ + return success(c, { results: rows.map((row) => listItem(row, tags, statuses)), total: totalRow?.value ?? 0, - })) + } satisfies ProblemList) }) problemRoutes.get("/problem-tags", async (c) => { @@ -161,7 +150,7 @@ problemRoutes.get("/problem-tags", async (c) => { .where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined) .groupBy(schema.problemTag.id, schema.problemTag.name).having(sql`count(${schema.problemTags.problemId}) > 0`) .orderBy(asc(schema.problemTag.name)) - return success(c, rows.map((row) => tagSchema.parse(row))) + return success(c, rows satisfies Tag[]) }) problemRoutes.get("/problems/random", async (c) => { @@ -177,7 +166,7 @@ problemRoutes.get("/problem-authors", async (c) => { .from(schema.problem).innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id)) .where(and(isNull(schema.problem.contestId), eq(schema.user.isDisabled, false), showAll ? undefined : eq(schema.problem.visible, true))) .groupBy(schema.user.username).orderBy(desc(count(schema.problem.id))) - return success(c, rows.map((row) => problemAuthorSchema.parse(row))) + return success(c, rows satisfies ProblemAuthor[]) }) problemRoutes.get("/problems/:id/beat-count", optionalAuth, async (c) => { @@ -232,7 +221,7 @@ problemRoutes.get("/problems/:displayId/yearly-ac", async (c) => { accepted: sql`count(*) filter (where ${schema.submission.result} in (0, 10))::int`, }).from(schema.submission).where(and(eq(schema.submission.problemId, problem.id), isNull(schema.submission.contestId), notInArray(schema.submission.result, [6, 7]))) .groupBy(year).orderBy(year) - return success(c, rows.map((row) => yearlyAcSchema.parse({ ...row, acRate: row.total > 0 ? Math.round(row.accepted / row.total * 10_000) / 100 : 0 }))) + return success(c, rows.map((row) => ({ ...row, acRate: row.total > 0 ? Math.round(row.accepted / row.total * 10_000) / 100 : 0 } satisfies YearlyAc))) }) problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => { @@ -283,7 +272,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => { } const samples = Array.isArray(row.problem.samples) ? row.problem.samples : [] - const data = problemDetailSchema.parse({ + const data = { id: row.problem.id, _id: row.problem.displayId, title: row.problem.title, @@ -292,7 +281,7 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => { outputDescription: row.problem.outputDescription, samples, hint: row.problem.hint, - languages: stringArray(row.problem.languages), + languages: row.problem.languages, template: publicTemplates(row.problem.template), createTime: row.problem.createTime, lastUpdateTime: row.problem.lastUpdateTime, @@ -316,11 +305,11 @@ problemRoutes.get("/problems/:displayId", optionalAuth, async (c) => { ? null : objectValue(row.problem.flowchartData), flowchartHint: row.problem.flowchartHint, - sqlConfig: row.problem.sqlConfig ? objectValue(row.problem.sqlConfig) : null, - sqlDisplay: row.problem.sqlDisplay ? objectValue(row.problem.sqlDisplay) : null, + sqlConfig: row.problem.sqlConfig, + sqlDisplay: row.problem.sqlDisplay, // 代码要求:只给渲染好的文案,规则原文不下发给学生 astRequirements: astRequirements(row.problem.astRules), - }) + } satisfies ProblemDetail return success(c, data) }) diff --git a/apps/api/src/routes/problemset.ts b/apps/api/src/routes/problemset.ts index 4c2376b..89a5719 100644 --- a/apps/api/src/routes/problemset.ts +++ b/apps/api/src/routes/problemset.ts @@ -1,13 +1,13 @@ import { - problemSetBadgeSchema, - problemSetListSchema, - problemSetProblemSchema, - problemSetProgressListSchema, - problemSetProgressSchema, - problemSetSchema, - updateProblemSetProgressRequestSchema, joinProblemSetRequestSchema, - userBadgeSchema, + updateProblemSetProgressRequestSchema, + type ProblemSet, + type ProblemSetBadge, + type ProblemSetList, + type ProblemSetProblem, + type ProblemSetProgress, + type ProblemSetProgressList, + type UserBadge, } from "@oj2/contract" import { and, @@ -32,7 +32,7 @@ import { failure, success } from "../http" import { JudgeStatus } from "../judge/status" import { updateAchievementsForProblemSet } from "../services/achievements" import { computeProgress, eligibleForBadge } from "../services/problemset" -import { objectValue, queryInteger, sampleUser } from "./helpers" +import { asFilterValue, objectValue, queryInteger, sampleUser } from "./helpers" export const problemsetRoutes = new Hono() @@ -65,7 +65,7 @@ async function problemSetCreators(ids: number[]) { } function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: boolean) { - return problemSetBadgeSchema.parse({ + return { id: badge.id, problemsetId: badge.problemsetId, name: badge.name, @@ -74,7 +74,7 @@ function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earned?: b conditionType: badge.conditionType, conditionValue: badge.conditionValue, isEarned: earned, - }) + } satisfies ProblemSetBadge } /** @@ -113,7 +113,7 @@ async function serializeProblemSets( const earned = new Set(earnedRows.map((item) => item.id)) return rows.map((row) => { const progress = progressBySet.get(row.id) - return problemSetSchema.parse({ + return { id: row.id, title: row.title, description: row.description, @@ -128,7 +128,7 @@ async function serializeProblemSets( completedCount: progress?.completedProblemsCount ?? 0, userProgress: progressSummary(progress), badges: includeBadges ? (badgesBySet.get(row.id) ?? []).map((badge) => badgeData(badge, earned.has(badge.id))) : undefined, - }) + } satisfies ProblemSet }) } @@ -140,17 +140,17 @@ problemsetRoutes.get("/problem-sets", optionalAuth, async (c) => { const difficulty = c.req.query("difficulty")?.trim() const status = c.req.query("status")?.trim() if (keyword) filters.push(or(ilike(schema.problemset.title, `%${keyword}%`), ilike(schema.problemset.description, `%${keyword}%`))!) - if (difficulty) filters.push(eq(schema.problemset.difficulty, difficulty)) - if (status) filters.push(eq(schema.problemset.status, status)) + if (difficulty) filters.push(eq(schema.problemset.difficulty, asFilterValue(difficulty))) + if (status) filters.push(eq(schema.problemset.status, asFilterValue(status))) const where = and(...filters) const [totalRows, rows] = await Promise.all([ db.select({ value: count() }).from(schema.problemset).where(where), db.select().from(schema.problemset).where(where).orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset), ]) - return success(c, problemSetListSchema.parse({ + return success(c, { results: await serializeProblemSets(rows, c.get("user")?.id, true), total: totalRows[0]?.value ?? 0, - })) + } satisfies ProblemSetList) }) problemsetRoutes.get("/problem-sets/:id", optionalAuth, async (c) => { @@ -189,7 +189,7 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => { .where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, c.get("user")!.id))).limit(1) : [] const completed = objectValue(progressRows[0]?.detail) - return success(c, rows.map(({ link, problemId, displayId, title, difficulty }) => problemSetProblemSchema.parse({ + return success(c, rows.map(({ link, problemId, displayId, title, difficulty }) => ({ id: link.id, problemsetId: link.problemsetId, problem: { id: problemId, _id: displayId, title, difficulty }, @@ -198,7 +198,7 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => { score: link.score, hint: link.hint, isCompleted: String(problemId) in completed, - }))) + } satisfies ProblemSetProblem))) }) async function recomputeProgress( @@ -344,13 +344,13 @@ problemsetRoutes.get("/users/:username/badges", optionalAuth, async (c) => { .from(schema.userBadge).innerJoin(schema.problemsetBadge, eq(schema.userBadge.badgeId, schema.problemsetBadge.id)) .innerJoin(schema.problemset, eq(schema.problemsetBadge.problemsetId, schema.problemset.id)) .where(eq(schema.userBadge.userId, target.id)).orderBy(desc(schema.userBadge.earnedTime)) - return success(c, rows.map(({ userBadge, badge, problemSet }) => userBadgeSchema.parse({ + return success(c, rows.map(({ userBadge, badge, problemSet }) => ({ id: userBadge.id, userId: userBadge.userId, badge: badgeData(badge), earnedTime: userBadge.earnedTime, problemset: { id: problemSet.id, title: problemSet.title }, - }))) + } satisfies UserBadge))) }) problemsetRoutes.get("/problem-sets/:id/badges", async (c) => { @@ -399,7 +399,7 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c .orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)), ]) const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem])) - const results = rows.map(({ progress, user: progressUser, realName }) => problemSetProgressSchema.parse({ + const results = rows.map(({ progress, user: progressUser, realName }) => ({ id: progress.id, problemsetId: progress.problemsetId, user: sampleUser(progressUser, realName), @@ -411,12 +411,12 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c totalProblemsCount: progress.totalProblemsCount, totalScore: progress.totalScore, completedProblems: Object.keys(objectValue(progress.progressDetail)).flatMap((key) => problemMap.get(key) ?? []), - })) + } satisfies ProblemSetProgress)) const stats = statsRows[0] - return success(c, problemSetProgressListSchema.parse({ + return success(c, { results, total: stats?.total ?? 0, statistics: { total: stats?.total ?? 0, completed: stats?.completed ?? 0, avgProgress: Number(stats?.avgProgress ?? 0) }, problems: problemRows, - })) + } satisfies ProblemSetProgressList) }) diff --git a/apps/api/src/routes/site.ts b/apps/api/src/routes/site.ts index c68b1ad..586dbaf 100644 --- a/apps/api/src/routes/site.ts +++ b/apps/api/src/routes/site.ts @@ -1,4 +1,4 @@ -import { onlineCountSchema, quoteSchema, websiteConfigSchema } from "@oj2/contract" +import type { OnlineCount, Quote, WebsiteConfig } from "@oj2/contract" import { asc, desc, eq } from "drizzle-orm" import { Hono } from "hono" import { resolve } from "node:path" @@ -14,7 +14,7 @@ export const siteRoutes = new Hono() siteRoutes.get("/site", async (c) => { const options = await getWebsiteOptions() - return success(c, websiteConfigSchema.parse({ + return success(c, { websiteBaseUrl: options.website_base_url, websiteName: options.website_name, websiteNameShortcut: options.website_name_shortcut, @@ -23,7 +23,7 @@ siteRoutes.get("/site", async (c) => { submissionListShowAll: options.submission_list_show_all, classList: options.class_list, enableMaxkb: options.enable_maxkb, - })) + } satisfies WebsiteConfig) }) /** @@ -31,7 +31,7 @@ siteRoutes.get("/site", async (c) => { * 而榜单页本身就允许匿名看。谁在线是另一回事,只在 /rankings/users 里对老师下发。 */ siteRoutes.get("/site/online", async (c) => { - return success(c, onlineCountSchema.parse({ count: await onlineCount() })) + return success(c, { count: await onlineCount() } satisfies OnlineCount) }) // 数据集读不到时的兜底(本机 dev 没挂 data/hitokoto 就会走这里) @@ -47,11 +47,6 @@ const fallbackQuotes = [ let categoryPaths: string[] | null = null const sentenceCache = new Map() -interface Quote { - hitokoto: string - from: string -} - async function loadSentences(path: string) { const cached = sentenceCache.get(path) if (cached) return cached @@ -78,10 +73,10 @@ async function randomQuote() { siteRoutes.get("/quotes/random", async (c) => { try { - return success(c, quoteSchema.parse(await randomQuote())) + return success(c, (await randomQuote()) satisfies Quote) } catch { const item = fallbackQuotes[Math.floor(Math.random() * fallbackQuotes.length)]! - return success(c, quoteSchema.parse(item)) + return success(c, item satisfies Quote) } }) diff --git a/apps/api/src/routes/submission.ts b/apps/api/src/routes/submission.ts index 36036fb..9913c24 100644 --- a/apps/api/src/routes/submission.ts +++ b/apps/api/src/routes/submission.ts @@ -2,14 +2,14 @@ import { randomBytes } from "node:crypto" import { createSubmissionRequestSchema, - createSubmissionResponseSchema, formatCodeRequestSchema, - formatCodeResponseSchema, - submissionDetailSchema, - submissionListItemSchema, - submissionListSchema, - submissionStatisticsItemsSchema, - submissionStatisticsSchema, + type CreateSubmissionResponse, + type FormatCodeResponse, + type SubmissionDetail, + type SubmissionList, + type SubmissionListItem, + type SubmissionStatistics, + type SubmissionStatisticsItems, } from "@oj2/contract" import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm" import { Hono } from "hono" @@ -23,7 +23,7 @@ import { import type { AuthUser } from "../auth/session" import { db, schema } from "../db" import { failure, success } from "../http" -import { JudgeStatus, UNJUDGED_RESULTS } from "../judge/status" +import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status" import { judgeQueue } from "../queue" import { canAccessContest, @@ -36,22 +36,10 @@ import { import { CodeFormatError, formatCode } from "../services/format-code" import { getBooleanOption } from "../services/options" import { consumeToken } from "../services/throttling" -import { - isAdminRole, - queryInteger, - rounded, - stripClassPrefix, - todayStart, -} from "./helpers" +import { asFilterValue, isAdminRole, queryInteger, rounded, stripClassPrefix, todayStart } from "./helpers" export const submissionRoutes = new Hono() -function stringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((item): item is string => typeof item === "string") - : [] -} - function objectValue(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -100,7 +88,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => { .limit(1) if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") - if (!stringArray(problem.languages).includes(parsed.data.language)) { + if (!problem.languages.includes(parsed.data.language)) { return failure( c, 400, @@ -162,7 +150,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => { return success( c, - createSubmissionResponseSchema.parse({ submissionId }), + { submissionId } satisfies CreateSubmissionResponse, 201, ) }) @@ -258,9 +246,11 @@ const FAILURE_MESSAGE_LIMIT = 400 * statistic_info 的那一段,提交详情页读的也是它。 */ async function lastFailureByUser(where: SQL | undefined, userIds: number[]) { + // result 手写成 JudgeStatusValue:这条裸 SQL 读的就是 submission.result 那一列, + // 口径要和列上的 $type 一致 const byUser = new Map< number, - { id: string; problem: string; result: number; error: string | null } + { id: string; problem: string; result: JudgeStatusValue; error: string | null } >() if (!userIds.length) return byUser @@ -269,7 +259,7 @@ async function lastFailureByUser(where: SQL | undefined, userIds: number[]) { user_id: number id: string problem: string - result: number + result: JudgeStatusValue error: string | null }>(sql` select user_id, id, problem, result, error from ( @@ -606,7 +596,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => { return success( c, - submissionStatisticsSchema.parse({ + { submissionCount, acceptedCount, judgingCount, @@ -615,7 +605,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => { data, dataUnaccepted, dataAttempted, - }), + } satisfies SubmissionStatistics, ) }) @@ -661,10 +651,10 @@ submissionRoutes.get("/submissions/statistics/items", requireTeacher, async (c) const truncated = rows.length > STATISTICS_ITEMS_LIMIT return success( c, - submissionStatisticsItemsSchema.parse({ + { items: rows.slice(0, STATISTICS_ITEMS_LIMIT), truncated, - }), + } satisfies SubmissionStatisticsItems, ) }) @@ -696,7 +686,7 @@ submissionRoutes.post("/code/format", requireAuth, async (c) => { if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid format payload") try { const code = await formatCode(parsed.data.code, parsed.data.language) - return success(c, formatCodeResponseSchema.parse({ code })) + return success(c, { code } satisfies FormatCodeResponse) } catch (error) { if (error instanceof CodeFormatError) { return failure(c, error.kind === "syntax" ? 400 : 500, error.kind === "syntax" ? "format-error" : "format-tool-error", error.message) @@ -831,7 +821,7 @@ async function submissionDetail(id: string, user: AuthUser) { // submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与 // SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。 const full = isAdminRole(user) - return submissionDetailSchema.parse({ + return { id: row.submission.id, createTime: row.submission.createTime, userId: row.submission.userId, @@ -847,7 +837,7 @@ async function submissionDetail(id: string, user: AuthUser) { // problem 表本来就 join 了,不额外查库 problemDisplayId: row.problem.displayId, showLink: true, - }) + } satisfies SubmissionDetail } /** @@ -926,7 +916,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => { // 「非管理员即受限」,不能写成「是普通用户才受限」—— // 后者对匿名用户(user 为 null)会短路,匿名反而能看到全部提交,权限大于登录学生。 if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) { - return success(c, submissionListSchema.parse({ results: [], total: 0 })) + return success(c, { results: [], total: 0 } satisfies SubmissionList) } const filters = [isNull(schema.submission.contestId)] const displayId = c.req.query("problemId")?.trim() @@ -936,8 +926,8 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => { if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`) if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id)) else if (username) filters.push(usernameFilter(username)) - if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, Number(result))) - if (language) filters.push(eq(schema.submission.language, language)) + if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, asFilterValue(Number(result)))) + if (language) filters.push(eq(schema.submission.language, asFilterValue(language))) if (c.req.query("today") === "1") filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`) const where = and(...filters) // count 不 join problem:problem 只有按题号筛选时才出现在 where 里,无条件 join 会让 @@ -960,8 +950,8 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => { // 来源题单的标题。一页里不同题单最多几个,按主键查一次就够 problemsetTitleMap(rows.map((row) => row.submission.problemsetId)), ]) - return success(c, submissionListSchema.parse({ - results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({ + return success(c, { + results: rows.map(({ submission, problem }) => ({ id: submission.id, problem: problem.displayId, problemTitle: problem.title, @@ -976,9 +966,9 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => { problemSet: submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId) ? { id: submission.problemsetId, title: problemsetTitles.get(submission.problemsetId)! } : null, - })), + } satisfies SubmissionListItem)), total: totalRows[0]?.value ?? 0, - })) + } satisfies SubmissionList) }) submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireContestAccess("submissions", "contestId"), async (c) => { @@ -993,7 +983,7 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`) if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id)) else if (username) filters.push(usernameFilter(username)) - if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, Number(result))) + if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, asFilterValue(Number(result)))) if (contestStatus(contest) !== "1") filters.push(sql`${schema.submission.createTime} >= ${contest.startTime}`) const where = and(...filters) // count 不 join problem:problem 只有按题号筛选时才出现在 where 里,无条件 join 会让 @@ -1013,8 +1003,8 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo // `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交, // 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。 // 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。 - return success(c, submissionListSchema.parse({ - results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({ + return success(c, { + results: rows.map(({ submission, problem }) => ({ id: submission.id, problem: problem.displayId, problemTitle: problem.title, @@ -1028,9 +1018,9 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo // 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了 // isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段 problemSet: null, - })), + } satisfies SubmissionListItem)), total: totalRows[0]?.value ?? 0, - })) + } satisfies SubmissionList) }) submissionRoutes.get("/submissions/:id", requireAuth, async (c) => { diff --git a/apps/api/src/services/profile.ts b/apps/api/src/services/profile.ts index a4fb244..39b5db6 100644 --- a/apps/api/src/services/profile.ts +++ b/apps/api/src/services/profile.ts @@ -1,4 +1,4 @@ -import { sessionUserSchema, userProfileSchema } from "@oj2/contract" +import type { SessionUser, UserProfile } from "@oj2/contract" import { and, eq } from "drizzle-orm" import { db, schema } from "../db" @@ -12,9 +12,9 @@ export async function getUserProfileById(userId: number, showRealName: boolean) .limit(1) if (!row) return null - return userProfileSchema.parse({ + return { id: row.profile.id, - user: sessionUserSchema.parse({ + user: { id: row.user.id, username: row.user.username, email: row.user.email, @@ -24,12 +24,12 @@ export async function getUserProfileById(userId: number, showRealName: boolean) lastLogin: row.user.lastLogin, isDisabled: row.user.isDisabled, className: row.user.className, - }), + } satisfies SessionUser, realName: showRealName ? row.profile.realName : null, acmProblemsStatus: row.profile.acmProblemsStatus, avatar: row.profile.avatar, mood: row.profile.mood, acceptedNumber: row.profile.acceptedNumber, submissionNumber: row.profile.submissionNumber, - }) + } satisfies UserProfile } diff --git a/apps/api/src/websocket.ts b/apps/api/src/websocket.ts index f0136b9..b4fd7bd 100644 --- a/apps/api/src/websocket.ts +++ b/apps/api/src/websocket.ts @@ -1,4 +1,4 @@ -import { flowchartUpdateSchema, submissionUpdateSchema } from "@oj2/contract" +import { submissionUpdateSchema, type FlowchartUpdate } from "@oj2/contract" import { and, eq } from "drizzle-orm" import { touchSession } from "./auth/session" @@ -345,11 +345,11 @@ async function handleMessage( return } const replay = flowchart.status === 2 - ? { type: "flowchart_evaluation_completed", submissionId: flowchart.id, score: flowchart.score ?? undefined, grade: flowchart.grade ?? undefined } + ? { type: "flowchart_evaluation_completed" as const, submissionId: flowchart.id, score: flowchart.score ?? undefined, grade: flowchart.grade ?? undefined } : flowchart.status === 3 - ? { type: "flowchart_evaluation_failed", submissionId: flowchart.id } - : { type: "flowchart_evaluation_update", submissionId: flowchart.id } - ws.send(JSON.stringify(flowchartUpdateSchema.parse(replay))) + ? { type: "flowchart_evaluation_failed" as const, submissionId: flowchart.id } + : { type: "flowchart_evaluation_update" as const, submissionId: flowchart.id } + ws.send(JSON.stringify(replay satisfies FlowchartUpdate)) return } diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md index f7f0d6a..ea63f72 100644 --- a/apps/web/CLAUDE.md +++ b/apps/web/CLAUDE.md @@ -120,9 +120,12 @@ return contract("GET /problems/:id", problemDetailSchema, value) - `exercise.data` 按题型收紧后,后端读路径(`routes/content.ts` 硬 parse)变成 一道闸,一行脏数据能让整条练习列表 500。 -所以:**同一个 schema 后端也在 `parse`**(`submissionDetailSchema` / -`exerciseSchema` / `contestRankItemSchema` 都是),收紧任何字段之前,拿根目录 -那份生产备份把全量数据跑一遍,尤其要看**空值**而不只是键集合。 +**后端出参已经不 `parse` 了**(原来 136 处,全部改成 `satisfies`;撤的时候炸出两个 +一直存在的线上 500,见 `../CLAUDE.md` 的「出参不 `parse`,用 `satisfies`」)。 +所以现在收紧一个字段的直接后果落在 **`tsc` 编译期**,而不再是运行时 500 —— 这是好事, +但别因此就放心大胆收:契约里的形状仍然要对得上库里的存量数据,前端拿到对不上的值 +一样会渲染错。收紧任何字段之前,拿根目录那份生产备份把全量数据跑一遍, +尤其要看**空值**而不只是键集合。 ### Key Utilities diff --git a/packages/contract/src/admin.ts b/packages/contract/src/admin.ts index 1116d49..0a70bc7 100644 --- a/packages/contract/src/admin.ts +++ b/packages/contract/src/admin.ts @@ -6,6 +6,7 @@ import { achievementRaritySchema } from "./achievement" import { rankProfileSchema } from "./account" import { paginatedSchema, sampleUserSchema } from "./common" import { reactionKeySchema } from "./content" +import { problemLanguageSchema } from "./language" import { astRulesSchema, problemDifficultySchema, @@ -671,7 +672,12 @@ export const adminProblemSchema = z.object({ languages: z.array(z.string()), template: z.record(z.string(), z.string()), createTime: z.string(), - lastUpdateTime: z.string(), + /** + * `problem.last_update_time` 是全库唯一可空的那一列(961 道题里 470 道是 NULL: + * 从来没被编辑过的老题)。公共的 problemDetailSchema 早就是 nullable,这里漏了, + * 于是后台打开任何一道没编辑过的题都会 500 在 parse 上。 + */ + lastUpdateTime: z.string().nullable(), timeLimit: z.number().int(), memoryLimit: z.number().int(), visible: z.boolean(), @@ -718,7 +724,9 @@ export const createProblemRequestSchema = z.object({ testCaseScore: z.array(problemTestCaseScoreSchema), timeLimit: z.number().int().min(1).max(1000 * 60), memoryLimit: z.number().int().min(1).max(1024), - languages: z.array(z.string()).min(1), + // 收窄到语言联合而不是裸 string[]:`problem.languages` 列上挂着 + // `$type()`,那个断言得有人兑现 —— 闸就设在这里(写入侧)。 + languages: z.array(problemLanguageSchema).min(1), template: z.record(z.string(), z.string()), visible: z.boolean(), difficulty: z.enum(["Low", "Mid", "High"]), diff --git a/packages/contract/src/submission.ts b/packages/contract/src/submission.ts index d4b48cf..c1fd972 100644 --- a/packages/contract/src/submission.ts +++ b/packages/contract/src/submission.ts @@ -149,7 +149,10 @@ export const submissionDetailSchema = z.object({ * 将来有人「顺手」把空值改成真值就不会变成泄露,因为这里压根没有这些字段。 */ export const embeddedSubmissionSchema = submissionDetailSchema - .omit({ info: true, contestId: true, problemId: true }) + // problemDisplayId 也要去掉:下面的 problem 就是它,同一个值留两份, + // 而路由只填了 problem —— 这里漏 omit 的那阵子,凡是收到过站内信的人 + // 打开消息页都是 500(parse 抛在缺失的 problemDisplayId 上,列表为空时才碰巧不炸)。 + .omit({ info: true, contestId: true, problemId: true, problemDisplayId: true }) // 旧 SubmissionSafeModelSerializer 里 problem 是 // `SlugRelatedField(slug_field="_id")`,即**展示用题号**而非数字主键。 // 站内信页面拿它拼 `/problem/<题号>` 链接,给数字 id 会拼出打不开的地址。