fix(题单): 「选做」终于作数了,分母只算必做题

isRequired 一直只是卡片上的一行字:卡片写着「(选做)」,进度分母和 all_problems
奖章却照样要求做完。结果是学生按提示跳过选做题,进度条卡在 100% 以下、全通奖章也
拿不到 —— 快照里 22 个人做完了全部必做题却显示未完成(题单 5 三人、6 十人、8 两人、
11 七人)。旧栈的 update_progress 同样不区分,是一路继承下来的。

改成:分母只算必做题,选做题做了仍然计分(totalScore 把它算进去),只是不卡完成。
一道必做都没标的题单退回「全部都算必做」—— 那种题单多半是没用这个字段,而不是真的
整单选做,不兜住的话它永远完不成。

## problem_count 奖章不能跟着改

老师当初是按题单的**总题数**设阈值的:题单 5 的「一职欧拉」要 8 题,而它的必做只有
7 道。要是 problem_count 也改用只数必做的 completedProblemsCount,这枚奖章一夜之间
不可得,76 个已经拿到的人会被 recalculateBadge 收回。所以它数的是「做出的题目总数
(含选做)」,从 progress_detail 的键数来。score 类同理不受影响。

预演证实了这道闸门有效:22 人拿到完成状态,奖章补发 56 条、**收回 0 条**。

## 顺带:第三份手抄的达标逻辑

PUT /problem-set-progress 里还藏着一份 inline 的奖章判定,和 services 里那份、
补发脚本里那份是三份各写各的 —— 这次改 problem_count 的语义,漏掉任何一份都会让
学生提交时发的奖章和后台重算的结果对不上。三处统一到 eligibleForBadge。

## 工具改名并扩到进度

backfill-badges → backfill-problemsets。语义变更之后,已有的进度行要跑一遍才会按新
规则重算,否则那 22 个人得等到老师下次动题单才生效。两笔账本来也是同一笔:进度一变,
奖章达标面就跟着变,所以落库走 resyncProgress(它重算进度后会顺手重算该题单全部奖章),
预演里的奖章差异也是照着订正后的进度算的,保证预演和 --apply 的结果一致。

在生产快照上实跑:

  合计:进度 494 条要重算(完成 +22 / -0),奖章补发 56 条、收回 0 条
  已订正 10 个题单 → 复核通过:题单数据与规则一致
  user_badge 1180 → 1236,已完成 621 → 643
  「未完成但有完成时间」仍是 4 条(d7a6414 那条语义保住了)
  重复跑幂等:进度 0 条、奖章 0 条

抽查题单 6(9 必做 + 1 选做):只做必做的显示 9/9 100% 已完成、80 分;连选做一起做的
同样 9/9 已完成,但 90 分。

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:55:36 -06:00
parent d9e6a2a3f0
commit 74b97c610f
6 changed files with 214 additions and 124 deletions

View File

@@ -5,8 +5,9 @@ import { objectValue } from "../routes/helpers"
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
type ProblemLink = { problemId: number; score: number }
type BadgeCheck = Pick<ProgressRow, "completedProblemsCount" | "totalProblemsCount" | "totalScore">
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
type BadgeCheck = Pick<ProgressRow,
"completedProblemsCount" | "totalProblemsCount" | "totalScore" | "progressDetail">
/**
* 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。
@@ -34,8 +35,17 @@ export function computeProgress(
// 分值以题单当前的设置为准detail 里存的是做出那一刻的快照
kept[key] = { ...objectValue(value), score }
}
const completed = Object.keys(kept).length
const total = links.length
// 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems
// 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到
// (题单 5/6/8/11。选做题做了仍然计分totalScore 把它算进去),只是不卡完成。
//
// 一道必做都没标的题单退回「全部都算必做」:那种题单多半是没用这个字段,而不是
// 真的整单选做;不兜住的话它永远完不成。
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、计进「完成题单数」成就而且后面补上题目也不会自愈。
const isCompleted = total > 0 && completed === total
@@ -97,13 +107,23 @@ async function writeProgress(rows: ProgressWrite[]) {
}
}
/** 纯逻辑判定,对齐旧 `ProblemSetBadge._is_eligible` */
/**
* 奖章达标判定的唯一实现。学生做出一题、后台改题单、补发脚本三处都调它 ——
* 以前是三份各写一遍。
*
* problem_count 数的是**做出的题目总数(含选做)**,不是 completedProblemsCount
* (自从分母只算必做,那个只数必做题)。老师当初是按题单的总题数设阈值的:题单 5
* 的「一职欧拉」要 8 题,而它的必做只有 7 道 —— 改用必做计数会让这枚奖章一夜之间
* 不可得76 个已经拿到的人被 recalculateBadge 收回。
*/
export function eligibleForBadge(badge: BadgeRow, progress: BadgeCheck) {
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 === "problem_count") {
return Object.keys(objectValue(progress.progressDetail)).length >= badge.conditionValue
}
if (badge.conditionType === "score") return progress.totalScore >= badge.conditionValue
return false
}
@@ -154,8 +174,11 @@ export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & {
*/
export async function resyncProgress(problemsetId: number) {
const [links, progresses, badges] = await Promise.all([
db.select({ problemId: schema.problemsetProblem.problemId, score: schema.problemsetProblem.score })
.from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, problemsetId)),
db.select({
problemId: schema.problemsetProblem.problemId,
score: schema.problemsetProblem.score,
isRequired: schema.problemsetProblem.isRequired,
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, problemsetId)),
db.select().from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, problemsetId)),
db.select().from(schema.problemsetBadge)
@@ -171,9 +194,9 @@ export async function resyncProgress(problemsetId: number) {
}
/** 按奖章算出「现在应该有谁」,只读,供补发脚本先看后写 */
export async function badgeHolderDiff(badge: BadgeRow) {
export async function badgeHolderDiff(badge: BadgeRow, known?: (BadgeCheck & { userId: number })[]) {
const [progresses, holders] = await Promise.all([
db.select().from(schema.problemsetProgress)
known ?? db.select().from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId)),
db.select({ userId: schema.userBadge.userId }).from(schema.userBadge)
.where(eq(schema.userBadge.badgeId, badge.id)),