fix(题单): 补回加入题单前旧提交的遮挡

旧栈的 SubmissionListSerializer.get_show_link 有一条防作弊规则:学生加入含某道题的
题单后,他在加入之前留下的 AC 代码就摆在提交列表里,复制粘贴即可过关,所以要把这段
旧提交藏起来。重写时整条丢了 —— canViewSubmission 里 grep 不到一个 problemset,
自己的提交第一行就无条件 return true。

前端反而把 UI 留着了:ProblemSubmission.vue 那个锁图标的 tooltip 一直写着「这道题在
你已经加入的题单中,只有在题单中完成此题,代码才可见」,而后端永远不会给出
showLink: false,图标一次都没亮过 —— 提示语本身成了操作指引。

题单的 end_time 也因此成了孤儿字段:它不是「截止后不能提交」,是这道闸门的时间边界。

影响面不是边角:8-07 的快照里 1734 人次、188 名学生在加入题单之前就已经 AC 过题单
里的题,占全部已解题次的 22.5%。

补回来的同时改了三处旧栈的做法:

- 判断放进 canViewSubmission,列表和详情一起挡。旧栈只挡列表链接,
  SubmissionAPI.get 光走 check_user_permission,知道 submission id 直接访问照样
  拿得到代码,遮挡是虚的。
- 一题落在多个已加入题单里时取 max(join_time),「存在任一题单要求遮挡就遮挡」等价于
  「早于最晚的那次加入」。旧栈用 .first() 取任意一条,行为不确定。
- 比赛提交列表不挂闸门。题单里的题必定是非比赛题(加题时卡了 contestId IS NULL),
  而那条列表只出比赛提交,交集恒空,挂上去就是每页白跑一次查询 —— 而比赛进行中它是
  被刷得最狠的。旧栈 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。

闸门只挡「看代码」这一路,不挡 allowShared=false 那一路 —— 后者是分享开关的归属校验,
与作弊无关,挡了学生连自己旧提交的分享都动不了。管理员不受限,对齐旧栈的
is_regular_user() 前提。

解锁三条路实跑验证过:在题单里做出该题、题单过 end_time、题单归档。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
This commit is contained in:
2026-08-31 08:23:13 -06:00
parent 2031e6a434
commit ff31f7abd1

View File

@@ -11,7 +11,7 @@ import {
submissionListSchema,
submissionStatisticsSchema,
} from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray, isNull, sql, type SQL } from "drizzle-orm"
import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
import { Hono } from "hono"
import {
@@ -371,16 +371,67 @@ submissionRoutes.post("/code/format", requireAuth, async (c) => {
}
})
/**
* 题单防作弊闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 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 },
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
}
if (row.userId === user.id || isAdminRole(user) || problem.createdById === user.id) return true
if (!allowShared) return false
if (contest && contestStatus(contest) !== "-1") return false
@@ -398,6 +449,8 @@ const submissionListColumns = {
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,
@@ -418,7 +471,13 @@ async function submissionDetail(id: string, user: AuthUser) {
.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 || !canViewSubmission(user, row.submission, row.problem, row.contest)) return null
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与 ip 只给管理员,对齐旧后端:
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
// SubmissionSafeModelSerializer(exclude=("info", "contest", "ip")) 之间二选一,
@@ -531,12 +590,18 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
totalQuery,
paginateSubmissionRows(where, limit, offset, Boolean(displayId)),
])
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
const joinTimes = user && !isAdminRole(user)
? await problemSetJoinTimes(user.id, [...new Set(
rows.filter((row) => row.submission.userId === user.id).map((row) => row.submission.problemId),
)])
: undefined
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) : false,
showLink: user ? canViewSubmission(user, submission, problem, null, true, joinTimes) : false,
createTime: submission.createTime,
userId: submission.userId,
username: submission.username,
@@ -576,6 +641,10 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
.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,