fix(题单): 进度记账挪到判题这一路,不再靠前端回调
前端记账是这条链上最松的一环:SubmitCode.vue 看到 AC 就回调
PUT /problem-set-progress,而且只认路由参数里那一个题单。于是
· 从普通题库入口做出同一道题 → 不计进度
· 网络一抖、页面提前关掉 → 进度静默丢失
· 一道题同时在两个已加入的题单里 → 只有进去的那个记上
旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账 ——
2026-05-22 00:50 那次一分钟内跨 7 个题单的批量补进度就是它跑的。
改成判题落库之后由 judge/run.ts 记账(recordSolvedProblem),记进该用户**所有**已加入
且包含这道题的题单。位置在最后那条 publishSubmissionUpdate("finished") 之前,所以前端
收到「判完了」时进度已经落库,跳回题单页看到的就是新数据。前端那次回调删掉。
不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积;
更要紧的是这条口径必须和补账工具一致,否则补账工具会永远「发现」差异。
实跑(本地起 api + worker 真判一次):提交时**完全没带 problemSetId**,判完之后
progress 变成 1/1 100% 已完成、10 分、complete_time 落下、all_problems 奖章发出、
problemset_submission 也记上了。
## 补账那半
backfill-problemsets 前面加一道「按实际 AC 补进度」,移植自旧栈的
fix_problemset_progress,口径和 recordSolvedProblem 逐条对齐(非比赛提交、
ACCEPTED 或 AST_CHECK_FAILED、取最早那次)。补录的格子先并进 detail 再重算,
所以预演里的奖章名单是照着「补完账又重算过」的进度算的,和 --apply 的结果一致。
在生产快照上实跑:
合计:补录 10 道题,进度 497 条要重算(完成 +23 / -0),奖章补发 56 条、收回 0 条
已订正 11 个题单 → 复核通过
user_badge 1180 → 1236,已完成 621 → 644,problemset_submission 7735 → 7745
「未完成但有完成时间」仍是 4 条;重复跑幂等
补录的 10 道分布在题单 4/5/6/15/16(1/1/1/4/3),和离线独立算的数字逐个对上 ——
其中题单 15 那位 AC 了全部 12 题却一直显示未完成。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
This commit is contained in:
@@ -210,3 +210,88 @@ export async function badgeHolderDiff(badge: BadgeRow, known?: (BadgeCheck & { u
|
||||
held: have.size,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判题通过后,把这道题记进该用户所有「已加入且包含这道题」的题单。
|
||||
*
|
||||
* 以前这件事由前端做:SubmitCode.vue 看到 AC 就回调 PUT /problem-set-progress,而且只回调
|
||||
* 路由参数里那一个题单。于是从普通题库入口做出同一道题不计进度、网络一抖进度就静默丢失;
|
||||
* 旧栈为此专门有个管理命令 fix_problemset_progress 定期按实际提交补账,2026-05-22 那次
|
||||
* 批量补进度就是它跑的(而它不补奖章,53 条漏发里的 23 条由此而来)。
|
||||
*
|
||||
* 挪到判题这一路之后,记账和判题在同一个事务链里,前端只管显示。
|
||||
*
|
||||
* 不按 visible / status 过滤:进度是学生自己的记录,老师把题单藏起来不该让它停止累积。
|
||||
* 更要紧的是这条规则必须和补账那条(scripts/backfill-problemsets.ts)一致 ——
|
||||
* 两边口径不一样的话,补账工具会永远「发现」差异。
|
||||
*/
|
||||
export async function recordSolvedProblem(
|
||||
userId: number,
|
||||
problemId: number,
|
||||
submissionId: string,
|
||||
solvedAt: string,
|
||||
) {
|
||||
const joined = await db
|
||||
.select({ problemsetId: schema.problemsetProgress.problemsetId })
|
||||
.from(schema.problemsetProgress)
|
||||
.innerJoin(schema.problemsetProblem, and(
|
||||
eq(schema.problemsetProblem.problemsetId, schema.problemsetProgress.problemsetId),
|
||||
eq(schema.problemsetProblem.problemId, problemId),
|
||||
))
|
||||
.where(eq(schema.problemsetProgress.userId, userId))
|
||||
const earned: BadgeRow[] = []
|
||||
let updated = 0
|
||||
for (const { problemsetId } of joined) {
|
||||
const hits = await db.transaction(async (tx) => {
|
||||
const [progress] = await tx.select().from(schema.problemsetProgress).where(and(
|
||||
eq(schema.problemsetProgress.problemsetId, problemsetId),
|
||||
eq(schema.problemsetProgress.userId, userId),
|
||||
)).for("update").limit(1)
|
||||
if (!progress) return []
|
||||
|
||||
// 提交记录先补上,即使这道题早就记过 —— 老数据里有记了进度没记提交的行
|
||||
const [existing] = await tx.select({ id: schema.problemsetSubmission.id })
|
||||
.from(schema.problemsetSubmission).where(and(
|
||||
eq(schema.problemsetSubmission.problemsetId, problemsetId),
|
||||
eq(schema.problemsetSubmission.userId, userId),
|
||||
eq(schema.problemsetSubmission.problemId, problemId),
|
||||
)).limit(1)
|
||||
if (!existing) {
|
||||
await tx.insert(schema.problemsetSubmission)
|
||||
.values({ problemsetId, userId, submissionId, problemId })
|
||||
}
|
||||
|
||||
const detail = objectValue(progress.progressDetail)
|
||||
if (String(problemId) in detail) return []
|
||||
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, problemsetId))
|
||||
const link = links.find((item) => item.problemId === problemId)
|
||||
if (!link) return []
|
||||
detail[String(problemId)] = { score: link.score, submit_time: solvedAt }
|
||||
const update = computeProgress(detail, links, progress.completeTime)
|
||||
await tx.update(schema.problemsetProgress).set(update)
|
||||
.where(eq(schema.problemsetProgress.id, progress.id))
|
||||
updated += 1
|
||||
|
||||
const badges = await tx.select().from(schema.problemsetBadge)
|
||||
.where(eq(schema.problemsetBadge.problemsetId, problemsetId))
|
||||
const eligible = badges.filter((badge) => eligibleForBadge(badge, { ...progress, ...update }))
|
||||
if (eligible.length === 0) return []
|
||||
// 达标的奖章一次插完,冲突忽略后 returning 回来的就是这次真拿到的
|
||||
const inserted = await tx.insert(schema.userBadge).values(eligible.map((badge) => ({
|
||||
userId,
|
||||
badgeId: badge.id,
|
||||
earnedTime: new Date().toISOString(),
|
||||
}))).onConflictDoNothing({ target: [schema.userBadge.badgeId, schema.userBadge.userId] })
|
||||
.returning({ badgeId: schema.userBadge.badgeId })
|
||||
const ids = new Set(inserted.map((row) => row.badgeId))
|
||||
return eligible.filter((badge) => ids.has(badge.id))
|
||||
})
|
||||
earned.push(...hits)
|
||||
}
|
||||
return { updated, earned }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user