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:
1 parent
2224478294
commit
8c53e28dcb
11 files changed
+4958
-617
No files matched your search
@@ -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))
|
||||
}
|
||||
Reference in new issue
Block a user