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:
@@ -6,7 +6,8 @@ import { and, eq, inArray } from "drizzle-orm"
|
|||||||
import { config } from "../config"
|
import { config } from "../config"
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
import { publishAchievementNotification } from "../events"
|
import { publishAchievementNotification } from "../events"
|
||||||
import { updateAchievementsForSubmission } from "../services/achievements"
|
import { updateAchievementsForProblemSet, updateAchievementsForSubmission } from "../services/achievements"
|
||||||
|
import { recordSolvedProblem } from "../services/problemset"
|
||||||
import { checkAst, type AstRule } from "./ast"
|
import { checkAst, type AstRule } from "./ast"
|
||||||
import { publishSubmissionUpdate } from "./events"
|
import { publishSubmissionUpdate } from "./events"
|
||||||
import type { JudgeJobData } from "./job"
|
import type { JudgeJobData } from "./job"
|
||||||
@@ -446,6 +447,45 @@ export async function judgeSubmission(job: JudgeJobData) {
|
|||||||
)
|
)
|
||||||
if (!saved) return
|
if (!saved) return
|
||||||
|
|
||||||
|
// 题单记账挪到判题这一路。以前靠前端 AC 之后回调 PUT /problem-set-progress,
|
||||||
|
// 只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖就静默丢失。
|
||||||
|
// 放在最后那条 publishSubmissionUpdate("finished") 之前 —— 前端收到「判完了」时
|
||||||
|
// 进度已经落库,跳回题单页看到的就是新数据。
|
||||||
|
// 比赛题不进题单(题单加题时卡了 contestId IS NULL),跳过。
|
||||||
|
if (row.submission.contestId === null && isAccepted(result)) {
|
||||||
|
try {
|
||||||
|
const { updated, earned } = await recordSolvedProblem(
|
||||||
|
row.submission.userId,
|
||||||
|
row.problem.id,
|
||||||
|
row.submission.id,
|
||||||
|
row.submission.createTime,
|
||||||
|
)
|
||||||
|
if (earned.length > 0) {
|
||||||
|
await publishAchievementNotification(row.submission.userId, earned.map((badge) => ({
|
||||||
|
id: badge.id,
|
||||||
|
name: badge.name,
|
||||||
|
description: badge.description,
|
||||||
|
icon: badge.icon,
|
||||||
|
rarity: "bronze",
|
||||||
|
kind: "badge",
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
if (updated > 0) {
|
||||||
|
const unlocked = await updateAchievementsForProblemSet(row.submission.userId)
|
||||||
|
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({
|
||||||
|
id: achievement.id,
|
||||||
|
name: achievement.name,
|
||||||
|
description: achievement.description,
|
||||||
|
icon: achievement.icon,
|
||||||
|
rarity: achievement.rarity,
|
||||||
|
kind: "achievement",
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to record problem set progress for ${row.submission.id}`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const unlocked = await updateAchievementsForSubmission(row.submission.id)
|
const unlocked = await updateAchievementsForSubmission(row.submission.id)
|
||||||
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({
|
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
import { eq } from "drizzle-orm"
|
import { and, eq, inArray, isNull, sql } from "drizzle-orm"
|
||||||
|
|
||||||
import { db, schema } from "../db"
|
import { db, schema } from "../db"
|
||||||
|
import { JudgeStatus } from "../judge/status"
|
||||||
import { objectValue } from "../routes/helpers"
|
import { objectValue } from "../routes/helpers"
|
||||||
import { badgeHolderDiff, computeProgress, recalculateBadge, resyncProgress } from "../services/problemset"
|
import { badgeHolderDiff, computeProgress, recalculateBadge, resyncProgress } from "../services/problemset"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把题单的进度和奖章订正到与当前规则一致。
|
* 把题单的进度和奖章订正到与当前规则一致。三笔历史欠账,一趟结清:
|
||||||
*
|
*
|
||||||
* 两笔历史欠账:
|
* 1. **进度漏记**。判题这一路记账(services/problemset.ts 的 recordSolvedProblem)是后来才有的,
|
||||||
|
* 在那之前靠前端 AC 之后回调,只认路由参数里那一个题单:从普通题库入口做出同一道题不计进度,
|
||||||
|
* 网络一抖就静默丢失。这里按实际 AC 记录补回来 —— 移植自旧栈的管理命令
|
||||||
|
* `problemset/management/commands/fix_problemset_progress.py`。
|
||||||
|
* 2. **奖章漏发**。奖章原本只在学生做出一道题那一刻发,进度从别的路径变了就没人回头判过达标。
|
||||||
|
* 生产快照里 53 条应发未发、涉及 30 名学生 —— 其中 23 条正是上面那个管理命令留下的:
|
||||||
|
* 它补进度,而旧栈的信号只挂在 ProblemSetProblem 和 ProblemSetBadge 上、不挂 Progress。
|
||||||
|
* 3. **算法改过**。分母只算必做题(选做不再卡完成)、空题单不再算完成、total_score 跟着分值走。
|
||||||
|
* 已有的行要跑一遍才会按新规则重算。
|
||||||
*
|
*
|
||||||
* 1. 奖章原本只在「学生做出一道题」那一刻发(`PUT /problem-set-progress`),进度从别的路径
|
* 三件事必须一趟做完,因为它们首尾相接:补进度 → 完成状态变 → 奖章达标面变。
|
||||||
* 变了 —— 后台加减题目、旧栈的 fix_problemset_progress 批量补进度 —— 就没人回头判过达标。
|
* 落库走 resyncProgress,它重算进度之后会顺手重算该题单的全部奖章。
|
||||||
* 生产快照里 53 条应发未发、涉及 30 名学生。
|
|
||||||
* 2. 进度的算法后来改了:分母只算必做题(选做题不再卡完成),空题单不再算完成,
|
|
||||||
* total_score 跟着题目分值走。已有的行要跑一遍才会按新规则重算。
|
|
||||||
*
|
|
||||||
* 两件事一起做,因为它们是同一笔账:进度一变,奖章达标面就跟着变,
|
|
||||||
* 所以落库走的是 resyncProgress —— 它重算进度之后会顺手重算这份题单的全部奖章。
|
|
||||||
*
|
*
|
||||||
* 默认只读,把差异打出来;确认无误再加 --apply 落库。
|
* 默认只读,把差异打出来;确认无误再加 --apply 落库。
|
||||||
* 只要预演里出现「收回」就先停下来让人看清楚,要真的收回得显式加 --allow-revoke ——
|
* 只要预演里出现「收回」就先停下来让人看清楚,要真的收回得显式加 --allow-revoke ——
|
||||||
@@ -27,9 +30,62 @@ import { badgeHolderDiff, computeProgress, recalculateBadge, resyncProgress } fr
|
|||||||
*
|
*
|
||||||
* 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
|
||||||
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-problemsets --apply
|
* docker compose -f docker/compose.debian.yml run --rm oj-api oj2-api backfill-problemsets --apply
|
||||||
*
|
|
||||||
* 本机开发:bun apps/api/src/main.ts backfill-problemsets
|
|
||||||
*/
|
*/
|
||||||
|
const ACCEPTED = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
|
||||||
|
|
||||||
|
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
|
||||||
|
|
||||||
|
async function loadSet(problemsetId: number) {
|
||||||
|
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, problemsetId)),
|
||||||
|
db.select().from(schema.problemsetProgress)
|
||||||
|
.where(eq(schema.problemsetProgress.problemsetId, problemsetId)),
|
||||||
|
db.select().from(schema.problemsetBadge)
|
||||||
|
.where(eq(schema.problemsetBadge.problemsetId, problemsetId)),
|
||||||
|
])
|
||||||
|
return { links, progresses, badges }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 找出「这个题单里的题,学生其实早就 AC 了,可进度里没记」的那些格子。
|
||||||
|
*
|
||||||
|
* 口径必须和 recordSolvedProblem 一模一样(非比赛提交、ACCEPTED 或 AST_CHECK_FAILED、
|
||||||
|
* 取最早那次),否则补账工具会永远「发现」差异。题单里的题必定是非比赛题,所以
|
||||||
|
* isNull(contestId) 实际上不会过滤掉任何东西,写上是为了两边字面一致。
|
||||||
|
*/
|
||||||
|
async function recoverable(links: ProblemLink[], progresses: (typeof schema.problemsetProgress.$inferSelect)[]) {
|
||||||
|
const gaps: { userId: number; problemId: number }[] = []
|
||||||
|
for (const progress of progresses) {
|
||||||
|
const detail = objectValue(progress.progressDetail)
|
||||||
|
for (const link of links) {
|
||||||
|
if (!(String(link.problemId) in detail)) gaps.push({ userId: progress.userId, problemId: link.problemId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (gaps.length === 0) return new Map<string, string>()
|
||||||
|
const rows = await db.select({
|
||||||
|
userId: schema.submission.userId,
|
||||||
|
problemId: schema.submission.problemId,
|
||||||
|
solvedAt: sql<string>`min(${schema.submission.createTime})::text`,
|
||||||
|
}).from(schema.submission).where(and(
|
||||||
|
inArray(schema.submission.userId, [...new Set(gaps.map((g) => g.userId))]),
|
||||||
|
inArray(schema.submission.problemId, [...new Set(gaps.map((g) => g.problemId))]),
|
||||||
|
isNull(schema.submission.contestId),
|
||||||
|
inArray(schema.submission.result, ACCEPTED),
|
||||||
|
)).groupBy(schema.submission.userId, schema.submission.problemId)
|
||||||
|
const solved = new Map(rows.map((row) => [`${row.userId}:${row.problemId}`, row.solvedAt]))
|
||||||
|
const found = new Map<string, string>()
|
||||||
|
for (const gap of gaps) {
|
||||||
|
const key = `${gap.userId}:${gap.problemId}`
|
||||||
|
const at = solved.get(key)
|
||||||
|
if (at) found.set(key, at)
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
export async function backfillProblemSets(options: { apply: boolean; allowRevoke: boolean }) {
|
export async function backfillProblemSets(options: { apply: boolean; allowRevoke: boolean }) {
|
||||||
const sets = await db.select({ id: schema.problemset.id, title: schema.problemset.title })
|
const sets = await db.select({ id: schema.problemset.id, title: schema.problemset.title })
|
||||||
.from(schema.problemset).orderBy(schema.problemset.id)
|
.from(schema.problemset).orderBy(schema.problemset.id)
|
||||||
@@ -41,23 +97,21 @@ export async function backfillProblemSets(options: { apply: boolean; allowRevoke
|
|||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const report = []
|
const report = []
|
||||||
for (const set of sets) {
|
for (const set of sets) {
|
||||||
const [links, progresses, badges] = await Promise.all([
|
const { links, progresses, badges } = await loadSet(set.id)
|
||||||
db.select({
|
const found = await recoverable(links, progresses)
|
||||||
problemId: schema.problemsetProblem.problemId,
|
const scoreByProblem = new Map(links.map((link) => [link.problemId, link.score]))
|
||||||
score: schema.problemsetProblem.score,
|
|
||||||
isRequired: schema.problemsetProblem.isRequired,
|
// 把补回来的格子先并进 detail,再按新规则重算 —— 奖章的差异要照着「补完账又重算过」
|
||||||
}).from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, set.id)),
|
// 的进度看,否则预演报出来的名单和 --apply 之后的结果对不上
|
||||||
db.select().from(schema.problemsetProgress)
|
const next = progresses.map((row) => {
|
||||||
.where(eq(schema.problemsetProgress.problemsetId, set.id)),
|
const detail = objectValue(row.progressDetail)
|
||||||
db.select().from(schema.problemsetBadge)
|
for (const link of links) {
|
||||||
.where(eq(schema.problemsetBadge.problemsetId, set.id)),
|
const at = found.get(`${row.userId}:${link.problemId}`)
|
||||||
])
|
if (at) detail[String(link.problemId)] = { score: scoreByProblem.get(link.problemId) ?? 0, submit_time: at }
|
||||||
// 按新规则重算一遍,但不落库 —— 奖章的差异要照着订正后的进度看,
|
}
|
||||||
// 否则预演里报出来的名单和 --apply 之后的结果对不上
|
return { ...row, ...computeProgress(detail, links, row.completeTime, now) }
|
||||||
const next = progresses.map((row) => ({
|
})
|
||||||
...row,
|
|
||||||
...computeProgress(objectValue(row.progressDetail), links, row.completeTime, now),
|
|
||||||
}))
|
|
||||||
const changed = next.filter((row, i) => {
|
const changed = next.filter((row, i) => {
|
||||||
const was = progresses[i]!
|
const was = progresses[i]!
|
||||||
return was.totalProblemsCount !== row.totalProblemsCount ||
|
return was.totalProblemsCount !== row.totalProblemsCount ||
|
||||||
@@ -72,9 +126,15 @@ export async function backfillProblemSets(options: { apply: boolean; allowRevoke
|
|||||||
const uncompleted = next.filter((row, i) => progresses[i]!.isCompleted && !row.isCompleted).length
|
const uncompleted = next.filter((row, i) => progresses[i]!.isCompleted && !row.isCompleted).length
|
||||||
const badgeDiffs = []
|
const badgeDiffs = []
|
||||||
for (const badge of badges) badgeDiffs.push({ badge, ...(await badgeHolderDiff(badge, next)) })
|
for (const badge of badges) badgeDiffs.push({ badge, ...(await badgeHolderDiff(badge, next)) })
|
||||||
report.push({ set, changed: changed.length, newlyCompleted, uncompleted, badgeDiffs })
|
report.push({
|
||||||
|
set, links, found, scoreByProblem,
|
||||||
|
changed: changed.length, newlyCompleted, uncompleted, badgeDiffs,
|
||||||
|
recovered: found.size,
|
||||||
|
recoveredUsers: new Set([...found.keys()].map((key) => key.split(":")[0]!)).size,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recovered = report.reduce((n, r) => n + r.recovered, 0)
|
||||||
const progressRows = report.reduce((n, r) => n + r.changed, 0)
|
const progressRows = report.reduce((n, r) => n + r.changed, 0)
|
||||||
const completedGain = report.reduce((n, r) => n + r.newlyCompleted, 0)
|
const completedGain = report.reduce((n, r) => n + r.newlyCompleted, 0)
|
||||||
const completedLoss = report.reduce((n, r) => n + r.uncompleted, 0)
|
const completedLoss = report.reduce((n, r) => n + r.uncompleted, 0)
|
||||||
@@ -84,6 +144,9 @@ export async function backfillProblemSets(options: { apply: boolean; allowRevoke
|
|||||||
console.log(`共 ${sets.length} 个题单\n`)
|
console.log(`共 ${sets.length} 个题单\n`)
|
||||||
for (const r of report) {
|
for (const r of report) {
|
||||||
const lines = []
|
const lines = []
|
||||||
|
if (r.recovered) {
|
||||||
|
lines.push(` 补录:${r.recovered} 道题已 AC 但进度里没记(${r.recoveredUsers} 名学生)`)
|
||||||
|
}
|
||||||
if (r.changed) {
|
if (r.changed) {
|
||||||
lines.push(` 进度:${r.changed} 条要重算` +
|
lines.push(` 进度:${r.changed} 条要重算` +
|
||||||
(r.newlyCompleted ? `,其中 ${r.newlyCompleted} 条未完成 → 已完成` : "") +
|
(r.newlyCompleted ? `,其中 ${r.newlyCompleted} 条未完成 → 已完成` : "") +
|
||||||
@@ -101,10 +164,10 @@ export async function backfillProblemSets(options: { apply: boolean; allowRevoke
|
|||||||
for (const line of lines) console.log(line)
|
for (const line of lines) console.log(line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`\n合计:进度 ${progressRows} 条要重算(完成 +${completedGain} / -${completedLoss}),` +
|
console.log(`\n合计:补录 ${recovered} 道题,进度 ${progressRows} 条要重算` +
|
||||||
`奖章补发 ${missing} 条、收回 ${extra} 条`)
|
`(完成 +${completedGain} / -${completedLoss}),奖章补发 ${missing} 条、收回 ${extra} 条`)
|
||||||
|
|
||||||
if (progressRows === 0 && missing === 0 && extra === 0) {
|
if (recovered === 0 && progressRows === 0 && missing === 0 && extra === 0) {
|
||||||
console.log("题单数据与当前规则一致,无需订正")
|
console.log("题单数据与当前规则一致,无需订正")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -120,8 +183,53 @@ export async function backfillProblemSets(options: { apply: boolean; allowRevoke
|
|||||||
|
|
||||||
let touched = 0
|
let touched = 0
|
||||||
for (const r of report) {
|
for (const r of report) {
|
||||||
if (!r.changed && !r.badgeDiffs.some((d) => d.missing.length || d.extra.length)) continue
|
const hasBadgeDrift = r.badgeDiffs.some((d) => d.missing.length || d.extra.length)
|
||||||
// resyncProgress 重算进度之后会把这份题单的奖章一并重算,两笔账一次结清
|
if (!r.recovered && !r.changed && !hasBadgeDrift) continue
|
||||||
|
// 补录的格子先写进 detail,resyncProgress 是照着库里的 detail 重算的
|
||||||
|
if (r.recovered) {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const rows = await tx.select().from(schema.problemsetProgress)
|
||||||
|
.where(eq(schema.problemsetProgress.problemsetId, r.set.id))
|
||||||
|
for (const row of rows) {
|
||||||
|
const detail = objectValue(row.progressDetail)
|
||||||
|
let dirty = false
|
||||||
|
for (const link of r.links) {
|
||||||
|
const at = r.found.get(`${row.userId}:${link.problemId}`)
|
||||||
|
if (!at || String(link.problemId) in detail) continue
|
||||||
|
detail[String(link.problemId)] = { score: r.scoreByProblem.get(link.problemId) ?? 0, submit_time: at }
|
||||||
|
dirty = true
|
||||||
|
const [existing] = await tx.select({ id: schema.problemsetSubmission.id })
|
||||||
|
.from(schema.problemsetSubmission).where(and(
|
||||||
|
eq(schema.problemsetSubmission.problemsetId, r.set.id),
|
||||||
|
eq(schema.problemsetSubmission.userId, row.userId),
|
||||||
|
eq(schema.problemsetSubmission.problemId, link.problemId),
|
||||||
|
)).limit(1)
|
||||||
|
if (!existing) {
|
||||||
|
const [submission] = await tx.select({ id: schema.submission.id }).from(schema.submission)
|
||||||
|
.where(and(
|
||||||
|
eq(schema.submission.userId, row.userId),
|
||||||
|
eq(schema.submission.problemId, link.problemId),
|
||||||
|
isNull(schema.submission.contestId),
|
||||||
|
inArray(schema.submission.result, ACCEPTED),
|
||||||
|
)).orderBy(schema.submission.createTime).limit(1)
|
||||||
|
if (submission) {
|
||||||
|
await tx.insert(schema.problemsetSubmission).values({
|
||||||
|
problemsetId: r.set.id,
|
||||||
|
userId: row.userId,
|
||||||
|
submissionId: submission.id,
|
||||||
|
problemId: link.problemId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (dirty) {
|
||||||
|
await tx.update(schema.problemsetProgress).set({ progressDetail: detail })
|
||||||
|
.where(eq(schema.problemsetProgress.id, row.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 重算进度,顺带重算这份题单的全部奖章
|
||||||
await resyncProgress(r.set.id)
|
await resyncProgress(r.set.id)
|
||||||
touched += 1
|
touched += 1
|
||||||
}
|
}
|
||||||
@@ -135,15 +243,12 @@ export async function backfillProblemSets(options: { apply: boolean; allowRevoke
|
|||||||
|
|
||||||
let remaining = 0
|
let remaining = 0
|
||||||
for (const set of sets) {
|
for (const set of sets) {
|
||||||
const [links, progresses, badges] = await Promise.all([
|
const { links, progresses, badges } = await loadSet(set.id)
|
||||||
db.select({
|
const found = await recoverable(links, progresses)
|
||||||
problemId: schema.problemsetProblem.problemId,
|
if (found.size) {
|
||||||
score: schema.problemsetProblem.score,
|
remaining += found.size
|
||||||
isRequired: schema.problemsetProblem.isRequired,
|
console.error(` 仍有可补录的进度:题单${set.id} ${found.size} 条`)
|
||||||
}).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) {
|
for (const row of progresses) {
|
||||||
const next = computeProgress(objectValue(row.progressDetail), links, row.completeTime, now)
|
const next = computeProgress(objectValue(row.progressDetail), links, row.completeTime, now)
|
||||||
if (row.isCompleted !== next.isCompleted || row.totalScore !== next.totalScore ||
|
if (row.isCompleted !== next.isCompleted || row.totalScore !== next.totalScore ||
|
||||||
|
|||||||
@@ -210,3 +210,88 @@ export async function badgeHolderDiff(badge: BadgeRow, known?: (BadgeCheck & { u
|
|||||||
held: have.size,
|
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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import { formatCode, submitCode, updateProblemSetProgress } from "oj/api"
|
import { formatCode, submitCode } from "oj/api"
|
||||||
import { useCodeStore } from "oj/store/code"
|
import { useCodeStore } from "oj/store/code"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
import { useFireworks } from "oj/problem/composables/useFireworks"
|
import { useFireworks } from "oj/problem/composables/useFireworks"
|
||||||
@@ -185,14 +185,10 @@ watch(
|
|||||||
// 1. 刷新题目状态
|
// 1. 刷新题目状态
|
||||||
problem.value!.myStatus = 0
|
problem.value!.myStatus = 0
|
||||||
|
|
||||||
// 2. 创建ProblemSetSubmission记录,更新题单进度
|
// 题单进度不在这里更新了。以前是 AC 之后回调 PUT /problem-set-progress,只认路由
|
||||||
if (problemSetId) {
|
// 参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖、页面提前关掉进度
|
||||||
await updateProblemSetProgress(
|
// 就静默丢失。现在判题那一路直接记账(judge/run.ts),而且是记进所有已加入且包含
|
||||||
Number(problemSetId),
|
// 这道题的题单;收到「判完了」的时候进度已经落库,跳回题单页看到的就是新数据。
|
||||||
problem.value!.id,
|
|
||||||
submission.value!.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result !== SubmissionStatus.accepted) return
|
if (result !== SubmissionStatus.accepted) return
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user