Files
OJ2/apps/api/src/routes/submission.ts
T
xuyueandClaude Opus 5.5 df76e7f101
Deploy / deploy (push) Canceled after 0s
feat(提交详情): 单独打开的一条提交改成左边代码、右边为什么错 + 这道题交的每一次
- 原来不显示是哪道题、时间只到日、答案错误不说为什么,测试点表 5 行一模一样
- 右边摆题目页那块例子对比(输入 / 正确输出 / 他的输出,差的字标红),编译失败、运行时错误同提交列表右栏的说法,代码里标出那一行
- 「这道题交了 N 次」常驻右栏,← → 一次次翻,看他是怎么改对的;题单藏着的那几次点不进
- 老师:「他的全部提交」和「⋯」(复制两项、重新判题);测试点方块悬停看用时和内存;判题中自己刷新
- 别人的代码、链接不对原来一直转圈,现在说清楚是哪种;自己在题单布置期内的照样看得到是哪道题、什么结果
- 后端详情多给题目名、班级、题单,`?attempts=1` 带上同题的每一次;看不到别人的回 403 而不是 404;提交者本人也拿得到 contestId(比赛里的题号是比赛自己的)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:00:22 +08:00

752 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ContestEnv>()
/**
* 落编辑过程信号。**失败只记日志、不影响提交** —— 这是附带的统计数据,
* 提交已经进库了,不能因为它回一个 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<number, string>,
) {
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<string>`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<number | null>) {
const unique = [...new Set(ids.filter((id): id is number => id !== null))]
if (unique.length === 0) return new Map<number, string>()
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<SQL | undefined> = [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<SQL | undefined> = [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)
})