Compare commits

...

5 Commits

Author SHA1 Message Date
e5a6f2d1e7 fix(题单): 归属校验、进度分母、截止时间可见性等六处零碎
Some checks failed
Deploy / deploy (push) Has been cancelled
## user-progress 缺归属校验

学生端那条 GET /problem-sets/:id/user-progress 只有 requireTeacher,没有归属校验,
任何 Teacher Admin 都能读到别人建的题单的学生名单与进度。补上,口径与后台
loadOwned 一致(超管放行,其余人只能看自己建的),越权报 404。

顺带订正 docs/specs/phase4-review-authz.md:449:那条写着题单进度「显式下发真名」,
与代码不符 —— 学生端这条走 sampleUser 且没传 includeRealName,realName 恒为 null
(SQL 里那次 leftJoin userProfile 是白查的)。真正下发真名的是后台那条
GET /admin/problem-sets/:id/progress,结论仍成立,但当时漏掉了归属校验这个缺口。

## 头部进度的分母

分母只算必做题之后,ProblemSetHeader 还在拿 completedCount / problemsCount 算 ——
前者是必做完成数,后者是总题数。做完全部必做题的人会看到「9 / 10、90%」,而同一张
卡片上又标着「已完成」,题单 6 那 10 个人正是这种。改成读 userProgress,另外把
「另有 N 道选做」标出来,否则「共 10 道题目」和「9 / 9」对不上。

## 截止时间

end_time 管的不是「到点不能做了」,是「到点之前看不到自己加入题单之前的旧代码」,
而学生端一个字都不显示 —— 被挡住的人不知道为什么,也不知道什么时候解锁。头部加一个
带解释的「截止 …」标签;提交列表那个锁图标的说明也补上另外两条解锁路径。

## 两个必然筛空的筛选器

学生端题单列表的难度、状态两个下拉:线上 16 个题单全是 Easy / active,选「中等」
「困难」「已归档」永远是空列表。撤掉,保留关键词搜索。接口那两个 query 参数留着,
哪天真的用起这两个字段再把 select 加回来。

## 题目移出题单时的提交记录

旧栈 problemset/signals.py 的 post_delete 会清掉该题在本题单的 ProblemSetSubmission,
OJ2 没做,于是那张表一直在攒指向已移出题单的孤儿行。补上。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-09-01 00:01:58 -06:00
9d9e104df6 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
2026-09-01 00:01:38 -06:00
74b97c610f 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
2026-08-31 08:55:36 -06:00
d9e6a2a3f0 perf(题单): 学生端题目列表少两次 join 一次查询,顺带把并列的 order 排稳
## 排序

学生端 /problem-sets/:id/problems 只按 order 排,没有 tiebreaker,而卡片是按数组
下标编号的(#1 #2 #3)。order 并列时 Postgres 不保证次序,题单 8(3 道题 order 都是
10)、题单 11(2 道并列 4)、题单 14(3 道并列 0)实际就有并列,于是「第 3 题」指
哪道题每次刷新都可能变。后台那条列表一直是 order + id 排的,两边本来就不一致。

补上 asc(id)。教师进度视图里那份题目清单(同一路由文件 :398)有同样的问题,一并补。

实跑:四道题、三道 order 并列,连打 5 次次序完全一致。

## 载荷

这个接口原来复用 problemListItemSchema,为此要多 join user + user_profile 凑
createdBy、再多查一次标签表凑 tags —— 而题单卡片只渲染题号、标题、难度、分数和
完成标记。tags / submissionNumber / acceptedNumber / createdBy / contestId /
allowFlowchart / showFlowchart / hasAstRules 一个都不用,myStatus 甚至写死是 null。

而且 select 的是 schema.problem 整行,题面、样例、答案、ast_rules、flowchart_data、
sql_display 全都白拉回来一遍。

改成 problemSetProblemItemSchema(id / _id / title / difficulty 四个字段),查询相应
收窄:两次 join 去掉,标签那次查询整条去掉,行查询只取四列。每次请求从 4 条查询降到
3 条,其中最大的那条不再拖整行题面。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-08-31 08:47:57 -06:00
d7a6414735 fix(题单): complete_time 翻回「只设不清」;完成题单后那个必然报错的 tab
三件事,都是上一轮结论没查扎实留下的。

## complete_time

f9354b0 把「退回未完成时清空 complete_time」定成两边统一的行为,理由写的是
「快照里 complete_time 非空 ⟺ is_completed」。那个检查只做了一个方向(已完成但
没有完成时间 = 0 条),反方向没查 —— 实际有 4 条 is_completed=false 却留着
complete_time,全在题单 8:那批人 2025-11-14 在它还只有 6 题时完成过,老师后来
加到 12 题,进度退回未完成,完成时间保留了下来。

旧栈是故意不清的(problemset/models.py:218 只有 `if is_completed and not
complete_time` 这一条赋值),语义是「曾经完成于」。清空是重写时在学生路径上引入的,
上一版又把它推广到了后台路径。

翻回只设不清。「未完成 + 有完成时间」是允许的组合。反过来清空的代价不可逆:往一个
100 人已完成的题单里加一道题、再改主意删掉,这 100 个人的历史完成时间就一起被冲成
「现在」—— 上一轮的实跑已经在题单 9 上复现过。

## 两处注释订正

「旧后端不做加题后的重算」这个说法是错的,本仓早先的注释里有,上一版我照搬进了
services/problemset.ts 和提交信息。旧栈用 signals 做了,而且两件事都做:
problemset/signals.py 在 ProblemSetProblem 的 post_save / post_delete 上重算全部
参与者进度、再重算该题单全部奖章。重写时 views 里翻不到显式调用就当成没做,于是
奖章那一半漏了 —— 53 条应发未发正是这么来的。

另外补上 53 条里那 23 条的出处:旧栈的管理命令 fix_problemset_progress 按实际 AC
补 progress_detail,而 signals 不挂在 Progress 上,所以进度补了、奖章没补。

## 用户进度 tab

detail.vue 的 showTabs 写的是「超管 或 自己完成了题单」,而它渲染的
UserProgressView 调的是 requireTeacher 的接口。两边正好错开:

- 学生做完题单 → tab 出现 → 点进去 403(实跑确认:已完成该题单的 student 拿到
  403 permission-denied,devadmin 拿到 200)。而 loadUserProgress 没有 try/catch、
  loading.value = false 又写在 await 之后,转圈永远停不下来。
- Teacher Admin 看不到这一栏,尽管他们才是它的目标用户、也是唯一调得动的角色。

条件换成 isTeacherOrAbove,取数补 try/finally。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqqZwxtXLo2GTqMi51C94D
2026-08-31 08:42:40 -06:00
16 changed files with 595 additions and 253 deletions

View File

@@ -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",

View File

@@ -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) => ({

View File

@@ -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)
}

View File

@@ -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)
})

View File

@@ -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({

View File

@@ -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
}

View 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
// 补录的格子先写进 detailresyncProgress 是照着库里的 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
}

View File

@@ -5,8 +5,9 @@ import { objectValue } from "../routes/helpers"
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
type ProblemLink = { problemId: number; score: number }
type BadgeCheck = Pick<ProgressRow, "completedProblemsCount" | "totalProblemsCount" | "totalScore">
type ProblemLink = { problemId: number; score: number; isRequired: boolean }
type BadgeCheck = Pick<ProgressRow,
"completedProblemsCount" | "totalProblemsCount" | "totalScore" | "progressDetail">
/**
* 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。
@@ -34,8 +35,17 @@ export function computeProgress(
// 分值以题单当前的设置为准detail 里存的是做出那一刻的快照
kept[key] = { ...objectValue(value), score }
}
const completed = Object.keys(kept).length
const total = links.length
// 分母只算必做题。「(选做)」这个标签一直只是卡片上的一行字,进度分母和 all_problems
// 奖章照样要求做完 —— 快照里 22 个人做完了全部必做题,界面却显示未完成、全通奖章也拿不到
// (题单 5/6/8/11。选做题做了仍然计分totalScore 把它算进去),只是不卡完成。
//
// 一道必做都没标的题单退回「全部都算必做」:那种题单多半是没用这个字段,而不是
// 真的整单选做;不兜住的话它永远完不成。
const required = links.filter((link) => link.isRequired)
const graded = required.length ? required : links
const gradedKeys = new Set(graded.map((link) => String(link.problemId)))
const completed = Object.keys(kept).filter((key) => gradedKeys.has(key)).length
const total = graded.length
// total > 0 这个前提不能省0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」,
// 还会写下 complete_time、计进「完成题单数」成就而且后面补上题目也不会自愈。
const isCompleted = total > 0 && completed === total
@@ -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 }
}

View File

@@ -53,7 +53,7 @@ const columns: DataTableColumn<SubmissionListItem>[] = [
h(Icon, { icon: "catppuccin:lock" }),
),
default: () =>
"这道题在你已经加入的题单中,只有在题单中完成此题,代码才可见。",
"这道题在你已经加入的题单里,加入之前的提交先藏起来了。在题单中做出此题即可解锁;题单过了截止时间也会解锁。",
},
),
])

View File

@@ -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

View File

@@ -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

View File

@@ -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
}
// 监听分页参数变化

View File

@@ -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>

View File

@@ -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="搜索题单..."

View File

@@ -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` 严 —— 合理收紧

View File

@@ -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(),