fix(题单): 进度重算漏了分数和完成状态,奖章没人补发
Some checks failed
Deploy / deploy (push) Has been cancelled

进度算法在学生路径和后台路径各写了一遍,于是各自漂了一段。后台那份
(resyncProgress)名叫「把所有参与者的进度重算一遍」,实际只更新分母和百分比:

- 不碰 total_score。改题目分值时专门调了它,函数体里却没这个字段,score 类奖章
  因此按陈旧分数判定。
- 不碰 is_completed。加一道题之后分母变大、百分比掉下来,人还标着「已完成」。
- 不清理 progress_detail。删掉一道题之后 least(completed, total) 只保证不超过分母,
  会把没做的题算成做了 —— 3 题的题单只做出 C,删掉 C 就变成 1/2 = 50%。
- 不重算奖章。题目集一变 all_problems 的达标面就变了,没有任何地方补发。

最后一条攒下了实打实的欠账:8-07 的快照里 53 条应发未发,涉及 30 名学生,误发 0 条。
其中 23 条来自 2026-05-22 00:50 那次一分钟内跨 7 个题单的批量补进度 —— 进度补了,
奖章没人回头判。

两边不再分叉的唯一办法是只留一处算法,所以抽出 services/problemset.ts:
computeProgress 是纯函数,学生做出一题和后台改题目都只是调用者;
eligibleForBadge / recalculateBadge / resyncProgress 一并搬过来,补发脚本才能复用
同一套判定。批量写回仍是一条 UPDATE ... FROM (VALUES ...),没退回逐行往返。

顺带修掉空题单的坑:isCompleted 加了 total > 0 前提。原来 0 === 0 也成立,老师先建
题单、学生先加入、题目后加,加入那一刻就写下 complete_time 并计进「完成题单数」成就,
而且后面补上题目也不会自愈。

一处行为变化:退回未完成时 complete_time 会清空。后台那份原来保留旧时间,学生那份
一直是清空的,统一成后者 —— 否则同一行会出现「未完成 + 有完成时间」,破坏现有数据里
「complete_time 非空 ⟺ is_completed」这条不变量。代价是加题再删题会把历史完成时间
洗成「现在」。

scripts/backfill-problemset-badges.ts 补历史欠账,默认只读预演,--apply 才落库。
只要存在误发就拒绝执行并打出名单,要连收回一起做得显式加 --allow-revoke ——
recalculateBadge 的删除是真删,user_badge 的 earned_time 没有别处备份。

在生产快照上实跑:补发 1180 → 1233 条,复核全部一致,历史 earned_time 未被刷新;
resyncProgress 对题单 9(7 题 / 117 人 / 100 人完成)加题后正确退回 0 人完成并收回
100 枚奖章,删题后完整恢复;改题目分值 10→20 使总分和 7460 → 8590,正好 +113×10。

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:38 -06:00
parent ff31f7abd1
commit f9354b0df1
5 changed files with 280 additions and 89 deletions

View File

@@ -12,19 +12,19 @@ import {
updateProblemSetRequestSchema,
updateProblemSetStatusRequestSchema,
} from "@oj2/contract"
import { and, asc, count, desc, eq, ilike, inArray, isNull, notInArray, or, sql } from "drizzle-orm"
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"
import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
import type { AuthUser } from "../../auth/session"
import { db, schema } from "../../db"
import { failure, success } from "../../http"
import { recalculateBadge, resyncProgress } from "../../services/problemset"
import { queryInteger, sampleUser } from "../helpers"
export const adminProblemSetRoutes = new Hono<AppEnv>()
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
/** 对齐旧 ensure_created_by超管放行其余人只能碰自己建的。越权报「不存在」 */
function ownedBy(user: AuthUser, row: { createdById: number }) {
@@ -305,46 +305,6 @@ async function badgesWithCount(badges: BadgeRow[]) {
}))
}
/** 纯逻辑判定,对齐旧 `ProblemSetBadge._is_eligible` */
function eligible(badge: BadgeRow, progress: ProgressRow) {
if (badge.conditionType === "all_problems") {
return progress.totalProblemsCount > 0 &&
progress.completedProblemsCount === progress.totalProblemsCount
}
if (badge.conditionType === "problem_count") return progress.completedProblemsCount >= badge.conditionValue
if (badge.conditionType === "score") return progress.totalScore >= badge.conditionValue
return false
}
/**
* 重算某个奖章的获得者,对齐旧 `recalculate_user_badges`(由 post_save 信号触发)。
* 保留已有记录的 earnedTime —— 只增删差集,不是先清空再重建,
* 否则每改一次条件所有人的获得时间都会刷新成今天。
*/
async function recalculateBadge(badge: BadgeRow) {
const progresses = await db.select().from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId))
const eligibleIds = progresses.filter((item) => eligible(badge, item)).map((item) => item.userId)
await db.transaction(async (tx) => {
await tx.delete(schema.userBadge).where(and(
eq(schema.userBadge.badgeId, badge.id),
eligibleIds.length ? notInArray(schema.userBadge.userId, eligibleIds) : undefined,
))
if (!eligibleIds.length) return
const existing = await tx.select({ userId: schema.userBadge.userId }).from(schema.userBadge)
.where(eq(schema.userBadge.badgeId, badge.id))
const have = new Set(existing.map((item) => item.userId))
const missing = eligibleIds.filter((id) => !have.has(id))
if (missing.length) {
await tx.insert(schema.userBadge).values(missing.map((userId) => ({
userId,
badgeId: badge.id,
earnedTime: new Date().toISOString(),
})))
}
})
}
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", "题单不存在")
@@ -408,31 +368,6 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher
// ---------------------------------------------------------------- 学生进度
/**
* 题目集或分值变动后,把所有参与者的进度重算一遍。
*
* 旧后端不做这件事:往题单里加一道题,学生那边的 totalProblemsCount 还是老数字,
* 进度百分比因此偏高,甚至已经「完成」的人分母变了却还标着完成。
* 只重算分母与百分比,不碰 completeTime —— 已经完成过的事实不因加题而撤销。
*/
async function resyncProgress(problemsetId: number) {
const [totalRow] = await db.select({ value: count() }).from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, problemsetId))
const total = totalRow?.value ?? 0
// 一条 UPDATE 把整个题单的参与者刷完。以前是先把 progress 全查出来再逐行 update
// 一个班的题单就是几十次往返,而算出来的值只跟 total 和这一行自己的 completed 有关。
// completed 用 least(...) 夹住,百分比按 JS 那边同样的「乘 10000 四舍五入再除 100」
// 保留两位小数 —— 数都是非负的numeric 的 round 和 Math.round 在这个区间一致。
const completed = sql`least(${schema.problemsetProgress.completedProblemsCount}, ${total})`
await db.update(schema.problemsetProgress).set({
totalProblemsCount: total,
completedProblemsCount: completed,
progressPercentage: total > 0
? sql`round((${completed}::numeric / ${total}) * 10000) / 100`
: sql`0`,
}).where(eq(schema.problemsetProgress.problemsetId, problemsetId))
}
adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c) => {
const row = await loadOwned(c, c.get("user")!)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")

View File

@@ -20,7 +20,6 @@ import {
gt,
ilike,
inArray,
isNull,
ne,
or,
sql,
@@ -33,6 +32,7 @@ import { publishAchievementNotification } from "../events"
import { failure, success } from "../http"
import { JudgeStatus } from "../judge/status"
import { updateAchievementsForProblemSet } from "../services/achievements"
import { computeProgress } from "../services/problemset"
import { objectValue, queryInteger, sampleUser } from "./helpers"
export const problemsetRoutes = new Hono<AppEnv>()
@@ -216,27 +216,9 @@ async function recomputeProgress(
) {
const links = await tx.select({ problemId: schema.problemsetProblem.problemId, score: schema.problemsetProblem.score })
.from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
const valid = new Map(links.map((link) => [String(link.problemId), link.score]))
for (const key of Object.keys(detail)) if (!valid.has(key)) delete detail[key]
let totalScore = 0
for (const [key, value] of Object.entries(detail)) {
const score = valid.get(key)
if (score === undefined) continue
totalScore += score
detail[key] = { ...objectValue(value), score }
}
const completed = Object.keys(detail).length
const total = links.length
const isCompleted = completed === total
const update = {
progressDetail: detail,
totalProblemsCount: total,
completedProblemsCount: completed,
totalScore,
progressPercentage: total > 0 ? completed / total * 100 : 0,
isCompleted,
completeTime: isCompleted ? progress.completeTime ?? new Date().toISOString() : null,
}
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份,
// 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed
const update = computeProgress(detail, links, progress.completeTime)
await tx.update(schema.problemsetProgress).set(update).where(eq(schema.problemsetProgress.id, progress.id))
return { ...progress, ...update }
}