Deploy / deploy (push) Canceled after 0s
借小丑牌结算时一张张牌亮起来:结果里每组测试点一个方块,从左往右依次亮, 对的绿、错的红并写组号,不到 1 秒亮完,提示和按钮亮完才出来,全对时亮完再放烟花。 大字和「通过几个」判完马上就有;只播刚交的那条,刷新、看旧提交、系统减少动画都直接全亮。 提交详情多给每组对没对(只有对错,不带数据),比赛照旧不给。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
758 lines
34 KiB
TypeScript
758 lines
34 KiB
TypeScript
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, mergesLines } 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,
|
||
unknownDisplayIds,
|
||
usernameFilter,
|
||
} from "../services/submission-filters"
|
||
import { submissionStatisticsRoutes } 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
|
||
const { code: source, language, problemId } = parsed.data
|
||
try {
|
||
const code = await formatCode(source, language)
|
||
// C / C++ 编不过就原样返回,不格式化(理由见 judge/run.ts 的 compileFails)。
|
||
// 代码照常提交、照常判成编译错误,只是不改动学生的代码。
|
||
// 只有 clang-format 真把两行并成一行时才去判题机编:判题机和判题抢同一台机器的 CPU,
|
||
// 全班一起交 C++ 时每人都要排满 8 秒超时;而编得过的代码九成以上根本不并行
|
||
if ((language === "c" || language === "cpp") && mergesLines(source, code)) {
|
||
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)
|
||
}
|
||
}
|
||
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
|
||
// 判题时已经按组号排过(judge/run.ts),这里再排一次:老数据不一定是排好的
|
||
const results = data
|
||
.map((item) => asRecord(item))
|
||
.sort((a, b) => Number(a.test_case) - Number(b.test_case))
|
||
.map((item) => item.result === JudgeStatus.ACCEPTED)
|
||
return { passed: results.filter(Boolean).length, total: results.length, results }
|
||
}
|
||
|
||
/** 列表行的 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)
|
||
})
|