import { randomBytes } from "node:crypto" import { createSubmissionRequestSchema, formatCodeRequestSchema, type CreateSubmissionResponse, type FormatCodeResponse, type SubmissionDetail, type SubmissionList, type SubmissionListItem, type SubmissionTrace, } from "@oj2/contract" import { and, count, desc, eq, inArray, isNull, sql, type SQL } from "drizzle-orm" import { Hono } from "hono" import { optionalAuth, requireAuth, requireTeacher } from "../auth/middleware" import type { AuthUser } from "../auth/session" import { db, schema } from "../db" import { failure, parseBody, success } from "../http" import { compileFails, templateForLanguage } from "../judge/run" import { JudgeStatus } from "../judge/status" import { parseProblemTemplate } from "../judge/template" import { judgeQueue } from "../queue" import { canAccessContest, contestStatus, findAccessibleContest, requireContestAccess, type ContestEnv, } from "../services/contest" import { CodeFormatError, formatCode } from "../services/format-code" import { getBooleanOption } from "../services/options" import { problemSetLockCutoffs } from "../services/problemset" import { consumeToken } from "../services/throttling" import { dayStart } from "../time" import { asFilterValue, asRecord, isAdminRole, isTeacherOrAbove, queryInteger } from "./helpers" import { classNameFilter, exactUsernameFilter, problemFilter, submissionStatisticsRoutes, unknownDisplayIds, usernameFilter, } from "./submission-statistics" export const submissionRoutes = new Hono() /** * 落编辑过程信号。**失败只记日志、不影响提交** —— 这是附带的统计数据, * 提交已经进库了,不能因为它回一个 500 让学生以为没交上。 * * `since_prev_ms` 在同一条 INSERT 里用子查询算,排掉刚插进去的这条自己; * 两次提交并发到达时也各自取到的是对方之外的最近一条。这道题第一次提交时 * `max()` 为 null,列就是 null。 */ async function saveTrace( submissionId: string, userId: number, problemId: number, createTime: string, trace: SubmissionTrace, ) { try { await db.insert(schema.submissionTrace).values({ submissionId, ...trace, sincePrevMs: sql`( select (extract(epoch from ${createTime}::timestamptz - max(${schema.submission.createTime})) * 1000)::bigint from ${schema.submission} where ${schema.submission.userId} = ${userId} and ${schema.submission.problemId} = ${problemId} and ${schema.submission.id} <> ${submissionId} )`, }) } catch (error) { console.error("Failed to record submission trace", error) } } submissionRoutes.post("/submissions", requireAuth, async (c) => { const parsed = await parseBody(c, createSubmissionRequestSchema, "Invalid submission payload") if (!parsed.success) return parsed.response let contestId: number | null = null if (parsed.data.contestId) { // 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体, // 中间件跑的时候 body 还没解析。全仓只有这一处仍是手工调用,改动时留意别漏掉鉴权。 const contest = await findAccessibleContest(c.get("user"), parsed.data.contestId) if (!contest) return failure(c, 404, "contest-not-found", "Contest does not exist") const access = await canAccessContest(c, contest, "problems") if (!access.ok) return failure(c, access.code === "login-required" ? 401 : 403, access.code, access.message) if (contestStatus(contest) === "-1") return failure(c, 403, "contest-ended", "The contest has ended") contestId = contest.id } // 限流,位置与旧后端 submission/views/oj.py 的 SubmissionAPI.post 一致: // 比赛权限校验之后、取题目之前,按用户 id 消耗一个令牌。判题沙箱是有限资源。 const throttle = await consumeToken("user", String(c.get("user")!.id)) if (!throttle.allowed) { return failure( c, 429, "too-many-submissions", `Please wait ${Math.floor(throttle.wait)} seconds`, ) } const [problem] = await db .select({ id: schema.problem.id, languages: schema.problem.languages, }) .from(schema.problem) .where( and( eq(schema.problem.id, parsed.data.problemId), eq(schema.problem.visible, true), contestId === null ? isNull(schema.problem.contestId) : eq(schema.problem.contestId, contestId), ), ) .limit(1) if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist") if (!problem.languages.includes(parsed.data.language)) { return failure( c, 400, "language-not-allowed", `${parsed.data.language} is not allowed in the problem`, ) } // 来源题单:前端只在 /problemset/:id/problem/:pid 那个入口带上它,落库纯粹是为了 // 在提交列表里标出「这条是刷题单刷出来的」。校验只确认这道题确实在那个题单里 —— // 不查 visible / status,因为藏起来的题单里还困着已加入的学生(他们照样在做题), // 也不查有没有加入:没加入照样能从题单页点进题目,标记来源不该比入口本身更严。 // 对不上就当没带,提交照收:来源标记错了顶多列表少个标签,不值得挡下一次提交。 let problemsetId: number | null = null if (contestId === null && parsed.data.problemSetId) { const [link] = await db .select({ id: schema.problemsetProblem.id }) .from(schema.problemsetProblem) .where( and( eq(schema.problemsetProblem.problemsetId, parsed.data.problemSetId), eq(schema.problemsetProblem.problemId, problem.id), ), ) .limit(1) if (link) problemsetId = parsed.data.problemSetId } const user = c.get("user")! const submissionId = randomBytes(16).toString("hex") const createTime = new Date().toISOString() await db.insert(schema.submission).values({ id: submissionId, problemId: problem.id, problemsetId, createTime, userId: user.id, username: user.username, code: parsed.data.code, result: JudgeStatus.PENDING, info: {}, language: parsed.data.language, statisticInfo: {}, contestId, }) if (parsed.data.trace) await saveTrace(submissionId, user.id, problem.id, createTime, parsed.data.trace) try { await judgeQueue.add("judge", { submissionId, problemId: problem.id }, { jobId: submissionId }) } catch (error) { await db .update(schema.submission) .set({ result: JudgeStatus.SYSTEM_ERROR }) .where(eq(schema.submission.id, submissionId)) console.error("Failed to enqueue submission", error) return failure(c, 502, "queue-unavailable", "Judge queue is unavailable") } return success(c, { submissionId } satisfies CreateSubmissionResponse, 201) }) submissionRoutes.get("/submissions/today-count", async (c) => { const language = c.req.query("language") if (language === "Flowchart") { const [row] = await db .select({ value: count() }) .from(schema.flowchartSubmission) .where(sql`${schema.flowchartSubmission.createTime} >= ${dayStart()}`) return success(c, row?.value ?? 0) } const [row] = await db .select({ value: count() }) .from(schema.submission) .where( and( isNull(schema.submission.contestId), sql`${schema.submission.createTime} >= ${dayStart()}`, ), ) return success(c, row?.value ?? 0) }) submissionRoutes.route("/", submissionStatisticsRoutes) // 老师也能重判:旧栈只给超管,前端却一直给老师亮着按钮,老师点了只会「权限不足」。 // 流程图那边老师本来就能重新评分,两边对齐。比赛提交不在此列(下面 isNull(contestId)) submissionRoutes.post("/submissions/:id/rejudge", requireTeacher, async (c) => { const [row] = await db .select({ id: schema.submission.id, problemId: schema.submission.problemId, result: schema.submission.result, }) .from(schema.submission) .where(and(eq(schema.submission.id, c.req.param("id")), isNull(schema.submission.contestId))) .limit(1) if (!row) return failure(c, 404, "submission-not-found", "Submission does not exist") // info 一起清:不清的话判题中那几秒,列表和详情按旧的逐点结果算出「通过 2/3」 await db .update(schema.submission) .set({ statisticInfo: {}, info: {}, result: JudgeStatus.PENDING }) .where(eq(schema.submission.id, row.id)) // jobId 必须带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId 做 jobId 的话 // BullMQ 会认为这个任务已经存在,重判静默变成空操作。与 flowcharts/:id/retry 同一处理。 await judgeQueue.add( "judge", // 带上重判之前的结果,落库时只做差量(见 judge/run.ts 的 persistResult) { submissionId: row.id, problemId: row.problemId, rejudgedFrom: row.result }, { jobId: `${row.id}:rejudge:${Date.now()}` }, ) return success(c, null) }) submissionRoutes.post("/code/format", requireAuth, async (c) => { const parsed = await parseBody(c, formatCodeRequestSchema, "Invalid format payload") if (!parsed.success) return parsed.response // C / C++ 编不过就原样返回,不格式化(理由见 judge/run.ts 的 compileFails)。 // 代码照常提交、照常判成编译错误,只是不改动学生的代码 const { code: source, language, problemId } = parsed.data if (language === "c" || language === "cpp") { const judgeLanguage = language === "c" ? "C" : "C++" const [problem] = problemId === undefined ? [] : await db .select({ template: schema.problem.template }) .from(schema.problem) .where(eq(schema.problem.id, problemId)) .limit(1) const rawTemplate = problem ? templateForLanguage(problem.template, judgeLanguage) : null const template = rawTemplate ? parseProblemTemplate(rawTemplate) : null const full = template ? `${template.prepend}\n${source}\n${template.append}` : source if (await compileFails(judgeLanguage, full)) { return success(c, { code: source } satisfies FormatCodeResponse) } } try { const code = await formatCode(source, language) return success(c, { code } satisfies FormatCodeResponse) } catch (error) { if (error instanceof CodeFormatError) { return failure( c, error.kind === "syntax" ? 400 : 500, // syntax-error 的 message 是 CPython 的报错原文,前端拿去翻成中文 error.kind === "syntax" ? "syntax-error" : "format-tool-error", error.message, ) } throw error } }) // 参数按「实际用到的字段」声明,而不是整行 $inferSelect:列表接口只 select 需要的列, // 传不进完整行。完整行在结构上满足这两个窄类型,详情接口照旧调用不受影响。 function canViewSubmission( user: AuthUser | null, row: { userId: number; problemId: number; createTime: string }, problem: { createdById: number }, problemSetCutoffs?: Map, ) { if (!user) return false // 题单防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。 // 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。 if (row.userId === user.id && !isTeacherOrAbove(user)) { const cutoff = problemSetCutoffs?.get(row.problemId) if (cutoff !== undefined && Date.parse(row.createTime) < Date.parse(cutoff)) return false } // 看别人代码的角色捷径只给老师和超管。学生管理员算学生(STUDENT_ROLES):他参赛、上榜、 // 和同学做同一份作业,能读别人的提交就是开卷。旧后端这里是 `not user.is_regular_user()`, // 学生管理员同样放行;OJ2 先收紧到「比赛没结束时不给」,现在整条都不给。 // // 只掐角色捷径,不掐 `problem.createdById === user.id`:那是这道题的作者本人, // 他早就知道答案了,挡他没有意义。 const elevated = isTeacherOrAbove(user) // 这三条就是全部:别人的代码谁都看不到,比赛内外一样。 // 分享功能(problem.share_submission 题目级 / submission.shared 单条)已经删掉, // 原来结尾的 `return problem.shareSubmission || row.shared` 随之消失;它上面那条 // 「比赛未结束一律不给」也一并去掉 —— 走到那里的必然不是本人/管理员/作者, // 现在无论比赛与否都是 false,留着是重复的。 return row.userId === user.id || elevated || problem.createdById === user.id } /** * 提交列表只取序列化用得到的列。取 `submission.*` / `problem.*` 会把 * submission.code(学生源码)、info、ip 和 problem 的 description / input_description / * output_description / hint / samples / answers / flowchart_data / sql_display 一并拉回来, * 这些字段列表一个都不用,纯属白传。 */ const submissionListColumns = { submission: { id: schema.submission.id, createTime: schema.submission.createTime, userId: schema.submission.userId, // 题单闸门要按题定位,序列化本身用不到它 problemId: schema.submission.problemId, /** * 显示**当前**用户名,和统计面板、个人主页对齐。列表里读的那份是提交时冻结的 * 快照,改过名的学生会显示旧名字 —— 按 `ks248` 筛出来的行却写着 * `ks24数媒1班ksXXX`,看着像筛错了。 * * 已删号的学生 user 表里没有行,退回冻结的那份(否则整列空着)。 */ username: sql`coalesce(${schema.user.username}, ${schema.submission.username})`, className: schema.user.className, result: schema.submission.result, language: schema.submission.language, statisticInfo: schema.submission.statisticInfo, // 只取 id,题单标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来 // 会动到下面那条调过的分页查询,而每页最多两三个不同的题单,PK 查一次更便宜 problemsetId: schema.submission.problemsetId, /** * 通过的测试点数 / 总数,口径同 caseSummary():只认 info.data 是数组、逐项 result 为 0。 * 在库里数完只回两个整数 —— info 带着每个点的 output_md5,整列拉回来只为数个数不值当 */ casesTotal: sql< number | null >`case when jsonb_typeof(${schema.submission.info} -> 'data') = 'array' then jsonb_array_length(${schema.submission.info} -> 'data') end`, casesPassed: sql< number | null >`case when jsonb_typeof(${schema.submission.info} -> 'data') = 'array' then (select count(*)::int from jsonb_array_elements(${schema.submission.info} -> 'data') as item where item ->> 'result' = '0') end`, }, problem: { displayId: schema.problem.displayId, title: schema.problem.title, createdById: schema.problem.createdById, }, } as const /** * 从判题原文里数出通过的测试点,为 null 的情形见契约 `caseSummary` 的注释。 * 这里只信「有没有 data 数组」,数组项的形状信判题机,和前端 submissionCaseResults 同口径。 */ function caseSummary(submission: typeof schema.submission.$inferSelect) { if (submission.contestId !== null || submission.language === "SQL") return null const data = asRecord(submission.info).data if (!Array.isArray(data) || data.length === 0) return null const passed = data.filter((item) => asRecord(item).result === JudgeStatus.ACCEPTED).length return { passed, total: data.length } } /** 列表行的 caseSummary:SQL 里数好的两个数,为 null 的情形和详情一致(SQL 题、没有逐点结果) */ function listCaseSummary(row: { language: string casesTotal: number | null casesPassed: number | null }) { if (row.language === "SQL" || !row.casesTotal) return null return { passed: row.casesPassed ?? 0, total: row.casesTotal } } /** * 提交详情。三种结局:没有这条(404)、别人的代码看不到(403)、能看 —— 能看里又分自己的 * 代码在题单布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。 */ async function submissionDetail(id: string, user: AuthUser, withAttempts: boolean) { const [row] = await db .select({ submission: schema.submission, problem: schema.problem, // 当前用户名 / 班级,和提交列表同口径;已删号的学生 user 表里没有行 currentUsername: schema.user.username, className: schema.user.className, problemSetTitle: schema.problemset.title, }) .from(schema.submission) .innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) .leftJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .leftJoin(schema.problemset, eq(schema.problemset.id, schema.submission.problemsetId)) .where(eq(schema.submission.id, id)) .limit(1) if (!row) return { status: "not-found" } as const const own = row.submission.userId === user.id // 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103) // 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。 const cutoffs = isTeacherOrAbove(user) || !own ? undefined : await problemSetLockCutoffs(user.id, [row.submission.problemId]) const visible = canViewSubmission(user, row.submission, row.problem, cutoffs) // 走到这里还看不了、又是自己的,只剩题单布置期这一种(见 canViewSubmission) if (!visible && !own) return { status: "forbidden" } as const // info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端: // submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与 // SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。 const full = isAdminRole(user) && visible const attempts = withAttempts ? await db .select({ id: schema.submission.id, createTime: schema.submission.createTime, userId: schema.submission.userId, problemId: schema.submission.problemId, result: schema.submission.result, }) .from(schema.submission) .where( and( eq(schema.submission.userId, row.submission.userId), eq(schema.submission.problemId, row.submission.problemId), row.submission.contestId === null ? isNull(schema.submission.contestId) : eq(schema.submission.contestId, row.submission.contestId), ), ) .orderBy(schema.submission.createTime, schema.submission.id) .limit(200) : null return { status: "ok", data: { id: row.submission.id, createTime: row.submission.createTime, userId: row.submission.userId, username: row.currentUsername ?? row.submission.username, code: visible ? row.submission.code : "", result: row.submission.result, info: full ? row.submission.info : {}, language: row.submission.language, statisticInfo: visible ? asRecord(row.submission.statisticInfo) : {}, // contest 也在旧后端的排除名单里,同样只给管理员。提交者本人例外:他知道自己在哪场比赛 // 里交的,不给的话独立页面上「复制回到题目」「题目链接」只能拼出题库的 // /problem/<题号> —— 比赛里的题号是比赛自己的,指到的是另一道题 contestId: full || own ? row.submission.contestId : null, problemId: row.submission.problemId, // problem 表本来就 join 了,不额外查库 problemDisplayId: row.problem.displayId, problemTitle: row.problem.title, className: row.className, problemSet: row.submission.problemsetId !== null && row.problemSetTitle !== null ? { id: row.submission.problemsetId, title: row.problemSetTitle } : null, showLink: visible, caseSummary: visible ? caseSummary(row.submission) : null, attempts: attempts?.map((item) => ({ id: item.id, createTime: item.createTime, result: item.result, showLink: canViewSubmission(user, item, row.problem, cutoffs), })) ?? null, } satisfies SubmissionDetail, } as const } /** * 提交列表取数据。深翻页不走 `LIMIT n OFFSET m`——Postgres 对 OFFSET 没有捷径,前 m 行 * 必须真的产出再丢掉,而丢弃发生在 join 之后,每一行都白回了一次表。生产快照(10.4 万条 * 公开提交)上最后一页实测 1258ms、碰了 95347 个 buffer。而且越早的页越慢:平时没人翻, * 那些数据页从来不在 shared_buffers 里,全是冷读。 * * 拆成两步就便宜得多: * 1. 只 select create_time / id —— 正好是 submission_public_create_time_id_idx 的两列, * 跳过 m 行走 Index Only Scan,Heap Fetches 为 0,纯在索引页里数数; * 2. 拿这一行当游标做 keyset 回查,只回表取 limit 行。 * 同一页实测降到约 9ms、885 个 buffer。代价变成 O(m) 个**索引条目**而不是堆页,按快照里 * 的索引密度外推,涨到 100 万条时最深一页仍在几十毫秒量级。 * * 排序必须带 id:create_time 只有毫秒精度(`new Date().toISOString()`),光靠它不是全序, * 游标用 `<=` 回查时同毫秒的上一页末行会重复出现在下一页页首。索引已按 (create_time DESC, * id DESC) 建好,带上 id 不会多出 Sort 节点。 * * offset 为 0 时没有可跳过的行,直接取,省一次往返。 * * **按用户名筛选另走一条路**:先把匹配的行整个圈出来(`materialized` 挡住规划器), * 在圈里排序取页,再按主键回表。不圈的话规划器一见 `ORDER BY ... LIMIT` 就选时间索引 * 倒扫、边扫边滤——它按平均密度估一个班的提交散布在全表,实际上一个班的提交扎堆在它 * 上课的那一两年,早就毕业的班要倒扫大半张表。快照实测第一页:ks248 66ms → 0.75ms、 * ks225 142ms → 2.7ms、ks212 翻到 1000 条 79ms → 3.6ms。圈的代价和匹配行数成正比, * 最宽的 `ks2`(9.5 万行)要 65ms,和同一请求里 count 扫表的量级一样,不另外拖慢响应。 * 游标那条路帮不了它:第一步游标定位本身就是同一个倒扫。 */ async function paginateSubmissionRows( where: SQL | undefined, limit: number, offset: number, byUsername: boolean, ) { const order = [desc(schema.submission.createTime), desc(schema.submission.id)] as const const page = (condition: SQL | undefined) => db .select(submissionListColumns) .from(schema.submission) .innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) // 取当前用户名用。left join 不是 inner —— 已删号的学生这边没有行, // inner join 会把他们的提交整条从列表里抹掉 .leftJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .where(condition) .orderBy(...order) .limit(limit) if (byUsername) { const matched = db .select({ id: schema.submission.id, createTime: schema.submission.createTime, }) .from(schema.submission) .where(where) return page(sql`${schema.submission.id} in ( with matched as materialized ${matched} select id from matched order by create_time desc, id desc limit ${limit} offset ${offset} )`) } if (offset === 0) return page(where) const [boundary] = await db .select({ createTime: schema.submission.createTime, id: schema.submission.id, }) .from(schema.submission) .where(where) .orderBy(...order) .limit(1) .offset(offset) // offset 越过了结果集尾巴,这一页本来就该是空的 if (!boundary) return [] return page( and( where, sql`(${schema.submission.createTime}, ${schema.submission.id}) <= (${boundary.createTime}::timestamptz, ${boundary.id}::text)`, ), ) } /** * 这一页里出现过的来源题单,id → 标题。传进来的数组允许带 null 和重复值。 * 一页最多 250 行、实际能落到的题单数是个位数,按主键 IN 查一次就完了。 */ async function problemsetTitleMap(ids: Array) { const unique = [...new Set(ids.filter((id): id is number => id !== null))] if (unique.length === 0) return new Map() const rows = await db .select({ id: schema.problemset.id, title: schema.problemset.title }) .from(schema.problemset) .where(inArray(schema.problemset.id, unique)) return new Map(rows.map((row) => [row.id, row.title])) } submissionRoutes.get("/submissions", optionalAuth, async (c) => { const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const user = c.get("user") // 「非管理员即受限」,不能写成「是普通用户才受限」—— // 后者对匿名用户(user 为 null)会短路,匿名反而能看到全部提交,权限大于登录学生。 if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) { return success(c, { results: [], total: 0, unknownProblems: [] } satisfies SubmissionList) } const displayId = c.req.query("problemDisplayId")?.trim() const myself = c.req.query("myself") === "1" ? user : null // 「只看自己」盖过用户名 const username = myself ? undefined : c.req.query("username")?.trim() const result = c.req.query("result") const language = c.req.query("language")?.trim() // 班级(user.class_name),和用户名可以同时用:「25计算机3班里名字带『王』的」 const className = myself ? undefined : c.req.query("className")?.trim() const filters: Array = [isNull(schema.submission.contestId)] filters.push( ...(await Promise.all([ displayId ? problemFilter(displayId, null) : undefined, className ? classNameFilter(className) : undefined, username ? c.req.query("exactUsername") === "1" ? exactUsernameFilter(username) : usernameFilter(username) : undefined, ])), ) if (myself) filters.push(eq(schema.submission.userId, myself.id)) 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} >= ${dayStart()}`) const where = and(...filters) // count 不 join problem:无条件 join 会让计划器把 count 退化成 seq scan // (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。 const [totalRows, rows, unknownProblems] = await Promise.all([ db.select({ value: count() }).from(schema.submission).where(where), // 班级和用户名一样是「圈一小撮人」,走同一条圈选路(见 paginateSubmissionRows) paginateSubmissionRows(where, limit, offset, Boolean(username || className)), displayId ? unknownDisplayIds(displayId, null) : [], ]) // 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询 const [cutoffs, problemsetTitles] = await Promise.all([ user && !isTeacherOrAbove(user) ? problemSetLockCutoffs(user.id, [ ...new Set( rows .filter((row) => row.submission.userId === user.id) .map((row) => row.submission.problemId), ), ]) : undefined, // 来源题单的标题。一页里不同题单最多几个,按主键查一次就够 problemsetTitleMap(rows.map((row) => row.submission.problemsetId)), ]) return success(c, { results: rows.map( ({ submission, problem }) => ({ id: submission.id, problemDisplayId: problem.displayId, problemTitle: problem.title, showLink: user ? canViewSubmission(user, submission, problem, cutoffs) : false, createTime: submission.createTime, userId: submission.userId, username: submission.username, className: submission.className || null, result: submission.result, language: submission.language, statisticInfo: asRecord(submission.statisticInfo), // 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了 problemSet: submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId) ? { id: submission.problemsetId, title: problemsetTitles.get(submission.problemsetId)!, } : null, caseSummary: listCaseSummary(submission), }) satisfies SubmissionListItem, ), total: totalRows[0]?.value ?? 0, unknownProblems, } satisfies SubmissionList) }) submissionRoutes.get( "/contests/:contestId/submissions", optionalAuth, requireContestAccess("submissions", "contestId"), async (c) => { const contest = c.get("contest")! const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 }) const offset = queryInteger(c.req.query("offset"), 0, { min: 0 }) const user = c.get("user") const displayId = c.req.query("problemDisplayId")?.trim() const myself = c.req.query("myself") === "1" ? user : null const username = myself ? undefined : c.req.query("username")?.trim() const result = c.req.query("result") const filters: Array = [eq(schema.submission.contestId, contest.id)] filters.push( ...(await Promise.all([ displayId ? problemFilter(displayId, contest.id) : undefined, username ? usernameFilter(username) : undefined, ])), ) if (myself) filters.push(eq(schema.submission.userId, myself.id)) 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) // 一场比赛最多一两千条提交,按 contest_create_time_idx 定位之后怎么滤都不贵, // 所以不像公开列表那样分游标 / 圈选两条路 const [totalRows, rows, unknownProblems] = await Promise.all([ db.select({ value: count() }).from(schema.submission).where(where), db .select(submissionListColumns) .from(schema.submission) .innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)) .leftJoin(schema.user, eq(schema.user.id, schema.submission.userId)) .where(where) .orderBy(desc(schema.submission.createTime)) .limit(limit) .offset(offset), displayId ? unknownDisplayIds(displayId, contest.id) : [], ]) // 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了 // `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交, // 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。 // 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。 return success(c, { results: rows.map( ({ submission, problem }) => ({ id: submission.id, problemDisplayId: problem.displayId, problemTitle: problem.title, showLink: user ? canViewSubmission(user, submission, problem) : false, createTime: submission.createTime, userId: submission.userId, username: submission.username, className: submission.className || null, result: submission.result, language: submission.language, statisticInfo: asRecord(submission.statisticInfo), // 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了 // isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段 problemSet: null, // 比赛只报对错,不给通过几个点(口径见契约 caseSummarySchema) caseSummary: null, }) satisfies SubmissionListItem, ), total: totalRows[0]?.value ?? 0, unknownProblems, } satisfies SubmissionList) }, ) submissionRoutes.get("/submissions/:id", requireAuth, async (c) => { const user = c.get("user")! const detail = await submissionDetail(c.req.param("id"), user, c.req.query("attempts") === "1") if (detail.status === "not-found") { return failure(c, 404, "submission-not-found", "Submission does not exist") } // 原来看不到也回 404,独立页面分不清「链接不对」和「别人的」,只能一直转圈 if (detail.status === "forbidden") { return failure(c, 403, "submission-forbidden", "No permission to view this submission") } return success(c, detail.data) })