Compare commits
2 Commits
2031e6a434
...
f9354b0df1
| Author | SHA1 | Date | |
|---|---|---|---|
| f9354b0df1 | |||
| ff31f7abd1 |
@@ -11,6 +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/scripts/backfill-problemset-badges.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:routes": "bun src/scripts/check-route-shadowing.ts",
|
||||
"db:pull": "drizzle-kit pull",
|
||||
|
||||
@@ -12,19 +12,19 @@ import {
|
||||
updateProblemSetRequestSchema,
|
||||
updateProblemSetStatusRequestSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, notInArray, or, sql } from "drizzle-orm"
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import { requireTeacher, type AppEnv } from "../../auth/middleware"
|
||||
import type { AuthUser } from "../../auth/session"
|
||||
import { db, schema } from "../../db"
|
||||
import { failure, success } from "../../http"
|
||||
import { recalculateBadge, resyncProgress } from "../../services/problemset"
|
||||
import { queryInteger, sampleUser } from "../helpers"
|
||||
|
||||
export const adminProblemSetRoutes = new Hono<AppEnv>()
|
||||
|
||||
type BadgeRow = typeof schema.problemsetBadge.$inferSelect
|
||||
type ProgressRow = typeof schema.problemsetProgress.$inferSelect
|
||||
|
||||
/** 对齐旧 ensure_created_by:超管放行,其余人只能碰自己建的。越权报「不存在」 */
|
||||
function ownedBy(user: AuthUser, row: { createdById: number }) {
|
||||
@@ -305,46 +305,6 @@ async function badgesWithCount(badges: BadgeRow[]) {
|
||||
}))
|
||||
}
|
||||
|
||||
/** 纯逻辑判定,对齐旧 `ProblemSetBadge._is_eligible` */
|
||||
function eligible(badge: BadgeRow, progress: ProgressRow) {
|
||||
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 === "score") return progress.totalScore >= badge.conditionValue
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 重算某个奖章的获得者,对齐旧 `recalculate_user_badges`(由 post_save 信号触发)。
|
||||
* 保留已有记录的 earnedTime —— 只增删差集,不是先清空再重建,
|
||||
* 否则每改一次条件所有人的获得时间都会刷新成今天。
|
||||
*/
|
||||
async function recalculateBadge(badge: BadgeRow) {
|
||||
const progresses = await db.select().from(schema.problemsetProgress)
|
||||
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId))
|
||||
const eligibleIds = progresses.filter((item) => eligible(badge, item)).map((item) => item.userId)
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(schema.userBadge).where(and(
|
||||
eq(schema.userBadge.badgeId, badge.id),
|
||||
eligibleIds.length ? notInArray(schema.userBadge.userId, eligibleIds) : undefined,
|
||||
))
|
||||
if (!eligibleIds.length) return
|
||||
const existing = await tx.select({ userId: schema.userBadge.userId }).from(schema.userBadge)
|
||||
.where(eq(schema.userBadge.badgeId, badge.id))
|
||||
const have = new Set(existing.map((item) => item.userId))
|
||||
const missing = eligibleIds.filter((id) => !have.has(id))
|
||||
if (missing.length) {
|
||||
await tx.insert(schema.userBadge).values(missing.map((userId) => ({
|
||||
userId,
|
||||
badgeId: badge.id,
|
||||
earnedTime: new Date().toISOString(),
|
||||
})))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
adminProblemSetRoutes.get("/problem-sets/:id/badges", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
@@ -408,31 +368,6 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher
|
||||
|
||||
// ---------------------------------------------------------------- 学生进度
|
||||
|
||||
/**
|
||||
* 题目集或分值变动后,把所有参与者的进度重算一遍。
|
||||
*
|
||||
* 旧后端不做这件事:往题单里加一道题,学生那边的 totalProblemsCount 还是老数字,
|
||||
* 进度百分比因此偏高,甚至已经「完成」的人分母变了却还标着完成。
|
||||
* 只重算分母与百分比,不碰 completeTime —— 已经完成过的事实不因加题而撤销。
|
||||
*/
|
||||
async function resyncProgress(problemsetId: number) {
|
||||
const [totalRow] = await db.select({ value: count() }).from(schema.problemsetProblem)
|
||||
.where(eq(schema.problemsetProblem.problemsetId, problemsetId))
|
||||
const total = totalRow?.value ?? 0
|
||||
// 一条 UPDATE 把整个题单的参与者刷完。以前是先把 progress 全查出来再逐行 update,
|
||||
// 一个班的题单就是几十次往返,而算出来的值只跟 total 和这一行自己的 completed 有关。
|
||||
// completed 用 least(...) 夹住,百分比按 JS 那边同样的「乘 10000 四舍五入再除 100」
|
||||
// 保留两位小数 —— 数都是非负的,numeric 的 round 和 Math.round 在这个区间一致。
|
||||
const completed = sql`least(${schema.problemsetProgress.completedProblemsCount}, ${total})`
|
||||
await db.update(schema.problemsetProgress).set({
|
||||
totalProblemsCount: total,
|
||||
completedProblemsCount: completed,
|
||||
progressPercentage: total > 0
|
||||
? sql`round((${completed}::numeric / ${total}) * 10000) / 100`
|
||||
: sql`0`,
|
||||
}).where(eq(schema.problemsetProgress.problemsetId, problemsetId))
|
||||
}
|
||||
|
||||
adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c) => {
|
||||
const row = await loadOwned(c, c.get("user")!)
|
||||
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
gt,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
ne,
|
||||
or,
|
||||
sql,
|
||||
@@ -33,6 +32,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 { objectValue, queryInteger, sampleUser } from "./helpers"
|
||||
|
||||
export const problemsetRoutes = new Hono<AppEnv>()
|
||||
@@ -216,27 +216,9 @@ async function recomputeProgress(
|
||||
) {
|
||||
const links = await tx.select({ problemId: schema.problemsetProblem.problemId, score: schema.problemsetProblem.score })
|
||||
.from(schema.problemsetProblem).where(eq(schema.problemsetProblem.problemsetId, progress.problemsetId))
|
||||
const valid = new Map(links.map((link) => [String(link.problemId), link.score]))
|
||||
for (const key of Object.keys(detail)) if (!valid.has(key)) delete detail[key]
|
||||
let totalScore = 0
|
||||
for (const [key, value] of Object.entries(detail)) {
|
||||
const score = valid.get(key)
|
||||
if (score === undefined) continue
|
||||
totalScore += score
|
||||
detail[key] = { ...objectValue(value), score }
|
||||
}
|
||||
const completed = Object.keys(detail).length
|
||||
const total = links.length
|
||||
const isCompleted = completed === total
|
||||
const update = {
|
||||
progressDetail: detail,
|
||||
totalProblemsCount: total,
|
||||
completedProblemsCount: completed,
|
||||
totalScore,
|
||||
progressPercentage: total > 0 ? completed / total * 100 : 0,
|
||||
isCompleted,
|
||||
completeTime: isCompleted ? progress.completeTime ?? new Date().toISOString() : null,
|
||||
}
|
||||
// 算法本身在 services/problemset.ts —— 后台改题目后的批量重算走的是同一份,
|
||||
// 两边曾经各写一遍,结果后台那份少算了 total_score 和 is_completed
|
||||
const update = computeProgress(detail, links, progress.completeTime)
|
||||
await tx.update(schema.problemsetProgress).set(update).where(eq(schema.problemsetProgress.id, progress.id))
|
||||
return { ...progress, ...update }
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
submissionListSchema,
|
||||
submissionStatisticsSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, count, desc, eq, ilike, inArray, isNull, sql, type SQL } from "drizzle-orm"
|
||||
import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import {
|
||||
@@ -371,16 +371,67 @@ submissionRoutes.post("/code/format", requireAuth, async (c) => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 题单防作弊闸门:查出这些题目里,哪些题的旧提交要对该用户藏起来,返回 problemId → 加入时间。
|
||||
*
|
||||
* 对齐旧后端 `submission/serializers.py:12` 的 `bulk_fetch_problemset_progress`。学生加入含
|
||||
* 某道题的题单后,他在加入之前留下的 AC 代码还摆在提交列表里,复制粘贴就能把题单刷完。
|
||||
* 备份快照里 1734 人次、188 名学生进过这个窗口(占已解题次的 22.5%),不是边角情况。
|
||||
*
|
||||
* 解锁的三条路全写在 where 里,任一成立就查不出来、也就不遮挡:
|
||||
* - 已经在题单里做出这道题(progress_detail 里有这道题的 key)
|
||||
* - 题单过了截止时间(end_time;为空表示不设期限,只能靠做出来解锁)
|
||||
* - 题单被归档(status 不是 active)
|
||||
*
|
||||
* 一道题可能同时落在多个已加入的题单里,取最晚的 join_time——「存在任一题单要求遮挡就遮挡」
|
||||
* 等价于「提交时间早于最晚的那次加入」。旧后端这里用 `.first()` 取任意一条,一题多题单时
|
||||
* 行为不确定,换成聚合顺手定死。
|
||||
*/
|
||||
async function problemSetJoinTimes(userId: number, problemIds: number[]) {
|
||||
const joinTimes = new Map<number, string>()
|
||||
if (problemIds.length === 0) return joinTimes
|
||||
const rows = await db
|
||||
.select({
|
||||
problemId: schema.problemsetProblem.problemId,
|
||||
// ::text 是为了拿回和 mode:"string" 列同样形状的字符串——聚合表达式不走列的类型映射,
|
||||
// 不加这个 cast 驱动会把 timestamptz 解析成 Date,下游的 Date.parse 就接不住了
|
||||
joinTime: sql<string>`max(${schema.problemsetProgress.joinTime})::text`,
|
||||
})
|
||||
.from(schema.problemsetProgress)
|
||||
.innerJoin(schema.problemset, eq(schema.problemset.id, schema.problemsetProgress.problemsetId))
|
||||
.innerJoin(schema.problemsetProblem, eq(schema.problemsetProblem.problemsetId, schema.problemset.id))
|
||||
.where(and(
|
||||
eq(schema.problemsetProgress.userId, userId),
|
||||
inArray(schema.problemsetProblem.problemId, problemIds),
|
||||
eq(schema.problemset.status, "active"),
|
||||
or(isNull(schema.problemset.endTime), gt(schema.problemset.endTime, sql`now()`)),
|
||||
sql`not jsonb_exists(${schema.problemsetProgress.progressDetail}, ${schema.problemsetProblem.problemId}::text)`,
|
||||
))
|
||||
.groupBy(schema.problemsetProblem.problemId)
|
||||
for (const row of rows) joinTimes.set(row.problemId, row.joinTime)
|
||||
return joinTimes
|
||||
}
|
||||
|
||||
// 参数按「实际用到的字段」声明,而不是整行 $inferSelect:列表接口只 select 需要的列,
|
||||
// 传不进完整行。完整行在结构上满足这两个窄类型,详情接口照旧调用不受影响。
|
||||
function canViewSubmission(
|
||||
user: AuthUser | null,
|
||||
row: { userId: number; shared: boolean },
|
||||
row: { userId: number; shared: boolean; problemId: number; createTime: string },
|
||||
problem: { createdById: number; shareSubmission: boolean },
|
||||
contest: typeof schema.contest.$inferSelect | null,
|
||||
allowShared = true,
|
||||
problemSetJoinTime?: Map<number, string>,
|
||||
) {
|
||||
if (!user) return false
|
||||
// 题单防作弊,见 problemSetJoinTimes。只对学生自己的提交生效,管理员不受限,对齐旧后端
|
||||
// `get_show_link` 里的 `obj.user_id == self.user.id and self.user.is_regular_user()`。
|
||||
//
|
||||
// 只挡「看代码」这一路,不挡 allowShared=false 的那一路:后者是分享/取消分享的归属校验,
|
||||
// 与作弊无关,挡了会让学生连自己旧提交的分享开关都动不了。
|
||||
if (allowShared && row.userId === user.id && !isAdminRole(user)) {
|
||||
const joinTime = problemSetJoinTime?.get(row.problemId)
|
||||
if (joinTime !== undefined && Date.parse(row.createTime) < Date.parse(joinTime)) return false
|
||||
}
|
||||
if (row.userId === user.id || isAdminRole(user) || problem.createdById === user.id) return true
|
||||
if (!allowShared) return false
|
||||
if (contest && contestStatus(contest) !== "-1") return false
|
||||
@@ -398,6 +449,8 @@ const submissionListColumns = {
|
||||
id: schema.submission.id,
|
||||
createTime: schema.submission.createTime,
|
||||
userId: schema.submission.userId,
|
||||
// 题单闸门要按题定位,序列化本身用不到它
|
||||
problemId: schema.submission.problemId,
|
||||
username: schema.submission.username,
|
||||
result: schema.submission.result,
|
||||
language: schema.submission.language,
|
||||
@@ -418,7 +471,13 @@ async function submissionDetail(id: string, user: AuthUser) {
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.leftJoin(schema.contest, eq(schema.submission.contestId, schema.contest.id))
|
||||
.where(eq(schema.submission.id, id)).limit(1)
|
||||
if (!row || !canViewSubmission(user, row.submission, row.problem, row.contest)) return null
|
||||
if (!row) return null
|
||||
// 详情也要过闸门。旧后端只挡了列表里的链接,`SubmissionAPI.get`(views/oj.py:103)
|
||||
// 光走 check_user_permission——知道 submission id 直接访问照样拿得到代码,遮挡是虚的。
|
||||
const joinTimes = isAdminRole(user) || row.submission.userId !== user.id
|
||||
? undefined
|
||||
: await problemSetJoinTimes(user.id, [row.submission.problemId])
|
||||
if (!canViewSubmission(user, row.submission, row.problem, row.contest, true, joinTimes)) return null
|
||||
// info(含每个测试点的 test_case 编号与 output_md5)与 ip 只给管理员,对齐旧后端:
|
||||
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
|
||||
// SubmissionSafeModelSerializer(exclude=("info", "contest", "ip")) 之间二选一,
|
||||
@@ -531,12 +590,18 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
||||
totalQuery,
|
||||
paginateSubmissionRows(where, limit, offset, Boolean(displayId)),
|
||||
])
|
||||
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
|
||||
const joinTimes = user && !isAdminRole(user)
|
||||
? await problemSetJoinTimes(user.id, [...new Set(
|
||||
rows.filter((row) => row.submission.userId === user.id).map((row) => row.submission.problemId),
|
||||
)])
|
||||
: undefined
|
||||
return success(c, submissionListSchema.parse({
|
||||
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({
|
||||
id: submission.id,
|
||||
problem: problem.displayId,
|
||||
problemTitle: problem.title,
|
||||
showLink: user ? canViewSubmission(user, submission, problem, null) : false,
|
||||
showLink: user ? canViewSubmission(user, submission, problem, null, true, joinTimes) : false,
|
||||
createTime: submission.createTime,
|
||||
userId: submission.userId,
|
||||
username: submission.username,
|
||||
@@ -576,6 +641,10 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
|
||||
.orderBy(desc(schema.submission.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
// 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了
|
||||
// `isNull(problem.contestId)`(admin/problemset.ts:232)——而这条列表只出比赛提交,
|
||||
// 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。
|
||||
// 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch,那边同样是死代码。
|
||||
return success(c, submissionListSchema.parse({
|
||||
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({
|
||||
id: submission.id,
|
||||
|
||||
93
apps/api/src/scripts/backfill-problemset-badges.ts
Normal file
93
apps/api/src/scripts/backfill-problemset-badges.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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。
|
||||
*
|
||||
* bun src/scripts/backfill-problemset-badges.ts
|
||||
* bun src/scripts/backfill-problemset-badges.ts --apply
|
||||
*/
|
||||
const apply = process.argv.includes("--apply")
|
||||
const allowRevoke = process.argv.includes("--allow-revoke")
|
||||
|
||||
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("没有任何题单奖章,无事可做")
|
||||
process.exit(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("奖章与规则一致,无需补发")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!apply) {
|
||||
console.log("\n这是只读预演。确认无误后加 --apply 落库。")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (extraTotal > 0 && !allowRevoke) {
|
||||
console.error(
|
||||
`\n存在 ${extraTotal} 条误发。补发用的 recalculateBadge 会把它们**删掉**,` +
|
||||
`而 user_badge 没有别处备份、earnedTime 删了就找不回来。\n` +
|
||||
`确认要连同收回一起执行,加 --allow-revoke。`,
|
||||
)
|
||||
process.exit(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} 条差异`)
|
||||
process.exit(remaining === 0 ? 0 : 1)
|
||||
180
apps/api/src/services/problemset.ts
Normal file
180
apps/api/src/services/problemset.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { and, eq, notInArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db, schema } from "../db"
|
||||
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">
|
||||
|
||||
/**
|
||||
* 题单进度的唯一算法:学生做出一道题后的增量更新、后台改动题目后的批量重算,都走这一份。
|
||||
*
|
||||
* 以前两边各写一遍,于是各自漂了一段。后台那份(resyncProgress)只更新分母和百分比:
|
||||
* - 改题目分值时调了它,可它根本不碰 total_score,score 类奖章按陈旧分数判定;
|
||||
* - 不碰 is_completed,加一道题之后分母变大、百分比掉下来,人还标着「已完成」;
|
||||
* - 不清理 progress_detail,删掉一道题之后 least(completed, total) 会把没做的题算成做了。
|
||||
* 两边不再分叉的唯一办法是只留一处算法,所以这里做成纯函数,两边都只是调用者。
|
||||
*/
|
||||
export function computeProgress(
|
||||
detail: Record<string, unknown>,
|
||||
links: ProblemLink[],
|
||||
previousCompleteTime: string | null,
|
||||
now = new Date().toISOString(),
|
||||
) {
|
||||
const scoreByProblem = new Map(links.map((link) => [String(link.problemId), link.score]))
|
||||
// 已经移出题单的题目要从 detail 里剔掉,留着它 completed 就会比实际做出的题还多
|
||||
const kept: Record<string, unknown> = {}
|
||||
let totalScore = 0
|
||||
for (const [key, value] of Object.entries(detail)) {
|
||||
const score = scoreByProblem.get(key)
|
||||
if (score === undefined) continue
|
||||
totalScore += score
|
||||
// 分值以题单当前的设置为准,detail 里存的是做出那一刻的快照
|
||||
kept[key] = { ...objectValue(value), score }
|
||||
}
|
||||
const completed = Object.keys(kept).length
|
||||
const total = links.length
|
||||
// total > 0 这个前提不能省:0 === 0 同样成立,没有题目的题单会让人一加入就算「完成」,
|
||||
// 还会写下 complete_time、计进「完成题单数」成就,而且后面补上题目也不会自愈。
|
||||
const isCompleted = total > 0 && completed === total
|
||||
return {
|
||||
progressDetail: kept,
|
||||
totalProblemsCount: total,
|
||||
completedProblemsCount: completed,
|
||||
totalScore,
|
||||
// 乘 10000 四舍五入再除 100,保留两位小数
|
||||
progressPercentage: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
|
||||
isCompleted,
|
||||
// 完成状态没了,complete_time 也不该留着。学生那一路本来就是这么写的,
|
||||
// 后台这一路以前保留旧时间,于是同一行会出现「未完成 + 有完成时间」。
|
||||
completeTime: isCompleted ? previousCompleteTime ?? now : null,
|
||||
}
|
||||
}
|
||||
|
||||
type ProgressWrite = ReturnType<typeof computeProgress> & { id: number }
|
||||
|
||||
/**
|
||||
* 一条 UPDATE 刷完整批参与者。逐行 update 的话一个班的题单就是上百次往返,
|
||||
* 而每行要写的值都已经在内存里算好了,没有一个依赖数据库现有的值。
|
||||
*/
|
||||
async function writeProgress(rows: ProgressWrite[]) {
|
||||
// 每行 8 个参数,留足余量避开 Postgres 的 65535 个绑定参数上限
|
||||
for (let start = 0; start < rows.length; start += 1000) {
|
||||
const chunk = rows.slice(start, start + 1000)
|
||||
const values = sql.join(
|
||||
chunk.map((row) => sql`(
|
||||
${row.id}::bigint,
|
||||
${JSON.stringify(row.progressDetail)}::jsonb,
|
||||
${row.totalProblemsCount}::int,
|
||||
${row.completedProblemsCount}::int,
|
||||
${row.totalScore}::int,
|
||||
${row.progressPercentage}::double precision,
|
||||
${row.isCompleted}::boolean,
|
||||
${row.completeTime}::timestamptz
|
||||
)`),
|
||||
sql`, `,
|
||||
)
|
||||
await db.execute(sql`
|
||||
update ${schema.problemsetProgress} as pg set
|
||||
progress_detail = v.detail,
|
||||
total_problems_count = v.total_count,
|
||||
completed_problems_count = v.completed_count,
|
||||
total_score = v.total_score,
|
||||
progress_percentage = v.percentage,
|
||||
is_completed = v.is_completed,
|
||||
complete_time = v.complete_time
|
||||
from (values ${values}) as v(
|
||||
id, detail, total_count, completed_count, total_score, percentage, is_completed, complete_time
|
||||
)
|
||||
where pg.id = v.id
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
/** 纯逻辑判定,对齐旧 `ProblemSetBadge._is_eligible` */
|
||||
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 === "score") return progress.totalScore >= badge.conditionValue
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 重算某枚奖章的获得者,对齐旧 `recalculate_user_badges`(由 post_save 信号触发)。
|
||||
* 保留已有记录的 earnedTime —— 只增删差集,不是先清空再重建,
|
||||
* 否则每改一次条件所有人的获得时间都会刷新成今天。
|
||||
*
|
||||
* 调用方手里已经有最新的进度时把它传进来(`known`),省掉一次回表;
|
||||
* 更要紧的是别用刚写完库之前的旧值去判定。
|
||||
*/
|
||||
export async function recalculateBadge(badge: BadgeRow, known?: (BadgeCheck & { userId: number })[]) {
|
||||
const progresses = known ?? await db.select().from(schema.problemsetProgress)
|
||||
.where(eq(schema.problemsetProgress.problemsetId, badge.problemsetId))
|
||||
const eligibleIds = progresses.filter((item) => eligibleForBadge(badge, item)).map((item) => item.userId)
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(schema.userBadge).where(and(
|
||||
eq(schema.userBadge.badgeId, badge.id),
|
||||
eligibleIds.length ? notInArray(schema.userBadge.userId, eligibleIds) : undefined,
|
||||
))
|
||||
if (!eligibleIds.length) return
|
||||
const existing = await tx.select({ userId: schema.userBadge.userId }).from(schema.userBadge)
|
||||
.where(eq(schema.userBadge.badgeId, badge.id))
|
||||
const have = new Set(existing.map((item) => item.userId))
|
||||
const missing = eligibleIds.filter((id) => !have.has(id))
|
||||
if (missing.length) {
|
||||
await tx.insert(schema.userBadge).values(missing.map((userId) => ({
|
||||
userId,
|
||||
badgeId: badge.id,
|
||||
earnedTime: new Date().toISOString(),
|
||||
})))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 题目集或分值变动后,把所有参与者的进度整体重算一遍,再重算这份题单的奖章。
|
||||
*
|
||||
* 旧后端两件事都不做:往题单里加一道题,学生那边的 totalProblemsCount 还是老数字,
|
||||
* 进度百分比因此偏高;奖章那边更是没人回头判过,生产快照里因此攒下 53 条应发未发
|
||||
* (30 名学生,其中 23 条来自 2026-05-22 那次批量补进度)。
|
||||
*/
|
||||
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().from(schema.problemsetProgress)
|
||||
.where(eq(schema.problemsetProgress.problemsetId, problemsetId)),
|
||||
db.select().from(schema.problemsetBadge)
|
||||
.where(eq(schema.problemsetBadge.problemsetId, problemsetId)),
|
||||
])
|
||||
const now = new Date().toISOString()
|
||||
const updated = progresses.map((progress) => ({
|
||||
...progress,
|
||||
...computeProgress(objectValue(progress.progressDetail), links, progress.completeTime, now),
|
||||
}))
|
||||
if (updated.length) await writeProgress(updated)
|
||||
for (const badge of badges) await recalculateBadge(badge, updated)
|
||||
}
|
||||
|
||||
/** 按奖章算出「现在应该有谁」,只读,供补发脚本先看后写 */
|
||||
export async function badgeHolderDiff(badge: BadgeRow) {
|
||||
const [progresses, holders] = await Promise.all([
|
||||
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)),
|
||||
])
|
||||
const eligible = new Set(progresses.filter((item) => eligibleForBadge(badge, item)).map((item) => item.userId))
|
||||
const have = new Set(holders.map((item) => item.userId))
|
||||
return {
|
||||
missing: [...eligible].filter((id) => !have.has(id)),
|
||||
extra: [...have].filter((id) => !eligible.has(id)),
|
||||
eligible: eligible.size,
|
||||
held: have.size,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user