fix(判题/统计): 老师重判一次题目提交数就多一;统计选 231 班会把 2311、2312 班也算进来

- 重判带上原来的结果,落库只做差量:提交数不变、旧结果那一格减一新结果加一,
  通过数按新旧结果调整(原来重判当新提交记账,连点三次就多三)。重判时一起清掉 info,
  判题中那几秒不再按旧结果显示「通过 2/3」
- 三个统计接口加 className,按 class_name 精确匹配(原来拼 ks231 做包含匹配)
- 流程图统计查几道题时要每道都拿到 A/S 才算完成;「每人最好的一次」截断时有标记,
  题号上限 20 道和代码统计一致;流程图列表支持按整名匹配用户
- 删掉统计弹框走后没人用的展开行明细接口,以及每次都白算的「语法未过题数」「最近一次错在哪」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-29 18:52:58 +08:00
1 parent ed55ee5af6
commit 26c8d65e89
10 files changed
+156 -266

No files matched your search

+8 -4
View File
@@ -452,7 +452,7 @@ const ACTIVE_CLASS_WINDOW_MS = 2 * 60 * 60 * 1000
/**
* 从实际提交猜正在上课的班,而**不是记住老师上次选的**:统计面板吃过那个亏(上一节课
* 的班悄悄留在框里,老师看的整个是别人的班,见 StatisticsPanel.vue 的注释)。
* 的班悄悄留在框里,老师看的整个是别人的班)。
*/
async function suggestActiveClass() {
// 流程图也算:一节课全班都在画流程图时,只数代码提交就猜成了别的班
@@ -668,9 +668,8 @@ classroomRoutes.get("/classroom/board", requireTeacher, async (c) => {
})
/**
* 老师给这个班布置今天的题。按展示题号给、不分大小写,存 problem.id 并保留输入顺序;
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉,
* 学生那边退回推断。
* 提交列表「这节课」按钮:这个班这节课是哪几道题,取法同课堂看板(老师布置的优先,
* 否则按今天同班提交推断)。不传班级就猜最近两小时在交题的班
*/
classroomRoutes.get("/classroom/lesson", requireTeacher, async (c) => {
const className = c.req.query("className")?.trim() || (await suggestActiveClass())
@@ -688,6 +687,11 @@ classroomRoutes.get("/classroom/lesson", requireTeacher, async (c) => {
} satisfies ClassLesson)
})
/**
* 老师给这个班布置今天的题。按展示题号给、不分大小写,存 problem.id 并保留输入顺序;
* 有对不上的题号就整个拒掉并点名是哪几个,免得存下半张单子老师还不知道。空数组 = 清掉,
* 学生那边退回推断。
*/
classroomRoutes.put("/classroom/lesson", requireTeacher, async (c) => {
const parsed = await parseBody(c, classLessonRequestSchema, "题号格式不对")
if (!parsed.success) return parsed.response
+45 -12
View File
@@ -31,6 +31,7 @@ import {
asRecord,
parseDisplayIds,
queryInteger,
scopedUsers,
rounded,
stripClassPrefix,
} from "./helpers"
@@ -215,8 +216,20 @@ async function flowchartClassFilter(className: string) {
: sql`false`
}
async function flowchartUserFilter(username: string) {
const ids = (await matchedUsers(username)).map((row) => row.id)
/**
* 用户名筛选。默认是「包含」;`exact` 时整名匹配(提交列表的「只看他」、协作条带进来的),
* 学号互相包含是常态,ks24a1 会混进 ks24a10–ks24a19
*/
async function flowchartUserFilter(username: string, exact = false) {
const ids = exact
? (
await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
).map((row) => row.id)
: (await matchedUsers(username)).map((row) => row.id)
return ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`
}
@@ -275,7 +288,9 @@ flowchartRoutes.get("/flowcharts", requireAuth, async (c) => {
filters.push(
...(await Promise.all([
displayId ? flowchartProblemFilter(displayId) : undefined,
!onlyMyself && username ? flowchartUserFilter(username) : undefined,
!onlyMyself && username
? flowchartUserFilter(username, c.req.query("exactUsername") === "1")
: undefined,
!onlyMyself && className ? flowchartClassFilter(className) : undefined,
])),
)
@@ -335,8 +350,8 @@ const FLOWCHART_COMPLETED = 2
* 看到的完成率和均分就是错的,而且从界面上看不出来。
*
* 会随数据量线性变重的只剩词云:每条 feedback / suggestions / comment 都要走一遍
* jieba,而前端的「全部时段」是不带 start 的(FlowchartStatisticsPanel.vue 那个
* `duration === "all"`),攒一学年就得把所有评语重新 cut 一遍。词云是辅助性的,
* jieba,而前端的「全部时段」是不带 start 的(统计页 oj/statistics/ 的
* 「全部」时段),攒一学年就得把所有评语重新 cut 一遍。词云是辅助性的,
* 看的是高频问题,取最近这些条足够。
*
* 这里**同时**卡了两道:SQL 侧 `order by create_time desc limit N` 只取最近 N 条提交,
@@ -347,6 +362,9 @@ const FLOWCHART_COMPLETED = 2
*/
const WORDCLOUD_TEXT_LIMIT = 3000
/** 「每人最好的一次」最多给多少人。快照里有流程图的学生一共 203 人 */
const PEOPLE_LIMIT = 500
flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
const end = c.req.query("end")?.trim()
if (!end) return failure(c, 400, "invalid-request", "end is required")
@@ -361,6 +379,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
// 可以一次查几道(统计页的题号框和代码统计同一个),有一道找不到就整体报错,
// 不退化成「全部题目」—— 否则打错一个字看到的是全站数据
const displayIds = parseDisplayIds(c.req.query("problemDisplayId") ?? "")
// 和代码统计同一个上限(submission-statistics.ts 的 STATISTICS_MAX_PROBLEMS)
if (displayIds.length > 20) return failure(c, 400, "invalid-request", "At most 20 problems")
if (displayIds.length) {
const lowered = displayIds.map((id) => id.toLowerCase())
const problems = await db
@@ -384,11 +404,15 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
)
}
const username = c.req.query("username")?.trim()
// 用户名先解析成账号,再拿 user_id 去筛 —— 理由同代码提交的统计接口
// (submission.ts 的 GET /submissions/statistics),顺带让下面这几条一个 join 都不用挂
const matched = username ? await matchedUsers(username) : []
if (username) {
// 用户名 / 班级先解析成账号,再拿 user_id 去筛 —— 理由同代码提交的统计接口
// (submission-statistics.ts 的 GET /submissions/statistics),顺带让下面这几条一个 join 都不用挂。
// 班级按 class_name 精确匹配(scopedUsers 的注释说了为什么不能拿 ks231 模糊匹配)
const scoped = await scopedUsers(
c.req.query("username")?.trim() || undefined,
c.req.query("className")?.trim() || undefined,
)
const matched = scoped ?? []
if (scoped) {
const ids = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件,否则「查无此班」变成「全站统计」
filters.push(ids.length ? inArray(schema.flowchartSubmission.userId, ids) : sql`false`)
@@ -396,7 +420,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
const where = and(...filters)
// 花名册:只有指定了用户名才谈得上「班级人数」,不指定时分母无意义。
// 未禁用的普通用户才进分母,教师和管理员不算
const roster = username
const roster = scoped
? matched.filter((row) => !row.isDisabled && row.adminType === "Regular User")
: []
@@ -492,6 +516,9 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
bestGrade: sql<string | null>`(array_agg(${schema.flowchartSubmission.aiGrade}
order by ${schema.flowchartSubmission.aiScore} desc nulls last, ${schema.flowchartSubmission.createTime} desc))[1]`,
count: count(),
// 拿到 A / S 的题数。查几道题时要每道都拿到才算完成,和代码统计「几道都做完」同口径
goodProblems: sql<number>`count(distinct ${schema.flowchartSubmission.problemId})
filter (where ${schema.flowchartSubmission.aiGrade} in ('S', 'A'))`.mapWith(Number),
})
.from(schema.flowchartSubmission)
.innerJoin(schema.user, eq(schema.user.id, schema.flowchartSubmission.userId))
@@ -503,12 +530,14 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
.limit(500),
])
const people = peopleRows.map((row) => ({
const peopleTruncated = peopleRows.length > PEOPLE_LIMIT
const people = peopleRows.slice(0, PEOPLE_LIMIT).map((row) => ({
username: row.username,
realName: stripClassPrefix(row.username, row.className),
bestScore: row.bestScore === null ? null : Number(row.bestScore),
bestGrade: row.bestGrade,
count: row.count,
goodProblems: row.goodProblems,
}))
if (!totals || totals.total === 0) {
@@ -527,6 +556,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
realName: stripClassPrefix(row.username, row.className),
})),
people: [],
peopleTruncated: false,
problemCount: displayIds.length,
} satisfies FlowchartStatistics)
}
@@ -591,6 +622,8 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
realName: stripClassPrefix(row.username, row.className),
})),
people,
peopleTruncated,
problemCount: displayIds.length,
} satisfies FlowchartStatistics)
})
+26
View File
@@ -159,6 +159,32 @@ export async function countFailedSubmissions(userId: number, problemId: number)
* 代码提交和流程图两条统计都走这里。流程图那张表连冻结用户名都没有(只有
* `user_id`),更是只能从这儿拿 id。
*/
/**
* 统计接口「圈哪些人」:班级按 `user.class_name` **精确**匹配,学生名按包含匹配,两个都给
* 就取交集,都没给返回 null(不圈人)。
*
* 原来统计只收一个 username、前端拼成 `ks231` 去做包含匹配 —— 231 班会连带 2311、2312 班
* (快照里 37 人变成 138 人),而提交列表的班级筛选是精确的,两边的人对不上
*/
export async function scopedUsers(username: string | undefined, className: string | undefined) {
if (!username && !className) return null
return db
.select({
id: schema.user.id,
username: schema.user.username,
className: schema.user.className,
isDisabled: schema.user.isDisabled,
adminType: schema.user.adminType,
})
.from(schema.user)
.where(
and(
className ? eq(schema.user.className, className) : undefined,
username ? ilike(schema.user.username, `%${username}%`) : undefined,
),
)
}
export async function matchedUsers(username: string) {
return db
.select({
+16 -183
View File
@@ -9,7 +9,6 @@
import {
type SubmissionStatistics,
type SubmissionStatisticsGrid,
type SubmissionStatisticsItems,
type TodaySubmissionStatistics,
} from "@oj2/contract"
import { and, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
@@ -18,11 +17,11 @@ import { Hono } from "hono"
import { optionalAuth, requireTeacher } from "../auth/middleware"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
import { JudgeStatus, UNJUDGED_RESULTS } from "../judge/status"
import { type ContestEnv } from "../services/contest"
import { getBooleanOption } from "../services/options"
import { localTime, dayStart } from "../time"
import { isAdminRole, matchedUsers, parseDisplayIds, rounded, stripClassPrefix } from "./helpers"
import { isAdminRole, parseDisplayIds, rounded, scopedUsers, stripClassPrefix } from "./helpers"
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
@@ -160,107 +159,6 @@ async function findPublicProblemsByDisplayIds(displayIds: string[]) {
return { ids: rows.map((row) => row.id), missing: missing ?? null }
}
/**
* 展开行一次只看一个人(表格的 updateExpandedRowKeys 只留最后一个 key),所以明细
* **按需拉**,不再随统计一起下发。
*
* 原来是随 data 一起给所有人各带一份:生产快照实测,「全部时段 + 不填条件」要搬
* 49108 行(最早那版不截断是 105631 行),而其中真正被人看到的最多一个人的那几十条。
*/
const STATISTICS_ITEMS_LIMIT = 200
/** 错误摘要截断长度。编译错误能刷几十行,弹层里放不下,也没必要 */
const FAILURE_MESSAGE_LIMIT = 400
/**
* 「交了没对」那一栏点开要看的:这个人**最近一条**提交错在哪。
*
* 有了它,老师看到「张三 12次」之后不用再切到提交列表、翻到这个人、点开代码 ——
* 点一下名字就知道是编译错了还是答案错了、报的什么。err_info 是判题机塞进
* statistic_info 的那一段,提交详情页读的也是它。
*/
async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
// result 手写成 JudgeStatusValue:这条裸 SQL 读的就是 submission.result 那一列,
// 口径要和列上的 $type 一致
const byUser = new Map<
number,
{
id: string
problemDisplayId: string
result: JudgeStatusValue
error: string | null
}
>()
if (!userIds.length) return byUser
// 不给 submission 起别名:where 里的条件是 drizzle 拼的,引用的是 "submission"."x"
const rows = await db.execute<{
user_id: number
id: string
problem: string
result: JudgeStatusValue
error: string | null
}>(sql`
select user_id, id, problem, result, error from (
select
${schema.submission.userId} as user_id,
${schema.submission.id} as id,
${schema.problem.displayId} as problem,
${schema.submission.result} as result,
left(${schema.submission.statisticInfo}->>'err_info', ${FAILURE_MESSAGE_LIMIT}) as error,
row_number() over (
partition by ${schema.submission.userId}
order by ${schema.submission.createTime} desc
) as rn
from ${schema.submission}
join ${schema.problem} on ${schema.problem.id} = ${schema.submission.problemId}
where ${and(where, inArray(schema.submission.userId, userIds))}
) t
where rn = 1
`)
for (const row of rows) {
byUser.set(row.user_id, {
id: row.id,
problemDisplayId: row.problem,
result: row.result,
error: row.error,
})
}
return byUser
}
/**
* 「答案对了,但没按要求的语法写」的题数(AST_CHECK_FAILED)。
*
* 只算**最后也没改对**的:同一道题上既有 AST_CHECK_FAILED 又有 ACCEPTED,说明学生后来
* 改成要求的写法了,不该再拿这个提醒老师。所以要先按「人 × 题」聚一层,不能直接
* `count(distinct problem_id) filter (result = 10)`。
*
* 口径本身不动 —— AST_CHECK_FAILED 仍然算通过(答案确实对了,全站一致)。这里只是
* 让教师看得见「这几个人是绕过要求做出来的」,教学上那不算达标。
*/
async function astOnlyByUser(where: SQL | undefined, userIds: number[]) {
const byUser = new Map<number, number>()
if (!userIds.length) return byUser
const rows = await db.execute<{ user_id: number; n: number }>(sql`
select user_id, count(*)::int as n from (
select
${schema.submission.userId} as user_id,
bool_or(${schema.submission.result} = ${JudgeStatus.AST_CHECK_FAILED}) as has_ast,
bool_or(${schema.submission.result} = ${JudgeStatus.ACCEPTED}) as has_ac
from ${schema.submission}
where ${and(where, inArray(schema.submission.userId, userIds))}
group by ${schema.submission.userId}, ${schema.submission.problemId}
) t
where has_ast and not has_ac
group by user_id
`)
for (const row of rows) byUser.set(row.user_id, row.n)
return byUser
}
/**
* 两条提交列表的用户名筛选。**两边都要匹配**:
*
@@ -419,13 +317,15 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
const filters = scope.filters
const username = c.req.query("username")?.trim()
// 用户名先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面,
const username = c.req.query("username")?.trim() || undefined
const className = c.req.query("className")?.trim() || undefined
// 用户名 / 班级先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面,
// 但换掉的是下面**四条**语句各一次的 submission 全表扫:`ilike` 走不了索引,
// 换成 `user_id in (...)` 之后四条全走索引(生产快照实测单条 18448 → 537
// buffers;同一个快照上整个接口查一个班 120~250ms → 10ms 上下),多这一次往返是赚的。
const matched = username ? await matchedUsers(username) : []
if (username) {
const scoped = await scopedUsers(username, className)
const matched = scoped ?? []
if (scoped) {
const matchedIds = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失,
// 「查无此班」会变成「全站统计」
@@ -512,16 +412,11 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
* 「提交记录」那张表列的是**窗口里交过东西的所有人**,`done` 标出谁做完了 ——
* 原来只给做完的人,于是一次没对的学生连同他的提交在这张表里根本不存在,
* 教师想看「他到底错在哪」得切到提交列表再翻。展开一行拉的是那个人的全部
* 提交(GET /submissions/statistics/items 不按结果过滤),对错都在里面。
* 提交,对错都在里面。
*
* 「完成人数」这些数字跟着 `done` 算,不是 `data.length`。
*/
const doneCount = perUser.filter(isDone).length
// 要等 perUser 回来才能查,所以进不了上面那个 Promise.all
const astOnlyByUserMap = await astOnlyByUser(
where,
perUser.map((row) => row.userId),
)
const submittedUserIds = new Set(perUser.map((row) => row.userId))
@@ -531,7 +426,6 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount,
solvedCount: row.solvedCount,
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
judgingCount: row.judgingCount,
correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount),
done: isDone(row),
@@ -560,14 +454,8 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
const rosterIds = new Set(rosterRows.map((row) => row.id))
const attemptedRows = perUser.filter((row) => {
if (isDone(row)) return false
return username
? rosterIds.has(row.userId)
: !row.isDisabled && row.adminType === "Regular User"
return scoped ? rosterIds.has(row.userId) : !row.isDisabled && row.adminType === "Regular User"
})
const failureByUser = await lastFailureByUser(
where,
attemptedRows.map((row) => row.userId),
)
const dataAttempted = attemptedRows.map((row) => ({
username: row.username,
/**
@@ -575,10 +463,9 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
* 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁,
* 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。
*/
realName: username ? stripClassPrefix(row.username, row.className) : row.username,
realName: scoped ? stripClassPrefix(row.username, row.className) : row.username,
submissionCount: row.submissionCount,
solvedCount: row.solvedCount,
lastFailure: failureByUser.get(row.userId) ?? null,
}))
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
@@ -599,61 +486,6 @@ submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async
} satisfies SubmissionStatistics)
})
/**
* 统计面板展开一行时拉这个人的提交明细。
*
* 用户名这里是**精确匹配**,不是统计接口那种 ilike —— 那边填 `ks251` 要圈出整个班,
* 这边是「点开的这一行是谁」。时间窗和题号沿用同一个 scope,不然展开行看到的
* 会是另一个范围的数据。
*/
submissionStatisticsRoutes.get("/submissions/statistics/items", requireTeacher, async (c) => {
const username = c.req.query("username")?.trim()
if (!username) return failure(c, 400, "invalid-request", "username is required")
const scope = await statisticsScope(c)
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
/**
* 展开的那一行给的是**当前**用户名,先换成 user_id 再查 —— 直接按
* `submission.username` 精确匹配的话,改过名的学生展开来是空的(他的提交
* 全挂在旧名字下)。
*
* 查不到账号才退回按提交里冻结的用户名匹配:已删号的学生仍然会出现在统计
* 表格里(那一行的名字取自提交),展开行不能因此空着。
*/
const [account] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(eq(schema.user.username, username))
.limit(1)
const identity = account
? eq(schema.submission.userId, account.id)
: eq(schema.submission.username, username)
// 多取一条,好知道是不是被截断了
// innerJoin 不会漏行:submission.problem_id 是 NOT NULL 且外键是 NO ACTION,
// 题目删不掉(真要删会被外键拦住并提示改为隐藏)
const rows = await db
.select({
id: schema.submission.id,
result: schema.submission.result,
createTime: schema.submission.createTime,
problemDisplayId: schema.problem.displayId,
problemTitle: schema.problem.title,
})
.from(schema.submission)
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
.where(and(...scope.filters, identity))
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
.limit(STATISTICS_ITEMS_LIMIT + 1)
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success(c, {
items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
truncated,
} satisfies SubmissionStatisticsItems)
})
/** 方块串一次最多给多少条。一个班一个月七八百条,5000 是全年级查一周的量 */
const GRID_LIMIT = 5000
@@ -666,9 +498,10 @@ submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, a
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
const filters = [...scope.filters]
const username = c.req.query("username")?.trim()
if (username) {
const ids = (await matchedUsers(username)).map((row) => row.id)
const username = c.req.query("username")?.trim() || undefined
const scoped = await scopedUsers(username, c.req.query("className")?.trim() || undefined)
if (scoped) {
const ids = scoped.map((row) => row.id)
filters.push(ids.length ? inArray(schema.submission.userId, ids) : sql`false`)
}
@@ -759,7 +592,7 @@ submissionStatisticsRoutes.get("/submissions/statistics/grid", requireTeacher, a
// 按班级汇总时要知道每个班多少人,「一道没交」才算得出来
const classSizes: Record<string, number> = {}
if (!username) {
if (!scoped) {
const names = [...new Set(kept.map((row) => row.className).filter((name) => name !== null))]
if (names.length) {
const sizes = await db
+5 -2
View File
@@ -216,22 +216,25 @@ submissionRoutes.post("/submissions/:id/rejudge", requireTeacher, async (c) => {
.select({
id: schema.submission.id,
problemId: schema.submission.problemId,
result: schema.submission.result,
})
.from(schema.submission)
.where(and(eq(schema.submission.id, c.req.param("id")), isNull(schema.submission.contestId)))
.limit(1)
if (!row) return failure(c, 404, "submission-not-found", "Submission does not exist")
// info 一起清:不清的话判题中那几秒,列表和详情按旧的逐点结果算出「通过 2/3」
await db
.update(schema.submission)
.set({ statisticInfo: {}, result: JudgeStatus.PENDING })
.set({ statisticInfo: {}, info: {}, result: JudgeStatus.PENDING })
.where(eq(schema.submission.id, row.id))
// jobId 必须带时间戳。队列保留最近 100 个已完成任务,沿用 submissionId 做 jobId 的话
// BullMQ 会认为这个任务已经存在,重判静默变成空操作。与 flowcharts/:id/retry 同一处理。
await judgeQueue.add(
"judge",
{ submissionId: row.id, problemId: row.problemId },
// 带上重判之前的结果,落库时只做差量(见 judge/run.ts 的 persistResult)
{ submissionId: row.id, problemId: row.problemId, rejudgedFrom: row.result },
{ jobId: `${row.id}:rejudge:${Date.now()}` },
)
return success(c, null)