只改界面文字和注释:网址 /problemset、接口、表名、字段名都不动,老链接照常能打开。 设计稿页名「题单重设计」和已执行的迁移文件保留原样。 线上 3 个成就(题单收藏家 / 入门 / 通关)已同步改名。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
959c326bc9
commit
1b703d13ae
68 files changed
+306
-306
No files matched your search
@@ -342,12 +342,12 @@ export const problemset = pgTable(
|
||||
createdById: integer('created_by_id').notNull(),
|
||||
/**
|
||||
* 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。
|
||||
* 布置期内,题单里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见
|
||||
* 布置期内,专题里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见
|
||||
* services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。
|
||||
* 两个都为空 = 没布置。
|
||||
*
|
||||
* 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、
|
||||
* 不一定同时升级,改列名会让没升级的那边一查题单就报错,所以只改代码里的名字
|
||||
* 不一定同时升级,改列名会让没升级的那边一查专题就报错,所以只改代码里的名字
|
||||
*/
|
||||
assignedUntil: timestamp('end_time', { withTimezone: true, mode: 'string' }),
|
||||
assignedAt: timestamp('assigned_at', { withTimezone: true, mode: 'string' }),
|
||||
@@ -613,9 +613,9 @@ export const submission = pgTable(
|
||||
shared: boolean().default(false).notNull(),
|
||||
statisticInfo: jsonb('statistic_info').default({}).notNull(),
|
||||
username: text().notNull(),
|
||||
// 来源题单:学生从题单入口(/problemset/:id/problem/:pid)提交时记下来,
|
||||
// 提交列表据此标出「这条来自题单」。**只是来源标记**,题单进度、奖章一概不看它,
|
||||
// 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有题单」记账。
|
||||
// 来源专题:学生从专题入口(/problemset/:id/problem/:pid)提交时记下来,
|
||||
// 提交列表据此标出「这条来自专题」。**只是来源标记**,专题进度、奖章一概不看它,
|
||||
// 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有专题」记账。
|
||||
// 老数据里只有迁移 0007 从(已废弃的)problemset_submission 回填的首次 AC 有值。
|
||||
problemsetId: bigint('problemset_id', { mode: 'number' }),
|
||||
},
|
||||
@@ -745,13 +745,13 @@ export const submission = pgTable(
|
||||
foreignColumns: [problem.id],
|
||||
name: 'submission_problem_id_76847b55_fk_problem_id',
|
||||
}),
|
||||
// 部分索引:绝大多数提交不来自题单,全列索引等于给 12 万行白建一遍。
|
||||
// 谓词是 IS NOT NULL,`problemset_id = $1` 蕴含非空,所以删题单时的外键检查
|
||||
// 部分索引:绝大多数提交不来自专题,全列索引等于给 12 万行白建一遍。
|
||||
// 谓词是 IS NOT NULL,`problemset_id = $1` 蕴含非空,所以删专题时的外键检查
|
||||
// 也能用上它——不然那条检查要顺序扫全表。
|
||||
index('submission_problemset_id_idx')
|
||||
.using('btree', table.problemsetId.asc().nullsLast())
|
||||
.where(sql`${table.problemsetId} is not null`),
|
||||
// 删掉题单不该带走提交:置空来源标记就行,提交本身照旧存在。
|
||||
// 删掉专题不该带走提交:置空来源标记就行,提交本身照旧存在。
|
||||
foreignKey({
|
||||
columns: [table.problemsetId],
|
||||
foreignColumns: [problemset.id],
|
||||
|
||||
@@ -268,9 +268,9 @@ export async function evaluateFlowchart(job: FlowchartJobData, { isFinalAttempt
|
||||
evaluationTime: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.flowchartSubmission.id, row.flowchart.id))
|
||||
// 画到 A / S 算这道题做完,记进所有已加入、且包含这道题的题单 —— 和判题那一路
|
||||
// 画到 A / S 算这道题做完,记进所有已加入、且包含这道题的专题 —— 和判题那一路
|
||||
// 同一个口径(不看是从哪个入口交的)。放在推「评完了」之前:前端收到的时候进度已经
|
||||
// 落库,1.5 秒后跳回题单页看到的就是新数据。比赛题不进题单
|
||||
// 落库,1.5 秒后跳回专题页看到的就是新数据。比赛题不进专题
|
||||
if (isFlowchartPass(result.grade) && row.problem.contestId === null) {
|
||||
await recordSolvedAndNotify(row.flowchart.userId, row.problem.id, new Date().toISOString())
|
||||
}
|
||||
|
||||
@@ -549,11 +549,11 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
)
|
||||
if (!saved) return
|
||||
|
||||
// 题单记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress,
|
||||
// 只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。
|
||||
// 专题记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress,
|
||||
// 只认路由参数里那一个专题:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。
|
||||
// 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时
|
||||
// 进度已经落库,跳回题单页看到的就是新数据。
|
||||
// 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。
|
||||
// 进度已经落库,跳回专题页看到的就是新数据。
|
||||
// 比赛题不进专题(专题加题时卡了 contestId IS NULL),跳过。
|
||||
if (row.submission.contestId === null && isAccepted(result)) {
|
||||
await recordSolvedAndNotify(row.submission.userId, row.problem.id, row.submission.createTime)
|
||||
}
|
||||
|
||||
@@ -525,7 +525,7 @@ adminAccountRoutes.delete('/users', requireSuperAdmin, async (c) => {
|
||||
// 旧后端靠 Django 的应用层级联硬删。这里不复刻那个行为,改为让数据库拦下来:
|
||||
// 撞外键说明该用户还有历史数据,应当禁用而不是删除。
|
||||
//
|
||||
// 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、题单进度、
|
||||
// 所以 0010 那一批 CASCADE **有意跳过了 user 的绝大多数外键**:成就、表情、专题进度、
|
||||
// AI 分析全都继续拦着。只有 user_profile 和 user_stat 走 CASCADE ——
|
||||
// 一个是一对一附属、一个是可重算的统计缓存,都不构成「这人做过什么」的证据。
|
||||
// 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个
|
||||
@@ -533,10 +533,10 @@ adminAccountRoutes.delete('/users', requireSuperAdmin, async (c) => {
|
||||
try {
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
/**
|
||||
* 外键拦得住成就、题单进度、比赛排名这些,**唯独提交拦不住** ——
|
||||
* 外键拦得住成就、专题进度、比赛排名这些,**唯独提交拦不住** ——
|
||||
* `submission.user_id` 没有外键(Django 那边就是个裸 IntegerField,上面已经
|
||||
* 说了为什么不补)。所以下面那句报错里写的「还有提交」一直是空头支票:
|
||||
* 只交过题、没拿过成就没进过题单的学生照样删得掉,提交留在库里成了孤儿 ——
|
||||
* 只交过题、没拿过成就没进过专题的学生照样删得掉,提交留在库里成了孤儿 ——
|
||||
* 用户没了、`submission.user_id` 还指着一个不存在的 id。生产快照实测:
|
||||
* 28 个已删账号留下 935 条这样的提交。
|
||||
*
|
||||
|
||||
@@ -28,7 +28,7 @@ export const adminProblemSetRoutes = new Hono<AppEnv>()
|
||||
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
|
||||
|
||||
/**
|
||||
* 取出题单并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 ——
|
||||
* 取出专题并校验归属。所有嵌套资源(题目/奖章/进度)都先过这一关 ——
|
||||
* 旧后端每个方法开头都手抄一遍这段 try/except,抄了 14 遍。
|
||||
*/
|
||||
async function loadOwned(c: { req: { param(name: string): string } }, user: AuthUser) {
|
||||
@@ -124,7 +124,7 @@ function assignment(requested: string | null, current: { assignedAt: string | nu
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 题单本体
|
||||
// ---------------------------------------------------------------- 专题本体
|
||||
|
||||
adminProblemSetRoutes.get('/problem-sets', requireTeacher, async (c) => {
|
||||
const limit = queryInteger(c.req.query('limit'), 10, { min: 1, max: 250 })
|
||||
@@ -132,8 +132,8 @@ adminProblemSetRoutes.get('/problem-sets', requireTeacher, async (c) => {
|
||||
const user = c.get('user')!
|
||||
const filters = []
|
||||
// 注意:这里**不过滤 visible**。旧后端的列表写死了 visible=True,可它同时又提供
|
||||
// 「切换可见性」的接口 —— 一旦把题单设成不可见,它就从后台列表里消失,
|
||||
// 再也没法在界面上改回来。后台必须能看见自己管的全部题单。
|
||||
// 「切换可见性」的接口 —— 一旦把专题设成不可见,它就从后台列表里消失,
|
||||
// 再也没法在界面上改回来。后台必须能看见自己管的全部专题。
|
||||
if (user.adminType !== 'Super Admin') filters.push(eq(schema.problemset.createdById, user.id))
|
||||
const keyword = c.req.query('keyword')?.trim()
|
||||
if (keyword) {
|
||||
@@ -174,13 +174,13 @@ adminProblemSetRoutes.post('/problem-sets', requireTeacher, async (c) => {
|
||||
|
||||
adminProblemSetRoutes.get('/problem-sets/:id', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
return success(c, await serialize(row))
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.put('/problem-sets/:id', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const parsed = await parseBody(c, updateProblemSetRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const period = assignment(parsed.data.assignedUntil, row)
|
||||
@@ -201,7 +201,7 @@ adminProblemSetRoutes.put('/problem-sets/:id', requireTeacher, async (c) => {
|
||||
|
||||
adminProblemSetRoutes.put('/problem-sets/:id/visibility', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
// 旧接口是「取反」语义,前端只传 id 不传目标值。保持不变:前端按钮就是个开关
|
||||
const [updated] = await db
|
||||
.update(schema.problemset)
|
||||
@@ -213,18 +213,18 @@ adminProblemSetRoutes.put('/problem-sets/:id/visibility', requireTeacher, async
|
||||
|
||||
adminProblemSetRoutes.delete('/problem-sets/:id', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
// 子表交给库级 CASCADE(0010):problemset_{badge,problem,progress,submission} 直接连坐,
|
||||
// user_badge 经 problemset_badge 二级连坐。原先这里手抄五条 delete 并要求「顺序不能反」。
|
||||
await db.delete(schema.problemset).where(eq(schema.problemset.id, row.id))
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 题单里的题目
|
||||
// ---------------------------------------------------------------- 专题里的题目
|
||||
|
||||
adminProblemSetRoutes.get('/problem-sets/:id/problems', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const rows = await db
|
||||
.select({ item: schema.problemsetProblem, problem: schema.problem })
|
||||
.from(schema.problemsetProblem)
|
||||
@@ -255,7 +255,7 @@ adminProblemSetRoutes.get('/problem-sets/:id/problems', requireTeacher, async (c
|
||||
*/
|
||||
adminProblemSetRoutes.post('/problem-sets/:id/problems', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const parsed = await parseBody(c, addProblemsToSetRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const wanted = [...new Set(parsed.data.problemIds.map((id) => id.toLowerCase()))]
|
||||
@@ -298,7 +298,7 @@ adminProblemSetRoutes.post('/problem-sets/:id/problems', requireTeacher, async (
|
||||
// 必须注册在 /problems/:itemId 前面:Hono 按注册顺序匹配,order 会被当成 :itemId 吃掉
|
||||
adminProblemSetRoutes.put('/problem-sets/:id/problems/order', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const parsed = await parseBody(c, reorderProblemSetProblemsRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const links = await db.select({ id: schema.problemsetProblem.id }).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, row.id))
|
||||
@@ -320,7 +320,7 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/order', requireTeacher, as
|
||||
|
||||
adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const parsed = await parseBody(c, updateProblemInSetRequestSchema, '参数错误')
|
||||
if (!parsed.success) return parsed.response
|
||||
const updated = await db
|
||||
@@ -328,7 +328,7 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher,
|
||||
.set(parsed.data)
|
||||
.where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id)))
|
||||
.returning({ id: schema.problemsetProblem.id })
|
||||
if (updated.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该题单中')
|
||||
if (updated.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该专题中')
|
||||
// 必做 / 选做变了,分母和「做完」跟着变
|
||||
await resyncProgress(row.id)
|
||||
return success(c, null)
|
||||
@@ -336,12 +336,12 @@ adminProblemSetRoutes.put('/problem-sets/:id/problems/:itemId', requireTeacher,
|
||||
|
||||
adminProblemSetRoutes.delete('/problem-sets/:id/problems/:itemId', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const deleted = await db
|
||||
.delete(schema.problemsetProblem)
|
||||
.where(and(eq(schema.problemsetProblem.id, queryInteger(c.req.param('itemId'), 0, { min: 1 })), eq(schema.problemsetProblem.problemsetId, row.id)))
|
||||
.returning({ id: schema.problemsetProblem.id })
|
||||
if (deleted.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该题单中')
|
||||
if (deleted.length === 0) return failure(c, 404, 'problem-not-in-set', '题目不在该专题中')
|
||||
await resyncProgress(row.id)
|
||||
return success(c, null)
|
||||
})
|
||||
@@ -383,14 +383,14 @@ async function badgesWithCount(badges: BadgeRow[]) {
|
||||
|
||||
adminProblemSetRoutes.get('/problem-sets/:id/badges', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const badges = await db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, row.id)).orderBy(asc(schema.problemsetBadge.id))
|
||||
return success(c, await badgesWithCount(badges))
|
||||
})
|
||||
|
||||
adminProblemSetRoutes.post('/problem-sets/:id/badges', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const parsed = await parseBody(c, createProblemSetBadgeRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const [created] = await db
|
||||
@@ -407,7 +407,7 @@ adminProblemSetRoutes.post('/problem-sets/:id/badges', requireTeacher, async (c)
|
||||
|
||||
adminProblemSetRoutes.put('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const parsed = await parseBody(c, updateProblemSetBadgeRequestSchema)
|
||||
if (!parsed.success) return parsed.response
|
||||
const [updated] = await db
|
||||
@@ -422,11 +422,11 @@ adminProblemSetRoutes.put('/problem-sets/:id/badges/:badgeId', requireTeacher, a
|
||||
|
||||
adminProblemSetRoutes.delete('/problem-sets/:id/badges/:badgeId', requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get('user')!)
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const badgeId = queryInteger(c.req.param('badgeId'), 0, { min: 1 })
|
||||
// 必须先确认这枚奖章确实属于本题单,再动 user_badge。
|
||||
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定题单,
|
||||
// 于是「自己的题单 id + 别人的奖章 id」会真删掉别人的获奖记录,
|
||||
// 必须先确认这枚奖章确实属于本专题,再动 user_badge。
|
||||
// 早先的写法把 userBadge 的清理放在归属校验之前、且只按 badgeId 不限定专题,
|
||||
// 于是「自己的专题 id + 别人的奖章 id」会真删掉别人的获奖记录,
|
||||
// 然后因为 problemset_badge 删了 0 行而返回 404 —— 事务已经 COMMIT,数据没了却报「不存在」。
|
||||
const [badge] = await db
|
||||
.select({ id: schema.problemsetBadge.id })
|
||||
|
||||
@@ -38,7 +38,7 @@ function flowchartThrottleKey(userId: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 题单防抄:题单布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸
|
||||
* 专题防抄:专题布置期内,学生在布置开始之前画的图先对他本人藏起来 —— 和代码提交同一道闸
|
||||
* (routes/submission.ts 的 canViewSubmission,规则见 problemSetLockCutoffs)。原来流程图
|
||||
* 这边没有这一条,以前画到 A 的那张图点一下就能载回画布,交上去就是 A。
|
||||
*/
|
||||
@@ -59,7 +59,7 @@ function canView(
|
||||
return row.userId === user.id || isTeacherOrAbove(user) || problem.createdById === user.id
|
||||
}
|
||||
|
||||
/** 只有学生看自己的提交时才需要查题单;其余情况给一张空表,省一次查询 */
|
||||
/** 只有学生看自己的提交时才需要查专题;其余情况给一张空表,省一次查询 */
|
||||
function cutoffsFor(user: AuthUser, rows: Array<{ userId: number; problemId: number }>) {
|
||||
if (isTeacherOrAbove(user)) return Promise.resolve(new Map<number, string>())
|
||||
const own = rows.filter((row) => row.userId === user.id).map((row) => row.problemId)
|
||||
@@ -270,7 +270,7 @@ async function flowchartUserFilter(username: string, exact = false) {
|
||||
const flowchartListColumns = {
|
||||
flowchart: {
|
||||
id: schema.flowchartSubmission.id,
|
||||
// showLink 判定要(本人 + 题单闸门),序列化本身用不到
|
||||
// showLink 判定要(本人 + 专题闸门),序列化本身用不到
|
||||
userId: schema.flowchartSubmission.userId,
|
||||
problemId: schema.flowchartSubmission.problemId,
|
||||
status: schema.flowchartSubmission.status,
|
||||
@@ -711,7 +711,7 @@ flowchartRoutes.post('/flowcharts/:id/retry', requireAuth, async (c) => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入题单之前的(题单闸门)。
|
||||
* 自己在这道题上评完的每一次,按时间从早到晚,已经滤掉加入专题之前的(专题闸门)。
|
||||
* 结果页签的历次分数(scores)从这一份出。
|
||||
*
|
||||
* 只取分数和闸门要的几列:整行带着画布 JSON 和 mermaid 源码(一条约 5KB),画了几十次
|
||||
@@ -740,7 +740,7 @@ async function myEvaluatedFlowcharts(user: AuthUser, problemId: number) {
|
||||
|
||||
/**
|
||||
* 历次分数:结果页签上那一排「C → B → A」。原来的 history 一次只给一条,
|
||||
* 要看走势得一页一页翻(它和 current 已经删了)。`hidden` 是被题单闸门藏起来的次数,界面上给一句说明。
|
||||
* 要看走势得一页一页翻(它和 current 已经删了)。`hidden` 是被专题闸门藏起来的次数,界面上给一句说明。
|
||||
*/
|
||||
flowchartRoutes.get('/problems/:id/flowchart/scores', requireAuth, async (c) => {
|
||||
const problemId = queryInteger(c.req.param('id'), 0, { min: 1 })
|
||||
|
||||
@@ -27,7 +27,7 @@ type ProgressRow = typeof schema.problemsetProgress.$inferSelect
|
||||
|
||||
const SOLVED_RESULTS = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||
|
||||
/** 必做题的 problemId;一道必做都没标的题单退回全部(和 computeProgress 同一个口径) */
|
||||
/** 必做题的 problemId;一道必做都没标的专题退回全部(和 computeProgress 同一个口径) */
|
||||
function gradedIds(links: { problemId: number; isRequired: boolean }[]) {
|
||||
const required = links.filter((link) => link.isRequired)
|
||||
return new Set((required.length ? required : links).map((link) => link.problemId))
|
||||
@@ -56,7 +56,7 @@ function progressSummary(progress: ProgressRow | undefined) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生能看到的题单:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生
|
||||
* 学生能看到的专题:公开的。老师连没公开的也能打开 —— 后台编辑完要到前台看看学生
|
||||
* 看到的样子、看全班情况,不该被 404 挡在外面。
|
||||
*/
|
||||
async function loadVisible(id: number, user: AuthUser | null | undefined) {
|
||||
@@ -99,7 +99,7 @@ function badgeData(badge: typeof schema.problemsetBadge.$inferSelect, earnedTime
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次把整页题单的附属数据全查回来,再在内存里按 problemsetId 分组。
|
||||
* 一次把整页专题的附属数据全查回来,再在内存里按 problemsetId 分组。
|
||||
* 固定 6 条查询,与行数无关(按行查就是 N+1)。
|
||||
*/
|
||||
async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boolean }[], userId?: number) {
|
||||
@@ -153,7 +153,7 @@ async function serializeProblemSets(rows: { set: ProblemSetRow; assigning: boole
|
||||
assignedUntil: row.assignedUntil,
|
||||
assigning,
|
||||
problemsCount: countBySet.get(row.id)?.value ?? 0,
|
||||
// 一道必做都没标的题单退回「全部都算必做」,和 computeProgress 同一个口径
|
||||
// 一道必做都没标的专题退回「全部都算必做」,和 computeProgress 同一个口径
|
||||
requiredCount: countBySet.get(row.id)?.required || (countBySet.get(row.id)?.value ?? 0),
|
||||
joinedCount: joinedBySet.get(row.id) ?? 0,
|
||||
userProgress: progressSummary(progress),
|
||||
@@ -207,7 +207,7 @@ problemsetRoutes.get('/problem-sets/locks', requireAuth, async (c) => {
|
||||
|
||||
problemsetRoutes.get('/problem-sets/:id', optionalAuth, async (c) => {
|
||||
const row = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user'))
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!row) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const [data] = await serializeProblemSets([row], c.get('user')?.id)
|
||||
return success(c, data)
|
||||
})
|
||||
@@ -215,10 +215,10 @@ problemsetRoutes.get('/problem-sets/:id', optionalAuth, async (c) => {
|
||||
problemsetRoutes.get('/problem-sets/:id/problems', optionalAuth, async (c) => {
|
||||
const user = c.get('user')
|
||||
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), user)
|
||||
if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const id = found.set.id
|
||||
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而题目是按数组下标
|
||||
// 编号的(1、2、3),老题单里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变
|
||||
// 编号的(1、2、3),老专题里 order 大量重复,不定死的话「第 3 题」每次刷新都可能变
|
||||
const rows = await db
|
||||
.select({
|
||||
link: schema.problemsetProblem,
|
||||
@@ -295,14 +295,14 @@ problemsetRoutes.post('/problem-set-progress', requireAuth, async (c) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const user = c.get('user')!
|
||||
const found = await loadVisible(parsed.data.problemSetId, user)
|
||||
if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const problemSet = found.set
|
||||
const [existing] = await db
|
||||
.select({ id: schema.problemsetProgress.id })
|
||||
.from(schema.problemsetProgress)
|
||||
.where(and(eq(schema.problemsetProgress.problemsetId, problemSet.id), eq(schema.problemsetProgress.userId, user.id)))
|
||||
.limit(1)
|
||||
if (existing) return failure(c, 409, 'already-joined', '已经加入该题单')
|
||||
if (existing) return failure(c, 409, 'already-joined', '已经加入该专题')
|
||||
await db.transaction(async (tx) => {
|
||||
const links = await tx
|
||||
.select({
|
||||
@@ -359,9 +359,9 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => {
|
||||
})
|
||||
|
||||
/**
|
||||
* 老师看一个班在这份题单里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
|
||||
* 老师看一个班在这个专题里的情况:真名 × 每道题(几点做对 / 错几次 / 加入前就做对过)。
|
||||
*
|
||||
* 所有老师都能看,不只是题单的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计
|
||||
* 所有老师都能看,不只是专题的创建者:系统里没有「班级归哪个老师」,课堂任务、数据统计
|
||||
* 也是任何老师看任何班,这里和它们一致。
|
||||
*
|
||||
* 班级不用老师选:从加入过的学生里数出有哪些班,最近有人加入的排第一个、默认就看它,
|
||||
@@ -370,7 +370,7 @@ problemsetRoutes.get('/users/:username/badges', optionalAuth, async (c) => {
|
||||
*/
|
||||
problemsetRoutes.get('/problem-sets/:id/class-view', requireTeacher, async (c) => {
|
||||
const found = await loadVisible(queryInteger(c.req.param('id'), 0, { min: 1 }), c.get('user'))
|
||||
if (!found) return failure(c, 404, 'problem-set-not-found', '题单不存在')
|
||||
if (!found) return failure(c, 404, 'problem-set-not-found', '专题不存在')
|
||||
const id = found.set.id
|
||||
const studentRole = inArray(schema.user.adminType, [...STUDENT_ROLES])
|
||||
const [links, joinedClasses] = await Promise.all([
|
||||
|
||||
@@ -101,10 +101,10 @@ submissionRoutes.post('/submissions', requireAuth, async (c) => {
|
||||
return failure(c, 400, 'language-not-allowed', `${parsed.data.language} is not allowed in the problem`)
|
||||
}
|
||||
|
||||
// 来源题单:前端只在 /problemset/:id/problem/:pid 那个入口带上它,落库纯粹是为了
|
||||
// 在提交列表里标出「这条是刷题单刷出来的」。校验只确认这道题确实在那个题单里 ——
|
||||
// 不查 visible / status,因为藏起来的题单里还困着已加入的学生(他们照样在做题),
|
||||
// 也不查有没有加入:没加入照样能从题单页点进题目,标记来源不该比入口本身更严。
|
||||
// 来源专题:前端只在 /problemset/:id/problem/:pid 那个入口带上它,落库纯粹是为了
|
||||
// 在提交列表里标出「这条是刷专题刷出来的」。校验只确认这道题确实在那个专题里 ——
|
||||
// 不查 visible / status,因为藏起来的专题里还困着已加入的学生(他们照样在做题),
|
||||
// 也不查有没有加入:没加入照样能从专题页点进题目,标记来源不该比入口本身更严。
|
||||
// 对不上就当没带,提交照收:来源标记错了顶多列表少个标签,不值得挡下一次提交。
|
||||
let problemsetId: number | null = null
|
||||
if (contestId === null && parsed.data.problemSetId) {
|
||||
@@ -241,7 +241,7 @@ function canViewSubmission(
|
||||
problemSetCutoffs?: Map<number, string>,
|
||||
) {
|
||||
if (!user) return false
|
||||
// 题单防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。
|
||||
// 专题防作弊,见 problemSetLockCutoffs。只对学生自己的提交生效,老师不受限。
|
||||
// 学生管理员算学生,同样受限(旧后端 `is_regular_user()` 放过了他)。
|
||||
if (row.userId === user.id && !isTeacherOrAbove(user)) {
|
||||
const cutoff = problemSetCutoffs?.get(row.problemId)
|
||||
@@ -273,7 +273,7 @@ const submissionListColumns = {
|
||||
id: schema.submission.id,
|
||||
createTime: schema.submission.createTime,
|
||||
userId: schema.submission.userId,
|
||||
// 题单闸门要按题定位,序列化本身用不到它
|
||||
// 专题闸门要按题定位,序列化本身用不到它
|
||||
problemId: schema.submission.problemId,
|
||||
/**
|
||||
* 显示**当前**用户名,和统计面板、个人主页对齐。列表里读的那份是提交时冻结的
|
||||
@@ -287,8 +287,8 @@ const submissionListColumns = {
|
||||
result: schema.submission.result,
|
||||
language: schema.submission.language,
|
||||
statisticInfo: schema.submission.statisticInfo,
|
||||
// 只取 id,题单标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来
|
||||
// 会动到下面那条调过的分页查询,而每页最多两三个不同的题单,PK 查一次更便宜
|
||||
// 只取 id,专题标题按页单独查一次(见 /submissions)——把 problemset 一起 join 进来
|
||||
// 会动到下面那条调过的分页查询,而每页最多两三个不同的专题,PK 查一次更便宜
|
||||
problemsetId: schema.submission.problemsetId,
|
||||
/**
|
||||
* 通过的测试点数 / 总数,口径同 caseSummary():只认 info.data 是数组、逐项 result 为 0。
|
||||
@@ -331,7 +331,7 @@ function listCaseSummary(row: { language: string; casesTotal: number | null; cas
|
||||
|
||||
/**
|
||||
* 提交详情。三种结局:没有这条(404)、别人的代码看不到(403)、能看 —— 能看里又分自己的
|
||||
* 代码在题单布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。
|
||||
* 代码在专题布置期内藏着(showLink: false,代码不给、别的照给,页面摆出是谁哪道题什么结果)。
|
||||
*/
|
||||
async function submissionDetail(id: string, user: AuthUser, withAttempts: boolean) {
|
||||
const [row] = await db
|
||||
@@ -355,7 +355,7 @@ async function submissionDetail(id: string, user: AuthUser, withAttempts: boolea
|
||||
// 光走 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)
|
||||
// 走到这里还看不了、又是自己的,只剩专题布置期这一种(见 canViewSubmission)
|
||||
if (!visible && !own) return { status: 'forbidden' } as const
|
||||
// info(含每个测试点的 test_case 编号与 output_md5)只给管理员,对齐旧后端:
|
||||
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
|
||||
@@ -489,8 +489,8 @@ async function paginateSubmissionRows(where: SQL | undefined, limit: number, off
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一页里出现过的来源题单,id → 标题。传进来的数组允许带 null 和重复值。
|
||||
* 一页最多 250 行、实际能落到的题单数是个位数,按主键 IN 查一次就完了。
|
||||
* 这一页里出现过的来源专题,id → 标题。传进来的数组允许带 null 和重复值。
|
||||
* 一页最多 250 行、实际能落到的专题数是个位数,按主键 IN 查一次就完了。
|
||||
*/
|
||||
async function problemsetTitleMap(ids: Array<number | null>) {
|
||||
const unique = [...new Set(ids.filter((id): id is number => id !== null))]
|
||||
@@ -545,7 +545,7 @@ submissionRoutes.get('/submissions', optionalAuth, async (c) => {
|
||||
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, {
|
||||
@@ -563,7 +563,7 @@ submissionRoutes.get('/submissions', optionalAuth, async (c) => {
|
||||
result: submission.result,
|
||||
language: submission.language,
|
||||
statisticInfo: asRecord(submission.statisticInfo),
|
||||
// 题单被删掉之后外键把 problemset_id 置了空,这里自然就没标记了
|
||||
// 专题被删掉之后外键把 problemset_id 置了空,这里自然就没标记了
|
||||
problemSet:
|
||||
submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId)
|
||||
? {
|
||||
@@ -609,7 +609,7 @@ submissionRoutes.get('/contests/:contestId/submissions', optionalAuth, requireCo
|
||||
.offset(offset),
|
||||
displayId ? unknownDisplayIds(displayId, contest.id) : [],
|
||||
])
|
||||
// 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了
|
||||
// 这里不挂专题防作弊闸门(对比公开列表):专题里的题必定是非比赛题——加题时卡了
|
||||
// `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交,
|
||||
// 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。
|
||||
// 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。
|
||||
@@ -628,7 +628,7 @@ submissionRoutes.get('/contests/:contestId/submissions', optionalAuth, requireCo
|
||||
result: submission.result,
|
||||
language: submission.language,
|
||||
statisticInfo: asRecord(submission.statisticInfo),
|
||||
// 比赛提交没有来源题单:题单只收非比赛题(admin/problemset.ts 加题时卡了
|
||||
// 比赛提交没有来源专题:专题只收非比赛题(admin/problemset.ts 加题时卡了
|
||||
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
|
||||
problemSet: null,
|
||||
// 比赛只报对错,不给通过几个点(口径见契约 caseSummarySchema)
|
||||
|
||||
@@ -25,7 +25,7 @@ import { metaAchievements, refreshUnlockedCount, rescanAchievement } from '../se
|
||||
*
|
||||
* **不管**的:acm_contest_rank(比赛榜有自己的一套罚时累计,重算要连带 submission_info
|
||||
* 里每题的尝试次数,口径复杂,单独一件事)、achievement.unlock_count(0010 之后
|
||||
* user_achievement 随成就级联,漂不了)、题单进度与奖章(判题这一路自己记账,见 services/problemset.ts)。
|
||||
* user_achievement 随成就级联,漂不了)、专题进度与奖章(判题这一路自己记账,见 services/problemset.ts)。
|
||||
*
|
||||
* 默认只读,把差异打出来;确认无误再加 --apply 落库。跑法对齐 migrate:
|
||||
*
|
||||
|
||||
@@ -53,11 +53,11 @@ export const ACHIEVEMENT_METRICS: AchievementMetric[] = [
|
||||
name: '参加比赛',
|
||||
helpText: '参加过的比赛数量(本指标是比赛维度,不受比赛提交不计入的限制)',
|
||||
},
|
||||
{ key: 'badge_count', name: '题单奖章', helpText: '获得的题单奖章数量' },
|
||||
{ key: 'badge_count', name: '专题奖章', helpText: '获得的专题奖章数量' },
|
||||
{
|
||||
key: 'problemset_completed',
|
||||
name: '做完的题单',
|
||||
helpText: '完成的题单数量',
|
||||
name: '做完的专题',
|
||||
helpText: '完成的专题数量',
|
||||
},
|
||||
{
|
||||
key: 'first_try_ac_count',
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AuthUser } from '../auth/session'
|
||||
import { db, schema } from '../db'
|
||||
|
||||
/**
|
||||
* 后台「谁能改哪一条」的判断,收在这一处。原来同一句「超管或创建者」在比赛、题单、
|
||||
* 后台「谁能改哪一条」的判断,收在这一处。原来同一句「超管或创建者」在比赛、专题、
|
||||
* 比赛题目、services/contest.ts 里各抄一遍,「题目全部权限」在题目和标签两个路由里
|
||||
* 各写一份 —— 哪天口径要改(比如加助教),漏改一处就是越权。
|
||||
*/
|
||||
|
||||
@@ -12,7 +12,7 @@ import { cached } from './cache'
|
||||
* 一个人在公开题库里的做题状态:做对了哪些、交过哪些。
|
||||
*
|
||||
* 代码看 `acm_problems_status`(列表的状态列一直就是它);画流程图的题,流程图评到
|
||||
* A / S 也算做对、交过流程图就算交过 —— 和题单、课堂条一个口径(题目页设计文档
|
||||
* A / S 也算做对、交过流程图就算交过 —— 和专题、课堂条一个口径(题目页设计文档
|
||||
* 第 3 节决定 6)。有的题一节课全班都在画流程图,只看代码的话状态列是空的。
|
||||
*/
|
||||
export async function problemStates(userId: number) {
|
||||
|
||||
@@ -11,7 +11,7 @@ type ProblemLink = { problemId: number; isRequired: boolean }
|
||||
type BadgeCheck = Pick<ProgressRow, 'completedProblemsCount' | 'totalProblemsCount' | 'progressDetail'>
|
||||
|
||||
/**
|
||||
* 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。
|
||||
* 专题进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。
|
||||
*
|
||||
* 以前两边各写一遍,于是各自漂了一段:后台那份不碰 is_completed(加一道题之后分母变大,
|
||||
* 人还标着「已完成」)、不清理 progress_detail(删掉一道题之后没做的题被算成做了)。
|
||||
@@ -19,7 +19,7 @@ type BadgeCheck = Pick<ProgressRow, 'completedProblemsCount' | 'totalProblemsCou
|
||||
*/
|
||||
export function computeProgress(detail: Record<string, unknown>, links: ProblemLink[], previousCompleteTime: string | null, now = new Date().toISOString()) {
|
||||
const inSet = new Set(links.map((link) => String(link.problemId)))
|
||||
// 已经移出题单的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多。
|
||||
// 已经移出专题的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多。
|
||||
// 老数据的每一项还带着 score(分数已经拿掉了),原样留着无害
|
||||
const kept: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(detail)) {
|
||||
@@ -27,17 +27,17 @@ export function computeProgress(detail: Record<string, unknown>, links: ProblemL
|
||||
}
|
||||
// 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems
|
||||
// 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到
|
||||
// (题单 5/6/8/11)。选做题做对了照样算进「做对 N 道」奖章,只是不卡完成。
|
||||
// (专题 5/6/8/11)。选做题做对了照样算进「做对 N 道」奖章,只是不卡完成。
|
||||
//
|
||||
// 一道必做都没标的题单退回「全部都算必做」:那种题单多半是没用这个字段,而不是
|
||||
// 一道必做都没标的专题退回「全部都算必做」:那种专题多半是没用这个字段,而不是
|
||||
// 真的整单选做;不兜住的话它永远完不成。
|
||||
const required = links.filter((link) => link.isRequired)
|
||||
const graded = required.length ? required : links
|
||||
const gradedKeys = new Set(graded.map((link) => String(link.problemId)))
|
||||
const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length
|
||||
const total = graded.length
|
||||
// total > 0 这个前提不能省:0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」,
|
||||
// 还会写下 complete_time、计进「完成题单数」成就,而且后面补上题目也不会自愈。
|
||||
// total > 0 这个前提不能省:0 === 0 同样成立,没有题目的专题会让人一加入就算「完成」,
|
||||
// 还会写下 complete_time、计进「完成专题数」成就,而且后面补上题目也不会自愈。
|
||||
const isCompleted = total > 0 && completed === total
|
||||
return {
|
||||
progressDetail: kept,
|
||||
@@ -46,9 +46,9 @@ export function computeProgress(detail: Record<string, unknown>, links: ProblemL
|
||||
isCompleted,
|
||||
// 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。
|
||||
//
|
||||
// 「未完成 + 有完成时间」是允许的组合,快照里就有 4 条 —— 题单 8 那批人在它
|
||||
// 「未完成 + 有完成时间」是允许的组合,快照里就有 4 条 —— 专题 8 那批人在它
|
||||
// 还只有 6 题时完成过,老师后来加到 12 题,进度退回未完成,完成时间留了下来。
|
||||
// 反过来清空的代价是不可逆:往一个 100 人已完成的题单里加一道题、再改主意删掉,
|
||||
// 反过来清空的代价是不可逆:往一个 100 人已完成的专题里加一道题、再改主意删掉,
|
||||
// 这 100 个人的历史完成时间就一起被冲成了「现在」。
|
||||
completeTime: previousCompleteTime ?? (isCompleted ? now : null),
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export function computeProgress(detail: Record<string, unknown>, links: ProblemL
|
||||
type ProgressWrite = ReturnType<typeof computeProgress> & { id: number }
|
||||
|
||||
/**
|
||||
* 一条 UPDATE 刷完整批参与者。逐行 update 的话一个班的题单就是上百次往返,
|
||||
* 一条 UPDATE 刷完整批参与者。逐行 update 的话一个班的专题就是上百次往返,
|
||||
* 而每行要写的值都已经在内存里算好了,没有一个依赖数据库现有的值。
|
||||
*/
|
||||
async function writeProgress(rows: ProgressWrite[]) {
|
||||
@@ -93,11 +93,11 @@ async function writeProgress(rows: ProgressWrite[]) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 奖章达标判定的唯一实现。学生做出一题、后台改题单、补发脚本三处都调它 ——
|
||||
* 奖章达标判定的唯一实现。学生做出一题、后台改专题、补发脚本三处都调它 ——
|
||||
* 以前是三份各写一遍。
|
||||
*
|
||||
* problem_count 数的是**做出的题目总数(含选做)**,不是 completedProblemsCount
|
||||
* (自从分母只算必做,那个只数必做题)。老师当初是按题单的总题数设阈值的:题单 5
|
||||
* (自从分母只算必做,那个只数必做题)。老师当初是按专题的总题数设阈值的:专题 5
|
||||
* 的「一职欧拉」要 8 题,而它的必做只有 7 道 —— 改用必做计数会让这枚奖章一夜之间
|
||||
* 不可得,76 个已经拿到的人被 recalculateBadge 收回。
|
||||
*/
|
||||
@@ -144,11 +144,11 @@ export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & {
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这份题单的奖章。
|
||||
* 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这个专题的奖章。
|
||||
*
|
||||
* 别信「旧后端不做这件事」那个说法(本仓早先的注释里有,是错的):旧栈用 signals 做了,
|
||||
* 而且两件事都做 —— problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete
|
||||
* 上重算全部参与者的进度、再重算该题单全部奖章的资格。重写时 views 里看不到显式调用,
|
||||
* 上重算全部参与者的进度、再重算该专题全部奖章的资格。重写时 views 里看不到显式调用,
|
||||
* 就当成没做,于是奖章那一半漏了,生产快照里攒下 53 条应发未发(30 名学生)。
|
||||
*
|
||||
* 那 53 条里有 23 条另有出处:旧栈的管理命令 fix_problemset_progress 按实际 AC 记录补
|
||||
@@ -177,16 +177,16 @@ export async function resyncProgress(problemsetId: number) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的题单。
|
||||
* 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的专题。
|
||||
*
|
||||
* 以前这件事由前端做:SubmitCode.vue 看到 AC 就回调 PUT /problem-set-progress,而且只回调
|
||||
* 路由参数里那一个题单。于是从普通题库入口做出同一道题不计进度、网络一抖进度就静默丢失;
|
||||
* 路由参数里那一个专题。于是从普通题库入口做出同一道题不计进度、网络一抖进度就静默丢失;
|
||||
* 旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账,2026-05-22 那次
|
||||
* 批量补进度就是它跑的(而它不补奖章,53 条漏发里的 23 条由此而来)。
|
||||
*
|
||||
* 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。
|
||||
*
|
||||
* 不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积。
|
||||
* 不按 visible / status 过滤:进度是学生自己的记录,老师把专题藏起来不该让它停止累积。
|
||||
*/
|
||||
export async function recordSolvedProblem(userId: number, problemId: number, solvedAt: string) {
|
||||
const joined = await db
|
||||
@@ -250,7 +250,7 @@ export async function recordSolvedProblem(userId: number, problemId: number, sol
|
||||
}
|
||||
|
||||
/**
|
||||
* 记题单进度,并把这一下拿到的徽章、解锁的题单类成就推给学生。判题(judge/run.ts)和
|
||||
* 记专题进度,并把这一下拿到的徽章、解锁的专题类成就推给学生。判题(judge/run.ts)和
|
||||
* 流程图评分(flowchart/run.ts)两条路都在「这道题做完了」的那一刻调它。
|
||||
* 记账失败不往外抛:判题 / 评分本身已经落库,不能因为进度没记上就把整次判题算失败。
|
||||
*/
|
||||
@@ -289,16 +289,16 @@ export async function recordSolvedAndNotify(userId: number, problemId: number, s
|
||||
}
|
||||
}
|
||||
|
||||
/** 题单现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */
|
||||
/** 专题现在是不是在布置期内。按数据库的钟算(`now()`),和防抄闸门同一个口径 */
|
||||
export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not null and ${schema.problemset.assignedUntil} > now())`
|
||||
|
||||
/**
|
||||
* 题单防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻
|
||||
* 专题防抄闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 分界时刻
|
||||
* (早于它的提交藏起来)。代码提交(routes/submission.ts)和流程图提交(routes/flowchart.ts)
|
||||
* 共用这一道闸。
|
||||
*
|
||||
* 规则(2026-10 题单重设计,用户拍板):题单**布置期内**,题单里这几道题,学生在**布置开始之前**
|
||||
* 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习题单:快照里 16% 的
|
||||
* 规则(2026-10 题单重设计,用户拍板):专题**布置期内**,专题里这几道题,学生在**布置开始之前**
|
||||
* 交的代码一律先藏起来 —— 不管他加没加入。老师常拿刚讲过的题攒复习专题:快照里 16% 的
|
||||
* 「学生 × 题」加入前就做对过,不藏的话把自己以前的答案贴进去就完了。
|
||||
*
|
||||
* 以前是「加入之后藏加入之前的」,于是先去提交记录里把旧答案复制下来、再点加入,闸就白设了;
|
||||
@@ -306,11 +306,11 @@ export const assigningSql = sql<boolean>`(${schema.problemset.assignedAt} is not
|
||||
* 在这一两周里也看不到自己的旧代码,用户认了。
|
||||
*
|
||||
* 解锁的路全写在 where 里,任一成立就查不出来、也就不遮挡:
|
||||
* - 题单不在布置期(没布置,或者布置期过了)
|
||||
* - 题单没公开
|
||||
* - 已经在这份题单里做对了这道题(progress_detail 里有这道题的 key)
|
||||
* - 专题不在布置期(没布置,或者布置期过了)
|
||||
* - 专题没公开
|
||||
* - 已经在这个专题里做对了这道题(progress_detail 里有这道题的 key)
|
||||
*
|
||||
* 一道题可能同时落在几个正在布置的题单里,取最晚的布置开始 ——「任一题单要求遮挡就遮挡」。
|
||||
* 一道题可能同时落在几个正在布置的专题里,取最晚的布置开始 ——「任一专题要求遮挡就遮挡」。
|
||||
*/
|
||||
export async function problemSetLockCutoffs(userId: number, problemIds: number[]) {
|
||||
const cutoffs = new Map<number, string>()
|
||||
@@ -338,7 +338,7 @@ export async function problemSetLockCutoffs(userId: number, problemIds: number[]
|
||||
return cutoffs
|
||||
}
|
||||
|
||||
/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的题单里 */
|
||||
/** 这道题的旧代码为什么藏着:它落在哪些正在布置、我还没在里面做对的专题里 */
|
||||
export async function problemSetLocksFor(userId: number, problemId: number) {
|
||||
return db
|
||||
.select({
|
||||
|
||||
Reference in new issue
Block a user