Files
OJ2/apps/api/src/routes/submission.ts
yuetsh 2c4d56b29a refactor(后端): 清掉 Django 遗留的死列与手工级联,角色字符串收成一份
三条迁移,一次部署(0008/0009 含 DROP COLUMN,需要 OJ2_ALLOW_DESTRUCTIVE=1):

- 0008 删 IP 相关:比赛 IP 白名单(前端本来就没有输入框,detail.vue 无条件置空)、
  submission.ip(前端从未显示过)、以及一次都没被调用过的 IP 限流桶。
  judge_server.ip 是运维数据,保留。
- 0009 删九个只有 Django 时代写过、OJ2 一次都没读过的列:user 的 auth_token /
  open_api / open_api_appkey / session_keys,user_profile 的 blog / github /
  school / major / language。open_api 后台连开关都没有,那段「已经开着就不重置
  appkey」的逻辑从上线起没进过 if。判据是「全仓零读取」而不是「看着没用」——
  raw_password 同样刺眼却是在用的,别一起清掉。
- 0010 给 17 条外键补上删除动作,不再是 Django 留下的一律 NO ACTION。父行消失后
  必然无意义、且不构成学生留痕的走 CASCADE(中间表、题单/教程/成就的组成部分、
  user_profile 与 user_stat);需要人看见的继续拦着——submission.problem_id、
  以及 user 的绝大多数外键,删用户撞外键会被 handler 翻译成「请改为禁用账号」,
  这是有意的:全 CASCADE 会静默抹掉成就与进度,而 submission.user_id 压根没有
  外键,结果是一半删一半留。六处手工级联随之删掉。

角色字符串收进 packages/contract/src/roles.ts:原先 ADMIN_ROLES / TEACHER_ROLES
在两个文件各抄一份、学生口径在四个文件各写一遍、前端 USER_TYPE 是第三份副本。
AuthUser.adminType 与 drizzle 的列都收窄成联合类型,二十多处 `=== "Super Admin"`
从此受编译器管着($type 是纯 TS 层的,generate 确认不产生任何 SQL 变更)。

顺带删掉 db/relations.ts —— drizzle-kit pull 的产物,全仓零引用。

一处行为变化:后台用户列表传非法的 ?type= 回 400,不再静默返回空列表;界面上的
下拉只有合法值,打不到这条。

验证:tsc / vue-tsc / vite build / check:routes 全过;三条迁移在 dev 库执行,
并逐条建 fixture 走 HTTP 接口验过删除连坐与拦截(题单五张子表连坐、user_badge
二级连坐、删有提交的题目仍 409、删有表情的用户仍 409)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AeJoYc2t2d7cThVqMBYrBF
2026-09-02 22:57:45 -06:00

731 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,
createSubmissionResponseSchema,
formatCodeRequestSchema,
formatCodeResponseSchema,
shareSubmissionRequestSchema,
submissionDetailSchema,
submissionListItemSchema,
submissionListSchema,
submissionStatisticsSchema,
} from "@oj2/contract"
import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
import { Hono } from "hono"
import {
optionalAuth,
requireAuth,
requireSuperAdmin,
requireTeacher,
} from "../auth/middleware"
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { judgeQueue } from "../queue"
import {
canAccessContest,
contestStatus,
findVisibleContest,
isContestAdmin,
requireContestAccess,
type ContestEnv,
} from "../services/contest"
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"
export const submissionRoutes = new Hono<ContestEnv>()
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: []
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
submissionRoutes.post("/submissions", requireAuth, async (c) => {
const parsed = createSubmissionRequestSchema.safeParse(
await c.req.json().catch(() => null),
)
if (!parsed.success) {
return failure(c, 400, "invalid-request", "Invalid submission payload")
}
let contestId: number | null = null
if (parsed.data.contestId) {
// 这里用不了 requireContestAccess 中间件:比赛 id 来自请求体,
// 中间件跑的时候 body 还没解析。全仓只有这一处仍是手工调用,改动时留意别漏掉鉴权。
const contest = await findVisibleContest(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 (!stringArray(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,
shared: false,
statisticInfo: {},
contestId,
})
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,
createSubmissionResponseSchema.parse({ submissionId }),
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} >= ${todayStart()}`)
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} >= ${todayStart()}`))
return success(c, row?.value ?? 0)
})
const ACCEPTED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/**
* 统计接口共用的时间窗解析。旧后端 `end` 必填、`start` 可选(不给就是「全部时段」)。
*/
function statisticsRange(c: { req: { query(name: string): string | undefined } }) {
const end = c.req.query("end")?.trim()
if (!end) return null
const start = c.req.query("start")?.trim()
return { start: start || null, end }
}
/**
* 按题号(展示用的 _id定位公开题目。找不到时统计接口要报错而不是退化成「全部题目」
* 否则教师打错一个字就会看到全站数据还以为是本题的。
*/
async function findPublicProblemByDisplayId(displayId: string) {
const [row] = await db
.select({ id: schema.problem.id })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
.limit(1)
return row ?? null
}
/**
* 用户名模糊匹配到的在册学生,用来算「班级人数」和「谁没做」。
* 只算未禁用的普通用户 —— 教师和管理员不该出现在完成度分母里。
*/
async function matchedStudents(username: string) {
return db
.select({ username: schema.user.username, className: schema.user.className })
.from(schema.user)
.where(
and(
ilike(schema.user.username, `%${username}%`),
eq(schema.user.isDisabled, false),
eq(schema.user.adminType, "Regular User"),
),
)
}
submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
const range = statisticsRange(c)
if (!range) return failure(c, 400, "invalid-request", "end is required")
const filters = [
isNull(schema.submission.contestId),
sql`${schema.submission.createTime} <= ${range.end}`,
]
if (range.start) filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
const displayId = c.req.query("problemId")?.trim()
if (displayId) {
const problem = await findPublicProblemByDisplayId(displayId)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
filters.push(eq(schema.submission.problemId, problem.id))
}
const username = c.req.query("username")?.trim()
if (username) filters.push(ilike(schema.submission.username, `%${username}%`))
const where = and(...filters)
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
const [[totals], perUser, rosterRows, items] = await Promise.all([
db
.select({ total: count(), accepted: acceptedFilter.mapWith(Number) })
.from(schema.submission)
.where(where),
db
.select({
username: schema.submission.username,
submissionCount: count(),
acceptedCount: acceptedFilter.mapWith(Number),
})
.from(schema.submission)
.where(where)
.groupBy(schema.submission.username)
.orderBy(desc(count())),
// 只有指定了用户名才有「班级人数」这个概念;不指定时分母无意义,旧后端也返回 0
username ? matchedStudents(username) : Promise.resolve([]),
db
.select({
username: schema.submission.username,
id: schema.submission.id,
result: schema.submission.result,
})
.from(schema.submission)
.where(where)
.orderBy(desc(schema.submission.createTime)),
])
const submissionCount = totals?.total ?? 0
const acceptedCount = totals?.accepted ?? 0
const itemsByUser = new Map<string, { id: string; result: number }[]>()
for (const item of items) {
const bucket = itemsByUser.get(item.username)
if (bucket) bucket.push({ id: item.id, result: item.result })
else itemsByUser.set(item.username, [{ id: item.id, result: item.result }])
}
const submittedUsernames = new Set(perUser.map((row) => row.username))
const classNames = new Map<string, string | null>()
if (submittedUsernames.size) {
const rows = await db
.select({ username: schema.user.username, className: schema.user.className })
.from(schema.user)
.where(inArray(schema.user.username, [...submittedUsernames]))
for (const row of rows) classNames.set(row.username, row.className)
}
// 只列出有正确提交的人。做了但一次没对的学生落在「未完成」那一栏
const data = perUser
.filter((row) => row.acceptedCount > 0)
.map((row) => ({
username: row.username,
className: classNames.get(row.username) ?? null,
submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount,
correctRate: rounded((row.acceptedCount / row.submissionCount) * 100),
submissionItems: itemsByUser.get(row.username) ?? [],
}))
const dataUnaccepted = rosterRows
.filter((row) => !submittedUsernames.has(row.username))
.map((row) => ({
username: row.username,
realName: stripClassPrefix(row.username, row.className),
}))
// 顺序照搬旧后端:先用原始 person_count 算完成度,再修正 person_count。
// 修正是为了兜住「学生已删号但提交记录还在」——那时完成人数会大于花名册人数。
let personCount = rosterRows.length
let personRate = 0
if (personCount) {
personRate = Math.min(100, rounded((data.length / personCount) * 100))
if (personCount < data.length) personCount = data.length
}
return success(
c,
submissionStatisticsSchema.parse({
submissionCount,
acceptedCount,
correctRate: submissionCount ? rounded((acceptedCount / submissionCount) * 100) : 0,
personCount,
personRate,
data,
dataUnaccepted,
}),
)
})
submissionRoutes.post("/submissions/:id/rejudge", requireSuperAdmin, async (c) => {
const [row] = await db
.select({ id: schema.submission.id, problemId: schema.submission.problemId })
.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")
await db
.update(schema.submission)
.set({ statisticInfo: {}, result: JudgeStatus.PENDING })
.where(eq(schema.submission.id, row.id))
// jobId 必须带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId 做 jobId 的话
// BullMQ 会认为这个任务已经存在,重判静默变成空操作。与 flowcharts/:id/retry 同一处理。
await judgeQueue.add(
"judge",
{ submissionId: row.id, problemId: row.problemId },
{ jobId: `${row.id}:rejudge:${Date.now()}` },
)
return success(c, null)
})
submissionRoutes.post("/code/format", requireAuth, async (c) => {
const parsed = formatCodeRequestSchema.safeParse(await c.req.json().catch(() => null))
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 }))
} catch (error) {
if (error instanceof CodeFormatError) {
return failure(c, error.kind === "syntax" ? 400 : 500, error.kind === "syntax" ? "format-error" : "format-tool-error", error.message)
}
throw error
}
})
/**
* 题单防作弊闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 加入时间。
*
* 对齐旧后端 `submission/serializers.py:12` 的 `bulk_fetch_problemset_progress`。学生加入含
* 某道题的题单后,他在加入之前留下的 AC 代码还摆在提交列表里,复制粘贴就能把题单刷完。
* 备份快照里 1734 人次、188 名学生进过这个窗口(占已解题次的 22.5%),不是边角情况。
*
* 解锁的三条路全写在 where 里,任一成立就查不出来、也就不遮挡:
* - 已经在题单里做出这道题progress_detail 里有这道题的 key
* - 题单过了截止时间end_time为空表示不设期限只能靠做出来解锁
* - 题单被归档status 不是 active
*
* 一道题可能同时落在多个已加入的题单里,取最晚的 join_time——「存在任一题单要求遮挡就遮挡」
* 等价于「提交时间早于最晚的那次加入」。旧后端这里用 `.first()` 取任意一条,一题多题单时
* 行为不确定,换成聚合顺手定死。
*/
async function problemSetJoinTimes(userId: number, problemIds: number[]) {
const joinTimes = new Map<number, string>()
if (problemIds.length === 0) return joinTimes
const rows = await db
.select({
problemId: schema.problemsetProblem.problemId,
// ::text 是为了拿回和 mode:"string" 列同样形状的字符串——聚合表达式不走列的类型映射,
// 不加这个 cast 驱动会把 timestamptz 解析成 Date下游的 Date.parse 就接不住了
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})::text`,
})
.from(schema.problemsetProgress)
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId))
.innerJoin(schema.problemsetProblem, eq(schema.problemsetProblem.problemsetId, schema.problemset.id))
.where(and(
eq(schema.problemsetProgress.userId, userId),
inArray(schema.problemsetProblem.problemId, problemIds),
eq(schema.problemset.status, "active"),
or(isNull(schema.problemset.endTime), gt(schema.problemset.endTime, sql`now()`)),
sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`,
))
.groupBy(schema.problemsetProblem.problemId)
for (const row of rows) joinTimes.set(row.problemId, row.joinTime)
return joinTimes
}
// 参数按「实际用到的字段」声明,而不是整行 $inferSelect列表接口只 select 需要的列,
// 传不进完整行。完整行在结构上满足这两个窄类型,详情接口照旧调用不受影响。
function canViewSubmission(
user: AuthUser | null,
row: { userId: number; shared: boolean; problemId: number; createTime: string },
problem: { createdById: number; shareSubmission: boolean },
contest: typeof schema.contest.$inferSelect | null,
allowShared = true,
problemSetJoinTime?: Map<number, string>,
) {
if (!user) return false
// 题单防作弊,见 problemSetJoinTimes。只对学生自己的提交生效管理员不受限对齐旧后端
// `get_show_link` 里的 `obj.user_id == self.user.id and self.user.is_regular_user()`。
//
// 只挡「看代码」这一路,不挡 allowShared=false 的那一路:后者是分享/取消分享的归属校验,
// 与作弊无关,挡了会让学生连自己旧提交的分享开关都动不了。
if (allowShared && row.userId === user.id && !isAdminRole(user)) {
const joinTime = problemSetJoinTime?.get(row.problemId)
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false
}
// 比赛没结束时,学生管理员不吃「管理员看得到所有人代码」这条捷径:他自己也在排行榜里
// contest.ts 的 rank 把 Student Admin 算作参赛者),既参赛又能读别人的提交就是开卷。
// 老师和超管不受影响 —— 他们不参赛。旧后端这里是 `not user.is_regular_user()`
// 学生管理员同样放行,所以这条是 OJ2 相对旧栈**收紧**的一处,不是修回归。
//
// 只掐角色捷径,不掐 `problem.createdById === user.id`:那是这道题的作者本人,
// 他早就知道答案了,挡他没有意义。
const elevated = isAdminRole(user)
&& !(contest && contestStatus(contest) !== "-1" && user.adminType === "Student Admin")
if (row.userId === user.id || elevated || problem.createdById === user.id) return true
if (!allowShared) return false
if (contest && contestStatus(contest) !== "-1") return false
return problem.shareSubmission || row.shared
}
/**
* 提交列表只取序列化用得到的列。取 `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,
username: schema.submission.username,
result: schema.submission.result,
language: schema.submission.language,
shared: schema.submission.shared,
statisticInfo: schema.submission.statisticInfo,
// 只取 id题单标题按页单独查一次见 /submissions——把 problemset 一起 join 进来
// 会动到下面那条调过的分页查询而每页最多两三个不同的题单PK 查一次更便宜
problemsetId: schema.submission.problemsetId,
},
problem: {
displayId: schema.problem.displayId,
title: schema.problem.title,
shareSubmission: schema.problem.shareSubmission,
createdById: schema.problem.createdById,
},
} as const
async function submissionDetail(id: string, user: AuthUser) {
const [row] = await db.select({ submission: schema.submission, problem: schema.problem, contest: schema.contest })
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.leftJoin(schema.contest, eq(schema.submission.contestId, schema.contest.id))
.where(eq(schema.submission.id, id)).limit(1)
if (!row) return null
// 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`views/oj.py:103
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
const joinTimes = isAdminRole(user) || row.submission.userId !== user.id
? undefined
: await problemSetJoinTimes(user.id, [row.submission.problemId])
if (!canViewSubmission(user, row.submission, row.problem, row.contest, true, joinTimes)) return null
// info含每个测试点的 test_case 编号与 output_md5只给管理员对齐旧后端
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
const full = isAdminRole(user)
return submissionDetailSchema.parse({
id: row.submission.id,
createTime: row.submission.createTime,
userId: row.submission.userId,
username: row.submission.username,
code: row.submission.code,
result: row.submission.result,
info: full ? row.submission.info : {},
language: row.submission.language,
shared: row.submission.shared,
statisticInfo: objectValue(row.submission.statisticInfo),
// contest 也在旧后端的排除名单里,同样只给管理员
contestId: full ? row.submission.contestId : null,
problemId: row.submission.problemId,
// problem 表本来就 join 了,不额外查库
problemDisplayId: row.problem.displayId,
showLink: true,
canUnshare: canViewSubmission(user, row.submission, row.problem, row.contest, false),
})
}
/**
* 提交列表取数据。深翻页不走 `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 ScanHeap Fetches 为 0纯在索引页里数数
* 2. 拿这一行当游标做 keyset 回查,只回表取 limit 行。
* 同一页实测降到约 9ms、885 个 buffer。代价变成 O(m) 个**索引条目**而不是堆页,按快照里
* 的索引密度外推,涨到 100 万条时最深一页仍在几十毫秒量级。
*
* 排序必须带 idcreate_time 只有毫秒精度(`new Date().toISOString()`),光靠它不是全序,
* 游标用 `<=` 回查时同毫秒的上一页末行会重复出现在下一页页首。索引已按 (create_time DESC,
* id DESC) 建好,带上 id 不会多出 Sort 节点。
*
* 两种情况退回普通 offsetoffset 为 0 时没有可跳过的行,白搭一次往返;按题号筛选时条件
* 在 problem 表上,第一步得跟着 join、index-only 就没了——而那时结果集只剩几百条,
* offset 本来也不慢。
*/
async function paginateSubmissionRows(
where: SQL | undefined,
limit: number,
offset: number,
filtersNeedProblem: boolean,
) {
const order = [desc(schema.submission.createTime), desc(schema.submission.id)] as const
const page = (cursor?: SQL) =>
db
.select(submissionListColumns)
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.where(cursor ? and(where, cursor) : where)
.orderBy(...order)
if (offset === 0 || filtersNeedProblem) return page().limit(limit).offset(offset)
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(
sql`(${schema.submission.createTime}, ${schema.submission.id}) <= (${boundary.createTime}::timestamptz, ${boundary.id}::text)`,
).limit(limit)
}
/**
* 这一页里出现过的来源题单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, submissionListSchema.parse({ results: [], total: 0 }))
}
const filters = [isNull(schema.submission.contestId)]
const displayId = c.req.query("problemId")?.trim()
const username = c.req.query("username")?.trim()
const result = c.req.query("result")
const language = c.req.query("language")?.trim()
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(ilike(schema.submission.username, `%${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 (c.req.query("today") === "1") filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`)
const where = and(...filters)
// count 不 join problemproblem 只有按题号筛选时才出现在 where 里,无条件 join 会让
// 计划器把 count 退化成 seq scan生产快照实测 7.5ms → 78ms
const totalQuery = displayId
? db.select({ value: count() }).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
: db.select({ value: count() }).from(schema.submission).where(where)
const [totalRows, rows] = await Promise.all([
totalQuery,
paginateSubmissionRows(where, limit, offset, Boolean(displayId)),
])
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
const [joinTimes, problemsetTitles] = await Promise.all([
user && !isAdminRole(user)
? problemSetJoinTimes(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, submissionListSchema.parse({
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({
id: submission.id,
problem: problem.displayId,
problemTitle: problem.title,
showLink: user ? canViewSubmission(user, submission, problem, null, true, joinTimes) : false,
createTime: submission.createTime,
userId: submission.userId,
username: submission.username,
result: submission.result,
language: submission.language,
shared: submission.shared,
statisticInfo: objectValue(submission.statisticInfo),
// 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了
problemSet: submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId)
? { id: submission.problemsetId, title: problemsetTitles.get(submission.problemsetId)! }
: null,
})),
total: totalRows[0]?.value ?? 0,
}))
})
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 filters = [eq(schema.submission.contestId, contest.id)]
const user = c.get("user")
const displayId = c.req.query("problemId")?.trim()
const username = c.req.query("username")?.trim()
const result = c.req.query("result")
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(ilike(schema.submission.username, `%${username}%`))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, Number(result)))
if (contestStatus(contest) !== "1") filters.push(sql`${schema.submission.createTime} >= ${contest.startTime}`)
const where = and(...filters)
// count 不 join problemproblem 只有按题号筛选时才出现在 where 里,无条件 join 会让
// 计划器把 count 退化成 seq scan生产快照实测 7.5ms → 78ms
const totalQuery = displayId
? db.select({ value: count() }).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
: db.select({ value: count() }).from(schema.submission).where(where)
const [totalRows, rows] = await Promise.all([
totalQuery,
db.select(submissionListColumns).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
.orderBy(desc(schema.submission.createTime)).limit(limit).offset(offset),
])
// 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了
// `isNull(problem.contestId)`admin/problemset.ts:232——而这条列表只出比赛提交
// 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。
// 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch那边同样是死代码。
return success(c, submissionListSchema.parse({
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({
id: submission.id,
problem: problem.displayId,
problemTitle: problem.title,
showLink: user ? canViewSubmission(user, submission, problem, contest) : false,
createTime: submission.createTime,
userId: submission.userId,
username: submission.username,
result: submission.result,
language: submission.language,
shared: submission.shared,
statisticInfo: objectValue(submission.statisticInfo),
// 比赛提交没有来源题单题单只收非比赛题admin/problemset.ts 加题时卡了
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
problemSet: null,
})),
total: totalRows[0]?.value ?? 0,
}))
})
submissionRoutes.get("/submissions/:id", requireAuth, async (c) => {
const user = c.get("user")!
const data = await submissionDetail(c.req.param("id"), user)
if (!data) {
return failure(c, 404, "submission-not-found", "Submission does not exist")
}
return success(c, data)
})
submissionRoutes.put("/submissions/:id", requireAuth, async (c) => {
const parsed = shareSubmissionRequestSchema.safeParse(await c.req.json().catch(() => null))
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid share payload")
const [row] = await db.select({ submission: schema.submission, problem: schema.problem, contest: schema.contest })
.from(schema.submission).innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.leftJoin(schema.contest, eq(schema.submission.contestId, schema.contest.id))
.where(eq(schema.submission.id, c.req.param("id"))).limit(1)
if (!row || !canViewSubmission(c.get("user")!, row.submission, row.problem, row.contest, false)) {
return failure(c, 404, "submission-not-found", "Submission does not exist")
}
if (row.contest && contestStatus(row.contest) === "0") {
return failure(c, 403, "contest-underway", "Can not share submission now")
}
await db.update(schema.submission).set({ shared: parsed.data.shared }).where(eq(schema.submission.id, row.submission.id))
return success(c, null)
})