Compare commits
5 Commits
05cf8011e3
...
e5a6f2d1e7
| Author | SHA1 | Date | |
|---|---|---|---|
| e5a6f2d1e7 | |||
| 9d9e104df6 | |||
| 74b97c610f | |||
| d9e6a2a3f0 | |||
| d7a6414735 |
@@ -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",
|
||||
|
||||
@@ -6,7 +6,8 @@ import { and, eq, inArray } from "drizzle-orm"
|
||||
import { config } from "../config"
|
||||
import { db, schema } from "../db"
|
||||
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 { publishSubmissionUpdate } from "./events"
|
||||
import type { JudgeJobData } from "./job"
|
||||
@@ -446,6 +447,45 @@ export async function judgeSubmission(job: JudgeJobData) {
|
||||
)
|
||||
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 {
|
||||
const unlocked = await updateAchievementsForSubmission(row.submission.id)
|
||||
await publishAchievementNotification(row.submission.userId, unlocked.map((achievement) => ({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -249,7 +249,8 @@ adminProblemSetRoutes.post("/problem-sets/:id/problems", requireTeacher, async (
|
||||
hint: parsed.data.hint,
|
||||
}).returning({ id: schema.problemsetProblem.id })
|
||||
// 题目集变了,已加入的人的 totalProblemsCount / 百分比都得跟着变,
|
||||
// 否则学生看到的进度分母还是老的。旧后端没做这一步。
|
||||
// 否则学生看到的进度分母还是老的。旧栈是靠 ProblemSetProblem 的 post_save 信号做的,
|
||||
// 不在 views 里,别因为翻不到显式调用就以为它没做(见 services/problemset.ts)。
|
||||
await resyncProgress(row.id)
|
||||
return success(c, { id: created!.id }, 201)
|
||||
})
|
||||
@@ -274,8 +275,14 @@ adminProblemSetRoutes.delete("/problem-sets/:id/problems/:itemId", requireTeache
|
||||
const deleted = await db.delete(schema.problemsetProblem).where(and(
|
||||
eq(schema.problemsetProblem.id, queryInteger(c.req.param("itemId"), 0, { min: 1 })),
|
||||
eq(schema.problemsetProblem.problemsetId, row.id),
|
||||
)).returning({ id: schema.problemsetProblem.id })
|
||||
)).returning({ id: schema.problemsetProblem.id, problemId: schema.problemsetProblem.problemId })
|
||||
if (deleted.length === 0) return failure(c, 404, "problem-not-in-set", "题目不在该题单中")
|
||||
// 这道题在本题单里的提交记录也要清掉,对齐旧栈 problemset/signals.py 的 post_delete。
|
||||
// 不清的话 problemset_submission 会一直攒指向已移出题单的孤儿行。
|
||||
await db.delete(schema.problemsetSubmission).where(and(
|
||||
eq(schema.problemsetSubmission.problemsetId, row.id),
|
||||
eq(schema.problemsetSubmission.problemId, deleted[0]!.problemId),
|
||||
))
|
||||
await resyncProgress(row.id)
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
problemListItemSchema,
|
||||
problemSetBadgeSchema,
|
||||
problemSetListSchema,
|
||||
problemSetProblemSchema,
|
||||
@@ -32,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>()
|
||||
@@ -168,44 +167,37 @@ problemsetRoutes.get("/problem-sets/:id/problems", optionalAuth, async (c) => {
|
||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset)
|
||||
.where(and(eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"))).limit(1)
|
||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const rows = await db.select({ link: schema.problemsetProblem, problem: schema.problem, user: schema.user, realName: schema.userProfile.realName })
|
||||
.from(schema.problemsetProblem).innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||
.innerJoin(schema.user, eq(schema.problem.createdById, schema.user.id))
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(eq(schema.problemsetProblem.problemsetId, id)).orderBy(asc(schema.problemsetProblem.order))
|
||||
const problemIds = rows.map((row) => row.problem.id)
|
||||
const [tagRows, progressRows] = await Promise.all([
|
||||
problemIds.length ? db.select({ problemId: schema.problemTags.problemId, name: schema.problemTag.name }).from(schema.problemTags)
|
||||
.innerJoin(schema.problemTag, eq(schema.problemTags.problemtagId, schema.problemTag.id)).where(inArray(schema.problemTags.problemId, problemIds)) : Promise.resolve([]),
|
||||
c.get("user") ? db.select({ detail: schema.problemsetProgress.progressDetail }).from(schema.problemsetProgress)
|
||||
.where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, c.get("user")!.id))).limit(1) : Promise.resolve([]),
|
||||
])
|
||||
const tags = new Map<number, string[]>()
|
||||
for (const tag of tagRows) tags.set(tag.problemId, [...(tags.get(tag.problemId) ?? []), tag.name])
|
||||
// 只取卡片要渲染的四列。取 schema.problem 整行会把题面、样例、答案、ast_rules、
|
||||
// flowchart_data、sql_display 一起拉回来,题单页一个都不用。
|
||||
//
|
||||
// order 后面必须再跟一个 tiebreaker:并列时 Postgres 不保证次序,而卡片是按数组
|
||||
// 下标编号的(#1 #2 #3),题单 8 / 11 / 14 实际就存在 order 重复,不定死的话
|
||||
// 「第 3 题」指哪道题每次刷新都可能不一样。后台那条列表一直是这么排的。
|
||||
const rows = await db.select({
|
||||
link: schema.problemsetProblem,
|
||||
problemId: schema.problem.id,
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
difficulty: schema.problem.difficulty,
|
||||
})
|
||||
.from(schema.problemsetProblem)
|
||||
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||
.where(eq(schema.problemsetProblem.problemsetId, id))
|
||||
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id))
|
||||
const progressRows = c.get("user")
|
||||
? await db.select({ detail: schema.problemsetProgress.progressDetail }).from(schema.problemsetProgress)
|
||||
.where(and(eq(schema.problemsetProgress.problemsetId, id), eq(schema.problemsetProgress.userId, c.get("user")!.id))).limit(1)
|
||||
: []
|
||||
const completed = objectValue(progressRows[0]?.detail)
|
||||
return success(c, rows.map(({ link, problem, user, realName }) => problemSetProblemSchema.parse({
|
||||
return success(c, rows.map(({ link, problemId, displayId, title, difficulty }) => problemSetProblemSchema.parse({
|
||||
id: link.id,
|
||||
problemsetId: link.problemsetId,
|
||||
problem: problemListItemSchema.parse({
|
||||
id: problem.id,
|
||||
_id: problem.displayId,
|
||||
title: problem.title,
|
||||
submissionNumber: problem.submissionNumber,
|
||||
acceptedNumber: problem.acceptedNumber,
|
||||
difficulty: problem.difficulty,
|
||||
createdBy: sampleUser(user, realName),
|
||||
tags: tags.get(problem.id) ?? [],
|
||||
contestId: problem.contestId,
|
||||
allowFlowchart: problem.allowFlowchart,
|
||||
showFlowchart: problem.showFlowchart,
|
||||
hasAstRules: problem.astRules !== null,
|
||||
myStatus: null,
|
||||
}),
|
||||
problem: { id: problemId, _id: displayId, title, difficulty },
|
||||
order: link.order,
|
||||
isRequired: link.isRequired,
|
||||
score: link.score,
|
||||
hint: link.hint,
|
||||
isCompleted: String(problem.id) in completed,
|
||||
isCompleted: String(problemId) in completed,
|
||||
})))
|
||||
})
|
||||
|
||||
@@ -214,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)
|
||||
@@ -291,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) => ({
|
||||
@@ -372,10 +365,17 @@ problemsetRoutes.get("/problem-sets/:id/badges", async (c) => {
|
||||
|
||||
problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c) => {
|
||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||
const [problemSet] = await db.select({ id: schema.problemset.id }).from(schema.problemset).where(and(
|
||||
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
|
||||
)).limit(1)
|
||||
if (!problemSet) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
const [problemSet] = await db.select({ id: schema.problemset.id, createdById: schema.problemset.createdById })
|
||||
.from(schema.problemset).where(and(
|
||||
eq(schema.problemset.id, id), eq(schema.problemset.visible, true), ne(schema.problemset.status, "draft"),
|
||||
)).limit(1)
|
||||
// 归属校验,和后台那条同类接口(admin/problemset.ts 的 loadOwned)一致:超管放行,
|
||||
// 其余老师只能看自己建的题单。少了这一道,任何 Teacher Admin 都能读到别人班的名单。
|
||||
// 越权报「不存在」,不泄露题单存在与否。
|
||||
const user = c.get("user")!
|
||||
if (!problemSet || (user.adminType !== "Super Admin" && problemSet.createdById !== user.id)) {
|
||||
return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
}
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
const className = c.req.query("className")?.trim()
|
||||
@@ -395,7 +395,8 @@ problemsetRoutes.get("/problem-sets/:id/user-progress", requireTeacher, async (c
|
||||
.orderBy(desc(schema.problemsetProgress.isCompleted), desc(schema.problemsetProgress.progressPercentage), asc(schema.problemsetProgress.joinTime)).limit(limit).offset(offset),
|
||||
db.select({ id: schema.problem.id, _id: schema.problem.displayId, title: schema.problem.title }).from(schema.problemsetProblem)
|
||||
.innerJoin(schema.problem, eq(schema.problemsetProblem.problemId, schema.problem.id))
|
||||
.where(eq(schema.problemsetProblem.problemsetId, id)).orderBy(asc(schema.problemsetProblem.order)),
|
||||
.where(eq(schema.problemsetProblem.problemsetId, id))
|
||||
.orderBy(asc(schema.problemsetProblem.order), asc(schema.problemsetProblem.id)),
|
||||
])
|
||||
const problemMap = new Map(problemRows.map((problem) => [String(problem.id), problem]))
|
||||
const results = rows.map(({ progress, user: progressUser, realName }) => problemSetProgressSchema.parse({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
271
apps/api/src/scripts/backfill-problemsets.ts
Normal file
271
apps/api/src/scripts/backfill-problemsets.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { and, eq, inArray, isNull, sql } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
import { JudgeStatus } from "../judge/status"
|
||||
import { objectValue } from "../routes/helpers"
|
||||
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 跟着分值走。
|
||||
* 已有的行要跑一遍才会按新规则重算。
|
||||
*
|
||||
* 三件事必须一趟做完,因为它们首尾相接:补进度 → 完成状态变 → 奖章达标面变。
|
||||
* 落库走 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
|
||||
*/
|
||||
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 }) {
|
||||
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 loadSet(set.id)
|
||||
const found = await recoverable(links, progresses)
|
||||
const scoreByProblem = new Map(links.map((link) => [link.problemId, link.score]))
|
||||
|
||||
// 把补回来的格子先并进 detail,再按新规则重算 —— 奖章的差异要照着「补完账又重算过」
|
||||
// 的进度看,否则预演报出来的名单和 --apply 之后的结果对不上
|
||||
const next = progresses.map((row) => {
|
||||
const detail = objectValue(row.progressDetail)
|
||||
for (const link of links) {
|
||||
const at = found.get(`${row.userId}:${link.problemId}`)
|
||||
if (at) detail[String(link.problemId)] = { score: scoreByProblem.get(link.problemId) ?? 0, submit_time: at }
|
||||
}
|
||||
return { ...row, ...computeProgress(detail, 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, 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 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.recovered) {
|
||||
lines.push(` 补录:${r.recovered} 道题已 AC 但进度里没记(${r.recoveredUsers} 名学生)`)
|
||||
}
|
||||
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合计:补录 ${recovered} 道题,进度 ${progressRows} 条要重算` +
|
||||
`(完成 +${completedGain} / -${completedLoss}),奖章补发 ${missing} 条、收回 ${extra} 条`)
|
||||
|
||||
if (recovered === 0 && 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) {
|
||||
const hasBadgeDrift = r.badgeDiffs.some((d) => d.missing.length || d.extra.length)
|
||||
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)
|
||||
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 loadSet(set.id)
|
||||
const found = await recoverable(links, progresses)
|
||||
if (found.size) {
|
||||
remaining += found.size
|
||||
console.error(` 仍有可补录的进度:题单${set.id} ${found.size} 条`)
|
||||
}
|
||||
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
|
||||
@@ -47,9 +57,13 @@ export function computeProgress(
|
||||
// 乘 10000 四舍五入再除 100,保留两位小数
|
||||
progressPercentage: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
|
||||
isCompleted,
|
||||
// 完成状态没了,complete_time 也不该留着。学生那一路本来就是这么写的,
|
||||
// 后台这一路以前保留旧时间,于是同一行会出现「未完成 + 有完成时间」。
|
||||
completeTime: isCompleted ? previousCompleteTime ?? now : null,
|
||||
// 只设不清,语义是「曾经完成于」,对齐旧栈 problemset/models.py:218。
|
||||
//
|
||||
// 「未完成 + 有完成时间」是允许的组合,快照里就有 4 条 —— 题单 8 那批人在它
|
||||
// 还只有 6 题时完成过,老师后来加到 12 题,进度退回未完成,完成时间留了下来。
|
||||
// 反过来清空的代价是不可逆:往一个 100 人已完成的题单里加一道题、再改主意删掉,
|
||||
// 这 100 个人的历史完成时间就一起被冲成了「现在」。
|
||||
completeTime: previousCompleteTime ?? (isCompleted ? now : null),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,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
|
||||
}
|
||||
@@ -139,14 +163,22 @@ export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & {
|
||||
/**
|
||||
* 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这份题单的奖章。
|
||||
*
|
||||
* 旧后端两件事都不做:往题单里加一道题,学生那边的 totalProblemsCount 还是老数字,
|
||||
* 进度百分比因此偏高;奖章那边更是没人回头判过,生产快照里因此攒下 53 条应发未发
|
||||
* (30 名学生,其中 23 条来自 2026-05-22 那次批量补进度)。
|
||||
* 别信「旧后端不做这件事」那个说法(本仓早先的注释里有,是错的):旧栈用 signals 做了,
|
||||
* 而且两件事都做 —— problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete
|
||||
* 上重算全部参与者的进度、再重算该题单全部奖章的资格。重写时 views 里看不到显式调用,
|
||||
* 就当成没做,于是奖章那一半漏了,生产快照里攒下 53 条应发未发(30 名学生)。
|
||||
*
|
||||
* 那 53 条里有 23 条另有出处:旧栈的管理命令 fix_problemset_progress 按实际 AC 记录补
|
||||
* progress_detail,可 signals 只挂在 ProblemSetProblem 和 ProblemSetBadge 上、不挂 Progress,
|
||||
* 所以进度补了、奖章一枚没补。OJ2 这边目前也还没有补进度的对应工具。
|
||||
*/
|
||||
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)
|
||||
@@ -162,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)),
|
||||
@@ -178,3 +210,88 @@ export async function badgeHolderDiff(badge: BadgeRow) {
|
||||
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 }
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ const columns: DataTableColumn<SubmissionListItem>[] = [
|
||||
h(Icon, { icon: "catppuccin:lock" }),
|
||||
),
|
||||
default: () =>
|
||||
"这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。",
|
||||
"这道题在你已经加入的题单里,加入之前的提交先藏起来了。在题单中做出此题即可解锁;题单过了截止时间也会解锁。",
|
||||
},
|
||||
),
|
||||
])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { storeToRefs } from "pinia"
|
||||
import { formatCode, submitCode, updateProblemSetProgress } from "oj/api"
|
||||
import { formatCode, submitCode } from "oj/api"
|
||||
import { useCodeStore } from "oj/store/code"
|
||||
import { useProblemStore } from "oj/store/problem"
|
||||
import { useFireworks } from "oj/problem/composables/useFireworks"
|
||||
@@ -185,14 +185,10 @@ watch(
|
||||
// 1. 刷新题目状态
|
||||
problem.value!.myStatus = 0
|
||||
|
||||
// 2. 创建ProblemSetSubmission记录,更新题单进度
|
||||
if (problemSetId) {
|
||||
await updateProblemSetProgress(
|
||||
Number(problemSetId),
|
||||
problem.value!.id,
|
||||
submission.value!.id,
|
||||
)
|
||||
}
|
||||
// 题单进度不在这里更新了。以前是 AC 之后回调 PUT /problem-set-progress,只认路由
|
||||
// 参数里那一个题单:从普通题库入口做出同一道题不计进度,网络一抖、页面提前关掉进度
|
||||
// 就静默丢失。现在判题那一路直接记账(judge/run.ts),而且是记进所有已加入且包含
|
||||
// 这道题的题单;收到「判完了」的时候进度已经落库,跳回题单页看到的就是新数据。
|
||||
|
||||
if (result !== SubmissionStatus.accepted) return
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from "@iconify/vue"
|
||||
import type { ProblemSet, UserBadge as UserBadgeType } from "utils/types"
|
||||
import { parseTime } from "utils/functions"
|
||||
import UserBadge from "shared/components/UserBadge.vue"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
|
||||
@@ -32,14 +33,22 @@ function getDifficultyTag(difficulty: string) {
|
||||
return difficultyMap[difficulty] || { type: "default", text: "未知" }
|
||||
}
|
||||
|
||||
// 进度一律读 userProgress:它的分母是**必做题数**,而 problemsCount 是总题数。
|
||||
// 拿总题数当分母的话,做完全部必做题的人会看到「9 / 10、90%」,而同一张卡片上
|
||||
// 又标着「已完成」—— 题单 6 那 10 个人正是这种。
|
||||
function getProgressPercentage() {
|
||||
if (!props.problemSet) return 0
|
||||
return Math.round(
|
||||
((props.problemSet.completedCount ?? 0) / props.problemSet.problemsCount) *
|
||||
100,
|
||||
)
|
||||
return Math.round(props.problemSet?.userProgress?.progressPercentage ?? 0)
|
||||
}
|
||||
|
||||
// 有选做题时把「必做 N 题」标出来,否则「共 10 道题目」和「9 / 9」对不上
|
||||
const optionalCount = computed(
|
||||
() => props.problemSet.problemsCount - (props.problemSet.userProgress?.totalCount ?? 0),
|
||||
)
|
||||
|
||||
const endTimeText = computed(() =>
|
||||
props.problemSet.endTime ? parseTime(props.problemSet.endTime, "YYYY-MM-DD HH:mm") : "",
|
||||
)
|
||||
|
||||
function handleJoin() {
|
||||
emit("join")
|
||||
}
|
||||
@@ -55,6 +64,14 @@ function handleJoin() {
|
||||
<n-tag :type="getDifficultyTag(problemSet.difficulty).type">
|
||||
{{ getDifficultyTag(problemSet.difficulty).text }}
|
||||
</n-tag>
|
||||
<!-- 截止时间不是「到点不能做了」,是「到点之前看不到自己加入题单之前的旧代码」,
|
||||
所以这里要连着解释一句,否则学生只看到一个日期,不知道它管什么 -->
|
||||
<n-tooltip trigger="hover" v-if="endTimeText">
|
||||
<template #trigger>
|
||||
<n-tag type="info">截止 {{ endTimeText }}</n-tag>
|
||||
</template>
|
||||
这个时间之前,你在加入题单之前提交过的代码是看不到的;在题单里做出该题即可解锁。
|
||||
</n-tooltip>
|
||||
<n-h2 style="margin: 0">{{ problemSet.title }}</n-h2>
|
||||
<n-tooltip trigger="hover" v-if="problemSet.description">
|
||||
<template #trigger>
|
||||
@@ -79,7 +96,11 @@ function handleJoin() {
|
||||
<n-flex align="center" v-if="isJoined">
|
||||
<n-text strong>完成进度</n-text>
|
||||
<n-text>
|
||||
{{ problemSet.completedCount }} / {{ problemSet.problemsCount }}
|
||||
{{ problemSet.userProgress?.completedCount ?? 0 }} /
|
||||
{{ problemSet.userProgress?.totalCount ?? 0 }}
|
||||
</n-text>
|
||||
<n-text depth="3" v-if="optionalCount > 0">
|
||||
(另有 {{ optionalCount }} 道选做)
|
||||
</n-text>
|
||||
</n-flex>
|
||||
<n-progress
|
||||
|
||||
@@ -9,6 +9,7 @@ import { usePagination } from "shared/composables/pagination"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
|
||||
const route = useRoute()
|
||||
const message = useMessage()
|
||||
const problemSetId = computed(() => Number(route.params.problemSetId))
|
||||
const progress = ref<ProblemSetProgress[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -54,19 +55,26 @@ async function loadUserProgress() {
|
||||
if (completionFilter.value) {
|
||||
params.completionStatus = completionFilter.value
|
||||
}
|
||||
const res = await getProblemSetUserProgress(problemSetId.value, params)
|
||||
try {
|
||||
const res = await getProblemSetUserProgress(problemSetId.value, params)
|
||||
|
||||
progress.value = res.results
|
||||
total.value = res.total
|
||||
// 使用后端返回的统计数据(基于所有数据)
|
||||
if (res.statistics) {
|
||||
statistics.value = res.statistics
|
||||
progress.value = res.results
|
||||
total.value = res.total
|
||||
// 使用后端返回的统计数据(基于所有数据)
|
||||
if (res.statistics) {
|
||||
statistics.value = res.statistics
|
||||
}
|
||||
// 保存所有题目信息
|
||||
if (res.problems) {
|
||||
allProblems.value = res.problems
|
||||
}
|
||||
} catch (err: any) {
|
||||
// finally 里收掉 loading:以前 loading.value = false 写在 await 之后,
|
||||
// 请求一失败(403、断网)转圈就永远停不下来
|
||||
message.error("加载用户进度失败:" + (err.data || "未知错误"))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
// 保存所有题目信息
|
||||
if (res.problems) {
|
||||
allProblems.value = res.problems
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
// 监听分页参数变化
|
||||
|
||||
@@ -97,11 +97,10 @@ async function handleJoinProblemSet() {
|
||||
}
|
||||
}
|
||||
|
||||
const showTabs = computed(
|
||||
() =>
|
||||
userStore.isSuperAdmin ||
|
||||
(isJoined.value && problemSet.value?.userProgress?.isCompleted),
|
||||
)
|
||||
// 「用户进度」那一栏调的是 requireTeacher 的接口(Teacher Admin | Super Admin),
|
||||
// 所以这里的条件必须跟它一致。以前写的是「超管 或 自己完成了题单」,两边正好错开:
|
||||
// 学生做完题单会看到这一栏、点进去 403;而真正该用它的 Teacher Admin 反倒看不到。
|
||||
const showTabs = computed(() => userStore.isTeacherOrAbove)
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
|
||||
@@ -16,45 +16,22 @@ const problemSets = ref<ProblemSet[]>([])
|
||||
|
||||
interface ProblemSetQuery {
|
||||
keyword: string
|
||||
difficulty: string
|
||||
status: string
|
||||
}
|
||||
|
||||
// 使用分页 composable
|
||||
const { query, clearQuery } = usePagination<ProblemSetQuery>(
|
||||
{
|
||||
keyword: useRouteQuery("keyword", "").value,
|
||||
difficulty: useRouteQuery("difficulty", "").value,
|
||||
status: useRouteQuery("status", "").value,
|
||||
},
|
||||
{
|
||||
defaultLimit: 30,
|
||||
},
|
||||
)
|
||||
|
||||
const difficultyOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "简单", value: "Easy" },
|
||||
{ label: "中等", value: "Medium" },
|
||||
{ label: "困难", value: "Hard" },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "活跃", value: "active" },
|
||||
{ label: "已归档", value: "archived" },
|
||||
]
|
||||
|
||||
async function listProblemSets() {
|
||||
if (query.page < 1) query.page = 1
|
||||
const offset = (query.page - 1) * query.limit
|
||||
const res = await getProblemSetList(
|
||||
offset,
|
||||
query.limit,
|
||||
query.keyword,
|
||||
query.difficulty,
|
||||
query.status,
|
||||
)
|
||||
const res = await getProblemSetList(offset, query.limit, query.keyword)
|
||||
total.value = res.total
|
||||
problemSets.value = res.results
|
||||
}
|
||||
@@ -102,35 +79,15 @@ watchDebounced(() => query.keyword, listProblemSets, {
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [query.page, query.limit, query.difficulty, query.status],
|
||||
listProblemSets,
|
||||
)
|
||||
watch(() => [query.page, query.limit], listProblemSets)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical size="large">
|
||||
<!-- 难度和状态两个筛选器撤了:线上 16 个题单全是 Easy / active,选「中等」「困难」
|
||||
「已归档」永远是空列表。接口那两个 query 参数还在,哪天真的用起这两个字段,
|
||||
把 select 加回来即可。 -->
|
||||
<n-space>
|
||||
<n-space align="center">
|
||||
<n-text>难度</n-text>
|
||||
<n-select
|
||||
v-model:value="query.difficulty"
|
||||
:options="difficultyOptions"
|
||||
placeholder="选择难度"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
/>
|
||||
</n-space>
|
||||
<n-space align="center">
|
||||
<n-text>状态</n-text>
|
||||
<n-select
|
||||
v-model:value="query.status"
|
||||
:options="statusOptions"
|
||||
placeholder="选择状态"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
/>
|
||||
</n-space>
|
||||
<n-input
|
||||
v-model:value="query.keyword"
|
||||
placeholder="搜索题单..."
|
||||
|
||||
@@ -446,7 +446,17 @@ POST /api/admin/problems/batch-tag {problemIds:[40]} → 404 no-problems
|
||||
- `DELETE /problem-tags/:id`(`tag.ts:92-102`)的事务里先删 `problemTags` 再删 `problemTag`,但删的是同一个 id,标签不存在时前一句是空操作,**不存在 C1 那种连带破坏**
|
||||
- `DELETE /users`(`account.ts:237-257`)禁止删除自己,已实跑确认返回 400 `cannot-delete-self`
|
||||
- `PUT /users/:id` 的 `normalizePermission`(`account.ts:49-53`)与 `account/views/admin.py:98-105` 的归一逻辑一致,降级超管会同步清掉 All
|
||||
- 真名下发受控:`sampleUser`(`routes/helpers.ts:15-25`)默认 `realName: null`,只有 `acm-helper`(`contest.ts:272`)和题单进度(`problemset.ts:426`)两处显式下发,两处都在 requireTeacher + 归属校验之后
|
||||
- 真名下发受控:`sampleUser`(`routes/helpers.ts:15-25`)默认 `realName: null`,只有 `acm-helper`(`contest.ts:272`)显式下发,在 requireTeacher + 归属校验之后
|
||||
|
||||
> **2026-08-31 订正**:本条原先还写了「题单进度(`problemset.ts:426`)」,与代码不符 ——
|
||||
> 学生端那条 `GET /problem-sets/:id/user-progress` 走的是 `sampleUser(progressUser, realName)`,
|
||||
> 没传 `includeRealName`,`realName` 恒为 `null`(SQL 里那次 `leftJoin userProfile` 是白查的)。
|
||||
> 真正显式下发真名的是**后台**那条 `GET /admin/problem-sets/:id/progress`,它手写
|
||||
> `adminProblemSetProgressSchema`,在 requireTeacher + `loadOwned` 归属校验之后,结论仍成立。
|
||||
>
|
||||
> 另外当时漏了一条:学生端那条 `user-progress` 只有 `requireTeacher`、**没有归属校验**,
|
||||
> 任何 Teacher Admin 都能读到别人建的题单的学生名单与进度(不含真名)。已补上归属校验,
|
||||
> 口径与后台的 `loadOwned` 一致,越权报 404。
|
||||
- `GET /ai/reports/:id`(`ai.ts:70-83`)不下发 `data` / `systemPrompt` / `userPrompt`
|
||||
- `GET /judge-servers`(`conf.ts:90-100`)下发 judge token,但在 requireSuperAdmin 之后,与旧 `conf/views.py:66-74` 一致
|
||||
- `DELETE /orphan-test-cases`(`conf.ts:147-160`)对指定 id 也先确认是孤儿,比旧 `conf/views.py:162-171` 严 —— 合理收紧
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { paginatedSchema, sampleUserSchema } from "./common"
|
||||
import { problemListItemSchema } from "./problem"
|
||||
import { maskedProblemDifficultySchema } from "./problem"
|
||||
|
||||
export const problemSetUserProgressSummarySchema = z.object({
|
||||
isJoined: z.boolean(),
|
||||
@@ -41,10 +41,25 @@ export const problemSetSchema = z.object({
|
||||
|
||||
export const problemSetListSchema = paginatedSchema(problemSetSchema)
|
||||
|
||||
/**
|
||||
* 题单页的题目卡片只渲染题号、标题、难度,所以只下发这三样(外加题目主键)。
|
||||
*
|
||||
* 以前这里复用 problemListItemSchema,代价是服务端每次都要多 join 一次 user +
|
||||
* user_profile 凑 createdBy、再多查一次标签表凑 tags,而 tags / submissionNumber /
|
||||
* acceptedNumber / createdBy / flowchart / hasAstRules / myStatus 在题单页一个都不渲染
|
||||
* —— myStatus 甚至是写死的 null。
|
||||
*/
|
||||
export const problemSetProblemItemSchema = z.object({
|
||||
id: z.number().int(),
|
||||
_id: z.string(),
|
||||
title: z.string(),
|
||||
difficulty: maskedProblemDifficultySchema,
|
||||
})
|
||||
|
||||
export const problemSetProblemSchema = z.object({
|
||||
id: z.number().int(),
|
||||
problemsetId: z.number().int(),
|
||||
problem: problemListItemSchema,
|
||||
problem: problemSetProblemItemSchema,
|
||||
order: z.number().int(),
|
||||
isRequired: z.boolean(),
|
||||
score: z.number().int(),
|
||||
|
||||
Reference in New Issue
Block a user