feat(题单): 「截止时间」改成「布置期」,布置期内没加入的人也藏旧代码;老师能看全班情况,后台一次加好几道题

- 布置期(列名仍是 end_time,机房共库不改名)+ 新列 assigned_at:布置期内题单里这几道题,
  学生在布置开始之前交的代码先藏起来,不管加没加入,堵住「先抄再加入」;做对或到期放出来,不拦做题
- 「总分达到 N」奖章换成「做对 N 道」,草稿并进「不公开」(0026)
- /problem-sets 带布置期、必做题数、奖章获得时间;题目带做对时刻、错几次、旧代码藏着没有
- 新增 /problem-sets/:id/class-view(老师看一个班:真名 × 每题,加入前就做对过)、/problem-sets/locks
- 后台:批量加题、拖动排序、切必做;去掉状态、难度、分数、提示和单独的进度页

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-09 22:01:39 +08:00
1 parent 2224478294
commit 8c53e28dcb
11 files changed
+4958 -617

No files matched your search

+69 -34
View File
@@ -1,4 +1,4 @@
import { and, eq, gt, inArray, isNull, notInArray, or, sql } from "drizzle-orm"
import { and, asc, eq, inArray, notInArray, sql } from "drizzle-orm"
import { db, schema } from "../db"
import { publishAchievementNotification } from "../events"
@@ -10,7 +10,7 @@ type ProgressRow = typeof schema.problemsetProgress.$inferSelect
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
type BadgeCheck = Pick<
ProgressRow,
"completedProblemsCount" | "totalProblemsCount" | "totalScore" | "progressDetail"
"completedProblemsCount" | "totalProblemsCount" | "progressDetail"
>
/**
@@ -132,7 +132,7 @@ export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
if (badge.conditionType === "problem_count") {
return Object.keys(asRecord(progress.progressDetail)).length >= badge.conditionValue
}
if (badge.conditionType === "score") return progress.totalScore >= badge.conditionValue
// 「总分达到 N」随分数一起拿掉了,0026 把它们换成了「做对 N 道」
return false
}
@@ -242,8 +242,8 @@ export async function recordSolvedProblem(
problemId: number,
/**
* 做出来的那次代码提交。流程图画到 A/S 算完成时传 null:problemset_submission.submission_id
* 外键指向代码提交表,流程图提交的 id 进不去。不影响「解锁加入题单之前的提交」——
* 那道闸看的是 progress_detail(本文件下面的 problemSetJoinTimes)
* 外键指向代码提交表,流程图提交的 id 进不去。不影响防抄闸门的解锁 ——
* 那道闸看的是 progress_detail(本文件下面的 problemSetLockCutoffs)
*/
submissionId: string | null,
solvedAt: string,
@@ -390,50 +390,85 @@ export async function recordSolvedAndNotify(
}
}
/** 题单现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */
export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not null and ${schema.problemset.assignedUntil} > now())`
/**
* 题单防作弊闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 加入时间。
* 代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)共用这一道闸。
* 题单防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻
* (早于它的提交藏起来)。代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)
* 共用这一道闸。
*
* 对齐旧后端 `submission/serializers.py:12` 的 `bulk_fetch_problemset_progress`。学生加入含
* 某道题的题单后,他在加入之前留下的 AC 代码还摆在提交列表里,复制粘贴就能把题单刷完。
* 备份快照里 1734 人次、188 名学生进过这个窗口(占已解题次的 22.5%),不是边角情况。
* 规则(2026-10 题单重设计,用户拍板):题单**布置期内**,题单里这几道题,学生在**布置开始之前**
* 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习题单:快照里 16% 的
* 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。
*
* 解锁的三条路全写在 where 里,任一成立就查不出来、也就不遮挡:
* - 已经在题单里做出这道题(progress_detail 里有这道题的 key)
* - 题单过了截止时间(end_time;为空表示不设期限,只能靠做出来解锁)
* - 题单被归档(status 不是 active)
* 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了;
* 现在分界是布置开始,没加入的人也藏,这个口子就没了。代价是别的班以前做过这几道题的人,
* 在这一两周里也看不到自己的旧代码,用户认了。
*
* 一道题可能同时落在多个已加入的题单里,取最晚的 join_time——「存在任一题单要求遮挡就遮挡」
* 等价于「提交时间早于最晚的那次加入」。旧后端这里用 `.first()` 取任意一条,一题多题单时
* 行为不确定,换成聚合顺手定死。
* 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡:
* - 题单不在布置期(没布置,或者布置期过了)
* - 题单没公开
* - 已经在这份题单里做对了这道题(progress_detail 里有这道题的 key)
*
* 一道题可能同时落在几个正在布置的题单里,取最晚的布置开始 ——「任一题单要求遮挡就遮挡」。
*/
export async function problemSetJoinTimes(userId: number, problemIds: number[]) {
const joinTimes = new Map<number, string>()
if (problemIds.length === 0) return joinTimes
export async function problemSetLockCutoffs(userId: number, problemIds: number[]) {
const cutoffs = new Map<number, string>()
if (problemIds.length === 0) return cutoffs
const rows = await db
.select({
problemId: schema.problemsetProblem.problemId,
// 聚合表达式不走列的类型映射,但 OID 还是 1184 —— db/index.ts 给这个 OID 挂了
// 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状。
// 原来那个 `::text` 要撤掉:它的 OID 是 25、绕过那个 parser,反而会变成 PG 文本。
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})`,
// 「转成 ISO 8601」的 parser,所以这里拿到的和 `mode:"string"` 的列同形状
cutoff: sql<string>`max(${schema.problemset.assignedAt})`,
})
.from(schema.problemsetProgress)
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId))
.innerJoin(
schema.problemsetProblem,
eq(schema.problemsetProblem.problemsetId, schema.problemset.id),
.from(schema.problemsetProblem)
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProblem.problemsetId))
.leftJoin(
schema.problemsetProgress,
and(
eq(schema.problemsetProgress.problemsetId, schema.problemset.id),
eq(schema.problemsetProgress.userId, userId),
),
)
.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)`,
eq(schema.problemset.visible, true),
assigningSql,
sql`not coalesce(jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text), false)`,
),
)
.groupBy(schema.problemsetProblem.problemId)
for (const row of rows) joinTimes.set(row.problemId, row.joinTime)
return joinTimes
for (const row of rows) cutoffs.set(row.problemId, row.cutoff)
return cutoffs
}
/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的题单里 */
export async function problemSetLocksFor(userId: number, problemId: number) {
return db
.select({
problemSetId: schema.problemset.id,
title: schema.problemset.title,
assignedUntil: sql<string>`${schema.problemset.assignedUntil}`,
})
.from(schema.problemsetProblem)
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProblem.problemsetId))
.leftJoin(
schema.problemsetProgress,
and(
eq(schema.problemsetProgress.problemsetId, schema.problemset.id),
eq(schema.problemsetProgress.userId, userId),
),
)
.where(
and(
eq(schema.problemsetProblem.problemId, problemId),
eq(schema.problemset.visible, true),
assigningSql,
sql`not coalesce(jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text), false)`,
),
)
.orderBy(asc(schema.problemset.assignedUntil))
}