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:
@@ -11,7 +11,7 @@
|
||||
"worker": "bun src/main.ts worker",
|
||||
"build": "bun build --compile --target=bun-linux-x64 src/main.ts --outfile ../../dist/oj2-api",
|
||||
"seed:dev": "bun src/scripts/seed-dev.ts",
|
||||
"backfill:badges": "bun src/main.ts backfill-badges",
|
||||
"backfill:problemsets": "bun src/main.ts backfill-problemsets",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:routes": "bun src/scripts/check-route-shadowing.ts",
|
||||
"db:pull": "drizzle-kit pull",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* oj2-api healthcheck # 探活,给 Dockerfile 的 HEALTHCHECK 用
|
||||
* oj2-api sql-child # SQL 判题子进程,由服务自己 spawn,不该手动调
|
||||
* oj2-api migrate # 执行待办的数据库迁移,部署时由 docker/deploy.sh 调
|
||||
* oj2-api backfill-badges # 补发历史欠账的题单奖章,默认只读预演,--apply 才落库
|
||||
* oj2-api backfill-problemsets # 把题单进度与奖章订正到与规则一致,默认只读预演
|
||||
*
|
||||
* 用动态 import 而非顶层 import:这几个模块都有导入即执行的副作用
|
||||
* (Bun.serve、连 Redis 开消费者),静态导入会让 sql-child 也把整个服务拉起来。
|
||||
@@ -35,10 +35,10 @@ switch (command) {
|
||||
break
|
||||
}
|
||||
// 一次性的数据订正,跟着二进制走而不是留成源码脚本 —— 生产镜像里没有 bun 也没有源码
|
||||
case "backfill-badges": {
|
||||
const { backfillProblemSetBadges } = await import("./scripts/backfill-problemset-badges")
|
||||
case "backfill-problemsets": {
|
||||
const { backfillProblemSets } = await import("./scripts/backfill-problemsets")
|
||||
const args = process.argv.slice(3)
|
||||
process.exit(await backfillProblemSetBadges({
|
||||
process.exit(await backfillProblemSets({
|
||||
apply: args.includes("--apply"),
|
||||
allowRevoke: args.includes("--allow-revoke"),
|
||||
}))
|
||||
@@ -63,6 +63,6 @@ switch (command) {
|
||||
}
|
||||
}
|
||||
default:
|
||||
console.error(`未知子命令:${command}\n可用:serve | worker | migrate | backfill-badges | healthcheck | sql-child`)
|
||||
console.error(`未知子命令:${command}\n可用:serve | worker | migrate | backfill-problemsets | healthcheck | sql-child`)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,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 { computeProgress, eligibleForBadge } from "../services/problemset"
|
||||
import { objectValue, queryInteger, sampleUser } from "./helpers"
|
||||
|
||||
export const problemsetRoutes = new Hono<AppEnv>()
|
||||
@@ -206,8 +206,11 @@ async function recomputeProgress(
|
||||
progress: typeof schema.problemsetProgress.$inferSelect,
|
||||
detail: Record<string, unknown>,
|
||||
) {
|
||||
const links = await tx.select({ problemId: schema.problemsetProblem.problemId, score: schema.problemsetProblem.score })
|
||||
.from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
|
||||
const links = await tx.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
score: schema.problemsetProblem.score,
|
||||
isRequired: schema.problemsetProblem.isRequired,
|
||||
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
|
||||
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份,
|
||||
// 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed
|
||||
const update = computeProgress(detail, links, progress.completeTime)
|
||||
@@ -283,11 +286,9 @@ problemsetRoutes.put("/problem-set-progress", requireAuth, async (c) => {
|
||||
})
|
||||
}
|
||||
const badges = await tx.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, problemSet.id))
|
||||
const hits = badges.filter((badge) => badge.conditionType === "all_problems"
|
||||
? updated.totalProblemsCount > 0 && updated.completedProblemsCount === updated.totalProblemsCount
|
||||
: badge.conditionType === "problem_count"
|
||||
? updated.completedProblemsCount >= badge.conditionValue
|
||||
: badge.conditionType === "score" && updated.totalScore >= badge.conditionValue)
|
||||
// 判定走 services/problemset.ts 那一份 —— 这里原来是第三份手抄的达标逻辑,
|
||||
// 后台重算和补发脚本各有各的,改一处规则就会漏掉另外两处
|
||||
const hits = badges.filter((badge) => eligibleForBadge(badge, updated))
|
||||
if (hits.length === 0) return { earned: [] as (typeof schema.problemsetBadge.$inferSelect)[] }
|
||||
// 达标的奖章一次插完,冲突忽略后 returning 回来的就是这次真拿到的
|
||||
const inserted = await tx.insert(schema.userBadge).values(hits.map((badge) => ({
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
import { badgeHolderDiff, recalculateBadge } from "../services/problemset"
|
||||
|
||||
/**
|
||||
* 补发历史欠账的题单奖章。
|
||||
*
|
||||
* 奖章原本只在「学生做出一道题」那一刻发(`PUT /problem-set-progress`),进度要是从别的
|
||||
* 路径变了 —— 后台加减题目、手工批量补进度 —— 就没人回头判过达标。生产快照里因此攒下
|
||||
* 53 条应发未发、涉及 30 名学生,其中 23 条来自 2026-05-22 00:50 那次一分钟内跨 7 个题单
|
||||
* 的批量补进度。
|
||||
*
|
||||
* 默认只读,把差异打出来;确认无误再加 --apply 落库。
|
||||
* 补发用的是 recalculateBadge,它同时会**收回**已经不达标的人的奖章,所以只要存在
|
||||
* 「误发」就先停下来让人看清楚,要真的收回得显式加 --allow-revoke。
|
||||
*
|
||||
* 做成 main.ts 的子命令而不是独立脚本,是因为生产镜像里只有编译好的单二进制,
|
||||
* 既没有 bun 也没有源码,`bun src/scripts/...` 在那儿根本不存在。跑法对齐 migrate:
|
||||
*
|
||||
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-badges
|
||||
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-badges --apply
|
||||
*
|
||||
* 本机开发:
|
||||
*
|
||||
* bun run --cwd apps/api backfill:badges
|
||||
* bun apps/api/src/main.ts backfill-badges --apply
|
||||
*/
|
||||
export async function backfillProblemSetBadges(options: { apply: boolean; allowRevoke: boolean }) {
|
||||
const rows = await db
|
||||
.select({ badge: schema.problemsetBadge, title: schema.problemset.title })
|
||||
.from(schema.problemsetBadge)
|
||||
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetBadge.problemsetId))
|
||||
.orderBy(schema.problemsetBadge.problemsetId, schema.problemsetBadge.id)
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("没有任何题单奖章,无事可做")
|
||||
return 0
|
||||
}
|
||||
|
||||
const diffs = []
|
||||
for (const { badge, title } of rows) {
|
||||
diffs.push({ badge, title, ...(await badgeHolderDiff(badge)) })
|
||||
}
|
||||
|
||||
const missingTotal = diffs.reduce((sum, d) => sum + d.missing.length, 0)
|
||||
const extraTotal = diffs.reduce((sum, d) => sum + d.extra.length, 0)
|
||||
const affected = new Set(diffs.flatMap((d) => [...d.missing, ...d.extra]))
|
||||
|
||||
console.log(`共 ${rows.length} 枚奖章\n`)
|
||||
for (const d of diffs) {
|
||||
if (!d.missing.length && !d.extra.length) continue
|
||||
const cond = `${d.badge.conditionType}/${d.badge.conditionValue}`
|
||||
console.log(
|
||||
` 题单${String(d.badge.problemsetId).padStart(2)} ${d.title} [${d.badge.name}] ${cond}\n` +
|
||||
` 应发 ${d.eligible} / 现有 ${d.held}` +
|
||||
(d.missing.length ? ` 漏发 ${d.missing.length}:user ${d.missing.join(", ")}` : "") +
|
||||
(d.extra.length ? ` 误发 ${d.extra.length}:user ${d.extra.join(", ")}` : ""),
|
||||
)
|
||||
}
|
||||
console.log(`\n合计:漏发 ${missingTotal} 条,误发 ${extraTotal} 条,涉及 ${affected.size} 名学生`)
|
||||
|
||||
if (missingTotal === 0 && extraTotal === 0) {
|
||||
console.log("奖章与规则一致,无需补发")
|
||||
return 0
|
||||
}
|
||||
|
||||
if (!options.apply) {
|
||||
console.log("\n这是只读预演,什么都没写。确认无误后加 --apply 落库。")
|
||||
return 0
|
||||
}
|
||||
|
||||
if (extraTotal > 0 && !options.allowRevoke) {
|
||||
console.error(
|
||||
`\n存在 ${extraTotal} 条误发。补发用的 recalculateBadge 会把它们**删掉**,` +
|
||||
`而 user_badge 没有别处备份、earnedTime 删了就找不回来。\n` +
|
||||
`确认要连同收回一起执行,加 --allow-revoke。`,
|
||||
)
|
||||
return 1
|
||||
}
|
||||
|
||||
let touched = 0
|
||||
for (const d of diffs) {
|
||||
if (!d.missing.length && !d.extra.length) continue
|
||||
await recalculateBadge(d.badge)
|
||||
touched += 1
|
||||
}
|
||||
console.log(`\n已重算 ${touched} 枚奖章,复核中……`)
|
||||
|
||||
let remaining = 0
|
||||
for (const { badge, title } of rows) {
|
||||
const after = await badgeHolderDiff(badge)
|
||||
if (after.missing.length || after.extra.length) {
|
||||
remaining += after.missing.length + after.extra.length
|
||||
console.error(` 仍不一致:题单${badge.problemsetId} ${title} [${badge.name}]`, after)
|
||||
}
|
||||
}
|
||||
console.log(remaining === 0 ? "复核通过:全部奖章与规则一致" : `复核未通过,仍有 ${remaining} 条差异`)
|
||||
return remaining === 0 ? 0 : 1
|
||||
}
|
||||
166
apps/api/src/scripts/backfill-problemsets.ts
Normal file
166
apps/api/src/scripts/backfill-problemsets.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
import { objectValue } from "../routes/helpers"
|
||||
import { badgeHolderDiff, computeProgress, recalculateBadge, resyncProgress } from "../services/problemset"
|
||||
|
||||
/**
|
||||
* 把题单的进度和奖章订正到与当前规则一致。
|
||||
*
|
||||
* 两笔历史欠账:
|
||||
*
|
||||
* 1. 奖章原本只在「学生做出一道题」那一刻发(`PUT /problem-set-progress`),进度从别的路径
|
||||
* 变了 —— 后台加减题目、旧栈的 fix_problemset_progress 批量补进度 —— 就没人回头判过达标。
|
||||
* 生产快照里 53 条应发未发、涉及 30 名学生。
|
||||
* 2. 进度的算法后来改了:分母只算必做题(选做题不再卡完成),空题单不再算完成,
|
||||
* total_score 跟着题目分值走。已有的行要跑一遍才会按新规则重算。
|
||||
*
|
||||
* 两件事一起做,因为它们是同一笔账:进度一变,奖章达标面就跟着变,
|
||||
* 所以落库走的是 resyncProgress —— 它重算进度之后会顺手重算这份题单的全部奖章。
|
||||
*
|
||||
* 默认只读,把差异打出来;确认无误再加 --apply 落库。
|
||||
* 只要预演里出现「收回」就先停下来让人看清楚,要真的收回得显式加 --allow-revoke ——
|
||||
* user_badge 没有别处备份,earnedTime 删了就找不回来。
|
||||
*
|
||||
* 做成 main.ts 的子命令而不是独立脚本,是因为生产镜像里只有编译好的单二进制,
|
||||
* 既没有 bun 也没有源码。跑法对齐 migrate:
|
||||
*
|
||||
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-problemsets
|
||||
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-problemsets --apply
|
||||
*
|
||||
* 本机开发:bun apps/api/src/main.ts backfill-problemsets
|
||||
*/
|
||||
export async function backfillProblemSets(options: { apply: boolean; allowRevoke: boolean }) {
|
||||
const sets = await db.select({ id: schema.problemset.id, title: schema.problemset.title })
|
||||
.from(schema.problemset).orderBy(schema.problemset.id)
|
||||
if (sets.length === 0) {
|
||||
console.log("没有任何题单,无事可做")
|
||||
return 0
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const report = []
|
||||
for (const set of sets) {
|
||||
const [links, progresses, badges] = await Promise.all([
|
||||
db.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
score: schema.problemsetProblem.score,
|
||||
isRequired: schema.problemsetProblem.isRequired,
|
||||
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, set.id)),
|
||||
db.select().from(schema.problemsetProgress)
|
||||
.where(eq(schema.problemsetProgress.problemsetId, set.id)),
|
||||
db.select().from(schema.problemsetBadge)
|
||||
.where(eq(schema.problemsetBadge.problemsetId, set.id)),
|
||||
])
|
||||
// 按新规则重算一遍,但不落库 —— 奖章的差异要照着订正后的进度看,
|
||||
// 否则预演里报出来的名单和 --apply 之后的结果对不上
|
||||
const next = progresses.map((row) => ({
|
||||
...row,
|
||||
...computeProgress(objectValue(row.progressDetail), links, row.completeTime, now),
|
||||
}))
|
||||
const changed = next.filter((row, i) => {
|
||||
const was = progresses[i]!
|
||||
return was.totalProblemsCount !== row.totalProblemsCount ||
|
||||
was.completedProblemsCount !== row.completedProblemsCount ||
|
||||
was.totalScore !== row.totalScore ||
|
||||
was.isCompleted !== row.isCompleted ||
|
||||
Math.abs(was.progressPercentage - row.progressPercentage) > 0.005 ||
|
||||
was.completeTime !== row.completeTime ||
|
||||
JSON.stringify(objectValue(was.progressDetail)) !== JSON.stringify(row.progressDetail)
|
||||
})
|
||||
const newlyCompleted = next.filter((row, i) => !progresses[i]!.isCompleted && row.isCompleted).length
|
||||
const uncompleted = next.filter((row, i) => progresses[i]!.isCompleted && !row.isCompleted).length
|
||||
const badgeDiffs = []
|
||||
for (const badge of badges) badgeDiffs.push({ badge, ...(await badgeHolderDiff(badge, next)) })
|
||||
report.push({ set, changed: changed.length, newlyCompleted, uncompleted, badgeDiffs })
|
||||
}
|
||||
|
||||
const progressRows = report.reduce((n, r) => n + r.changed, 0)
|
||||
const completedGain = report.reduce((n, r) => n + r.newlyCompleted, 0)
|
||||
const completedLoss = report.reduce((n, r) => n + r.uncompleted, 0)
|
||||
const missing = report.reduce((n, r) => n + r.badgeDiffs.reduce((m, d) => m + d.missing.length, 0), 0)
|
||||
const extra = report.reduce((n, r) => n + r.badgeDiffs.reduce((m, d) => m + d.extra.length, 0), 0)
|
||||
|
||||
console.log(`共 ${sets.length} 个题单\n`)
|
||||
for (const r of report) {
|
||||
const lines = []
|
||||
if (r.changed) {
|
||||
lines.push(` 进度:${r.changed} 条要重算` +
|
||||
(r.newlyCompleted ? `,其中 ${r.newlyCompleted} 条未完成 → 已完成` : "") +
|
||||
(r.uncompleted ? `,${r.uncompleted} 条已完成 → 未完成` : ""))
|
||||
}
|
||||
for (const d of r.badgeDiffs) {
|
||||
if (!d.missing.length && !d.extra.length) continue
|
||||
lines.push(` 奖章[${d.badge.name}] ${d.badge.conditionType}/${d.badge.conditionValue}:` +
|
||||
`应发 ${d.eligible} / 现有 ${d.held}` +
|
||||
(d.missing.length ? ` 补发 ${d.missing.length}:user ${d.missing.join(", ")}` : "") +
|
||||
(d.extra.length ? ` 收回 ${d.extra.length}:user ${d.extra.join(", ")}` : ""))
|
||||
}
|
||||
if (lines.length) {
|
||||
console.log(` 题单${String(r.set.id).padStart(2)} ${r.set.title}`)
|
||||
for (const line of lines) console.log(line)
|
||||
}
|
||||
}
|
||||
console.log(`\n合计:进度 ${progressRows} 条要重算(完成 +${completedGain} / -${completedLoss}),` +
|
||||
`奖章补发 ${missing} 条、收回 ${extra} 条`)
|
||||
|
||||
if (progressRows === 0 && missing === 0 && extra === 0) {
|
||||
console.log("题单数据与当前规则一致,无需订正")
|
||||
return 0
|
||||
}
|
||||
if (!options.apply) {
|
||||
console.log("\n这是只读预演,什么都没写。确认无误后加 --apply 落库。")
|
||||
return 0
|
||||
}
|
||||
if (extra > 0 && !options.allowRevoke) {
|
||||
console.error(`\n预演里有 ${extra} 条奖章要被收回,而 user_badge 没有别处备份、` +
|
||||
`earnedTime 删了就找不回来。\n确认要连同收回一起执行,加 --allow-revoke。`)
|
||||
return 1
|
||||
}
|
||||
|
||||
let touched = 0
|
||||
for (const r of report) {
|
||||
if (!r.changed && !r.badgeDiffs.some((d) => d.missing.length || d.extra.length)) continue
|
||||
// resyncProgress 重算进度之后会把这份题单的奖章一并重算,两笔账一次结清
|
||||
await resyncProgress(r.set.id)
|
||||
touched += 1
|
||||
}
|
||||
// 没有参与者、只有奖章欠账的题单不会走上面那条,兜一遍
|
||||
for (const r of report) {
|
||||
for (const d of r.badgeDiffs) {
|
||||
if (d.missing.length || d.extra.length) await recalculateBadge(d.badge)
|
||||
}
|
||||
}
|
||||
console.log(`\n已订正 ${touched} 个题单,复核中……`)
|
||||
|
||||
let remaining = 0
|
||||
for (const set of sets) {
|
||||
const [links, progresses, badges] = await Promise.all([
|
||||
db.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
score: schema.problemsetProblem.score,
|
||||
isRequired: schema.problemsetProblem.isRequired,
|
||||
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, set.id)),
|
||||
db.select().from(schema.problemsetProgress).where(eq(schema.problemsetProgress.problemsetId, set.id)),
|
||||
db.select().from(schema.problemsetBadge).where(eq(schema.problemsetBadge.problemsetId, set.id)),
|
||||
])
|
||||
for (const row of progresses) {
|
||||
const next = computeProgress(objectValue(row.progressDetail), links, row.completeTime, now)
|
||||
if (row.isCompleted !== next.isCompleted || row.totalScore !== next.totalScore ||
|
||||
row.completedProblemsCount !== next.completedProblemsCount ||
|
||||
row.totalProblemsCount !== next.totalProblemsCount) {
|
||||
remaining += 1
|
||||
console.error(` 进度仍不一致:题单${set.id} user ${row.userId}`)
|
||||
}
|
||||
}
|
||||
for (const badge of badges) {
|
||||
const after = await badgeHolderDiff(badge)
|
||||
if (after.missing.length || after.extra.length) {
|
||||
remaining += after.missing.length + after.extra.length
|
||||
console.error(` 奖章仍不一致:题单${set.id} [${badge.name}]`, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(remaining === 0 ? "复核通过:题单数据与规则一致" : `复核未通过,仍有 ${remaining} 处差异`)
|
||||
return remaining === 0 ? 0 : 1
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user