Compare commits
5 Commits
9c5a1d551d
...
6bef55904f
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bef55904f | |||
| 80f3b21e95 | |||
| a5b57d8ab8 | |||
| 64ad6139f5 | |||
| a89eed7bdd |
21
apps/api/src/db/0012_drop_redundant_indexes.sql
Normal file
21
apps/api/src/db/0012_drop_redundant_indexes.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
DROP INDEX "acm_contest_rank_contest_id_21030ccd";--> statement-breakpoint
|
||||
DROP INDEX "acm_contest_rank_user_id_40391ab2";--> statement-breakpoint
|
||||
DROP INDEX "flowchart_submission_id_0dbfc4f9_like";--> statement-breakpoint
|
||||
DROP INDEX "flowchart_submission_problem_id_8551edbf";--> statement-breakpoint
|
||||
DROP INDEX "flowchart_submission_user_id_225c83e8";--> statement-breakpoint
|
||||
DROP INDEX "message_recipient_id_2aa5dd76";--> statement-breakpoint
|
||||
DROP INDEX "message_submission_id_2fdf8a47_like";--> statement-breakpoint
|
||||
DROP INDEX "problem__id_919b1d80";--> statement-breakpoint
|
||||
DROP INDEX "problem_contest_id_328e013a";--> statement-breakpoint
|
||||
DROP INDEX "problem_tags_problem_id_866ecb8d";--> statement-breakpoint
|
||||
DROP INDEX "problemset_problem_problemset_id_350d17fb";--> statement-breakpoint
|
||||
DROP INDEX "problemset_progress_problemset_id_20a9632e";--> statement-breakpoint
|
||||
DROP INDEX "problemset_submission_problemset_id_85290e17";--> statement-breakpoint
|
||||
DROP INDEX "problemset_submission_submission_id_78e2b807_like";--> statement-breakpoint
|
||||
DROP INDEX "problemset_submission_user_id_915fc9c6";--> statement-breakpoint
|
||||
DROP INDEX "reaction_problem_id_a7f3b9f3";--> statement-breakpoint
|
||||
DROP INDEX "submission_contest_id_775716d5";--> statement-breakpoint
|
||||
DROP INDEX "submission_problem_id_76847b55";--> statement-breakpoint
|
||||
DROP INDEX "submission_user_id_3779a8c1";--> statement-breakpoint
|
||||
DROP INDEX "user_achievement_user_id_b8ec7d6a";--> statement-breakpoint
|
||||
DROP INDEX "user_badge_user_id_a286d718";
|
||||
4
apps/api/src/db/0013_add_filter_and_metrics_indexes.sql
Normal file
4
apps/api/src/db/0013_add_filter_and_metrics_indexes.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
CREATE INDEX "flowchart_create_time_idx" ON "flowchart_submission" USING btree ("create_time");--> statement-breakpoint
|
||||
CREATE INDEX "submission_language_time_idx" ON "submission" USING btree ("language","create_time") WHERE "submission"."contest_id" is null;--> statement-breakpoint
|
||||
CREATE INDEX "submission_result_time_idx" ON "submission" USING btree ("result","create_time") WHERE "submission"."contest_id" is null;--> statement-breakpoint
|
||||
CREATE INDEX "submission_public_metrics_idx" ON "submission" USING btree ("user_id","problem_id","result","create_time") WHERE "submission"."contest_id" is null;
|
||||
3600
apps/api/src/db/meta/0012_snapshot.json
Normal file
3600
apps/api/src/db/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
3693
apps/api/src/db/meta/0013_snapshot.json
Normal file
3693
apps/api/src/db/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,20 @@
|
||||
"when": 1788788493497,
|
||||
"tag": "0011_user_lookup_indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1788869393805,
|
||||
"tag": "0012_drop_redundant_indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1788913334948,
|
||||
"tag": "0013_add_filter_and_metrics_indexes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -127,9 +127,10 @@ export const flowchartSubmission = pgTable("flowchart_submission", {
|
||||
}, (table) => [
|
||||
index("flowchart_problem_time_idx").using("btree", table.problemId.asc().nullsLast().op("int4_ops"), table.createTime.asc().nullsLast().op("int4_ops")),
|
||||
index("flowchart_status_idx").using("btree", table.status.asc().nullsLast().op("int4_ops")),
|
||||
index("flowchart_submission_id_0dbfc4f9_like").using("btree", table.id.asc().nullsLast().op("text_pattern_ops")),
|
||||
index("flowchart_submission_problem_id_8551edbf").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
|
||||
index("flowchart_submission_user_id_225c83e8").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
|
||||
// 流程图列表分页。原来是 hash join 全表再 top-N 排序(4.5ms / 551 buffers),
|
||||
// 走这条之后 0.19ms / 47。绝对值不大,但索引只要 64kB,而这张表每行带一大坨
|
||||
// jsonb,行数涨上去是线性恶化的。ASC 反向扫,理由同 submission 那几条。
|
||||
index("flowchart_create_time_idx").using("btree", table.createTime.asc().nullsLast()),
|
||||
index("flowchart_user_time_idx").using("btree", table.userId.asc().nullsLast().op("int4_ops"), table.createTime.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.problemId],
|
||||
@@ -151,11 +152,9 @@ export const message = pgTable("message", {
|
||||
senderId: integer("sender_id").notNull(),
|
||||
submissionId: text("submission_id").notNull(),
|
||||
}, (table) => [
|
||||
index("message_recipient_id_2aa5dd76").using("btree", table.recipientId.asc().nullsLast().op("int4_ops")),
|
||||
index("message_recipient_time_idx").using("btree", table.recipientId.asc().nullsLast().op("timestamptz_ops"), table.createTime.asc().nullsLast().op("int4_ops")),
|
||||
index("message_sender_id_a2a2e825").using("btree", table.senderId.asc().nullsLast().op("int4_ops")),
|
||||
index("message_submission_id_2fdf8a47").using("btree", table.submissionId.asc().nullsLast().op("text_ops")),
|
||||
index("message_submission_id_2fdf8a47_like").using("btree", table.submissionId.asc().nullsLast().op("text_pattern_ops")),
|
||||
foreignKey({
|
||||
columns: [table.recipientId],
|
||||
foreignColumns: [user.id],
|
||||
@@ -245,7 +244,6 @@ export const problemsetProblem = pgTable("problemset_problem", {
|
||||
problemsetId: bigint("problemset_id", { mode: "number" }).notNull(),
|
||||
}, (table) => [
|
||||
index("problemset_problem_problem_id_fff2d686").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
|
||||
index("problemset_problem_problemset_id_350d17fb").using("btree", table.problemsetId.asc().nullsLast().op("int8_ops")),
|
||||
foreignKey({
|
||||
columns: [table.problemId],
|
||||
foreignColumns: [problem.id],
|
||||
@@ -274,7 +272,6 @@ export const problemsetProgress = pgTable("problemset_progress", {
|
||||
problemsetId: bigint("problemset_id", { mode: "number" }).notNull(),
|
||||
userId: integer("user_id").notNull(),
|
||||
}, (table) => [
|
||||
index("problemset_progress_problemset_id_20a9632e").using("btree", table.problemsetId.asc().nullsLast().op("int8_ops")),
|
||||
index("problemset_progress_user_id_c8041a80").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.problemsetId],
|
||||
@@ -302,10 +299,7 @@ export const problemsetSubmission = pgTable("problemset_submission", {
|
||||
index("problemset__problem_22f053_idx").using("btree", table.problemsetId.asc().nullsLast().op("int8_ops"), table.problemId.asc().nullsLast().op("int8_ops")),
|
||||
index("problemset__user_id_2f1501_idx").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
|
||||
index("problemset_submission_problem_id_5629b105").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
|
||||
index("problemset_submission_problemset_id_85290e17").using("btree", table.problemsetId.asc().nullsLast().op("int8_ops")),
|
||||
index("problemset_submission_submission_id_78e2b807").using("btree", table.submissionId.asc().nullsLast().op("text_ops")),
|
||||
index("problemset_submission_submission_id_78e2b807_like").using("btree", table.submissionId.asc().nullsLast().op("text_pattern_ops")),
|
||||
index("problemset_submission_user_id_915fc9c6").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.problemId],
|
||||
foreignColumns: [problem.id],
|
||||
@@ -335,7 +329,6 @@ export const reaction = pgTable("reaction", {
|
||||
problemId: integer("problem_id").notNull(),
|
||||
userId: integer("user_id").notNull(),
|
||||
}, (table) => [
|
||||
index("reaction_problem_id_a7f3b9f3").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
|
||||
index("reaction_problem_type_idx").using("btree", table.problemId.asc().nullsLast().op("int4_ops"), table.type.asc().nullsLast().op("int4_ops")),
|
||||
index("reaction_user_id_cfa7f469").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
@@ -397,8 +390,6 @@ export const problem = pgTable("problem", {
|
||||
sqlConfig: jsonb("sql_config"),
|
||||
sqlDisplay: jsonb("sql_display"),
|
||||
}, (table) => [
|
||||
index("problem__id_919b1d80").using("btree", table.displayId.asc().nullsLast().op("text_ops")),
|
||||
index("problem_contest_id_328e013a").using("btree", table.contestId.asc().nullsLast().op("int4_ops")),
|
||||
index("problem_contest_visible_idx").using("btree", table.contestId.asc().nullsLast().op("bool_ops"), table.visible.asc().nullsLast().op("int4_ops")),
|
||||
index("problem_created_by_id_cb362143").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
|
||||
index("problem_visible_idx").using("btree", table.visible.asc().nullsLast().op("bool_ops")),
|
||||
@@ -420,7 +411,6 @@ export const problemTags = pgTable("problem_tags", {
|
||||
problemId: integer("problem_id").notNull(),
|
||||
problemtagId: integer("problemtag_id").notNull(),
|
||||
}, (table) => [
|
||||
index("problem_tags_problem_id_866ecb8d").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
|
||||
index("problem_tags_problemtag_id_72d20571").using("btree", table.problemtagId.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.problemId],
|
||||
@@ -491,12 +481,53 @@ export const submission = pgTable("submission", {
|
||||
// 两列同为 ASC 时整条索引反着扫就是精确的反序,所以反而是能用的那一种。
|
||||
// 这两列都 NOT NULL,nulls 位置在语义上无所谓,纯粹是规划器的匹配规则。
|
||||
index("submission_public_create_time_id_idx").using("btree", table.createTime.asc().nullsLast(), table.id.asc().nullsLast()).where(sql`${table.contestId} is null`),
|
||||
/**
|
||||
* Django 给每个外键都自动建了一个单列索引,`db_index=True` 的还会多一个
|
||||
* `_like`(text_pattern_ops)。0012 把其中 21 个删了 —— 它们的列都是某个
|
||||
* 复合索引的**最左前缀**,规划器本来就走那一个,多出来的只是每次写入要多维护
|
||||
* 一棵树。这张表上删的三个是 contest_id / problem_id / user_id,分别被下面
|
||||
* 的 contest_create_time_idx、problem_user_idx、user_create_time_idx 覆盖。
|
||||
*
|
||||
* 加新索引时先看一眼有没有现成的复合索引已经以它打头,别把这批又建回来。
|
||||
*/
|
||||
index("problem_user_idx").using("btree", table.problemId.asc().nullsLast().op("int4_ops"), table.userId.asc().nullsLast().op("int4_ops")),
|
||||
index("submission_contest_id_775716d5").using("btree", table.contestId.asc().nullsLast().op("int4_ops")),
|
||||
index("submission_problem_id_76847b55").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
|
||||
/**
|
||||
* `submission_result_37e2f67a` 是 Django 建的单列索引,**别当成被下面
|
||||
* submission_result_time_idx 覆盖了就删**:那个是 `WHERE contest_id IS NULL`
|
||||
* 的部分索引,管不了「全库含比赛按 result 统计」那类查询(实测删掉之后
|
||||
* `count(*) where result in (6,7)` 从走索引掉回 75ms 全表扫)。856kB,留着。
|
||||
*/
|
||||
index("submission_result_37e2f67a").using("btree", table.result.asc().nullsLast().op("int4_ops")),
|
||||
index("submission_user_id_3779a8c1").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
|
||||
index("user_create_time_idx").using("btree", table.userId.asc().nullsLast().op("int4_ops"), table.createTime.asc().nullsLast().op("timestamptz_ops")),
|
||||
/**
|
||||
* 提交列表的「语言」和「结果」两个下拉筛选。原来这两列上要么没索引、要么只有
|
||||
* 不带 `contest_id IS NULL` 的单列索引,翻页那条靠 submission_public_create_time_id_idx
|
||||
* 边扫边滤还能对付,**count 那条只能全表扫**(快照实测固定 75~82ms / 18448 buffers,
|
||||
* 筛什么值都一样)。加完:语言 count 80ms → 11ms(Python3,占 8 成)/ 1.6ms(C),
|
||||
* 结果 count 75ms → 2.0ms。
|
||||
*
|
||||
* 更要命的是冷门语言的**翻页**:Python2 只有 3 条、全是 2022 年的,分页索引得从
|
||||
* 最新一路倒扫到底才凑够一页,43ms 全表扫;走这条索引是 0.02ms。
|
||||
*
|
||||
* 两列都 ASC NULLS LAST,理由同上面 submission_public_create_time_id_idx ——
|
||||
* 靠 Index Scan **Backward** 出 `ORDER BY create_time DESC`。这里再实测了一遍:
|
||||
* 写成 DESC NULLS LAST 规划器直接不认这条索引,回落到分页索引带 Filter。
|
||||
*/
|
||||
index("submission_language_time_idx").using("btree", table.language.asc().nullsLast(), table.createTime.asc().nullsLast()).where(sql`${table.contestId} is null`),
|
||||
index("submission_result_time_idx").using("btree", table.result.asc().nullsLast(), table.createTime.asc().nullsLast()).where(sql`${table.contestId} is null`),
|
||||
/**
|
||||
* 覆盖索引,专门给「在全部公开提交上做聚合」那几个接口用:教师统计不填班级、
|
||||
* 活跃榜、题目 AC 趋势。它们慢的**不是聚合本身,是为了读这四个小列把 145MB 的堆
|
||||
* 翻一遍** —— `code` 和 `info` 占了这张表的绝大部分体积,聚合一列都用不上。
|
||||
*
|
||||
* 有了它这些查询走 Index Only Scan,只读 6MB。快照实测:
|
||||
* 教师统计全站 186ms → 49ms(还消掉了 4.2MB 的落盘排序)、活跃榜 108ms → 18ms、
|
||||
* AC 趋势 120ms → 41ms,buffers 一律从 18000+ 掉到 2000 以内。
|
||||
*
|
||||
* 列序按 user_id 打头:三个查询里两个按人分组,能省掉排序。加列要谨慎 ——
|
||||
* 多一列就多一份 10 万行的拷贝,而它的价值全在「窄」上。
|
||||
*/
|
||||
index("submission_public_metrics_idx").using("btree", table.userId.asc().nullsLast(), table.problemId.asc().nullsLast(), table.result.asc().nullsLast(), table.createTime.asc().nullsLast()).where(sql`${table.contestId} is null`),
|
||||
foreignKey({
|
||||
columns: [table.contestId],
|
||||
foreignColumns: [contest.id],
|
||||
@@ -565,7 +596,6 @@ export const userAchievement = pgTable("user_achievement", {
|
||||
userId: integer("user_id").notNull(),
|
||||
}, (table) => [
|
||||
index("user_achievement_achievement_id_29db600d").using("btree", table.achievementId.asc().nullsLast().op("int8_ops")),
|
||||
index("user_achievement_user_id_b8ec7d6a").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
|
||||
index("user_achv_notified_idx").using("btree", table.userId.asc().nullsLast().op("int4_ops"), table.notified.asc().nullsLast().op("bool_ops")),
|
||||
index("user_achv_time_idx").using("btree", table.userId.asc().nullsLast().op("int4_ops"), table.unlockTime.desc().nullsFirst().op("timestamptz_ops")),
|
||||
foreignKey({
|
||||
@@ -590,7 +620,6 @@ export const userBadge = pgTable("user_badge", {
|
||||
userId: integer("user_id").notNull(),
|
||||
}, (table) => [
|
||||
index("user_badge_badge_id_92a983e9").using("btree", table.badgeId.asc().nullsLast().op("int8_ops")),
|
||||
index("user_badge_user_id_a286d718").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
columns: [table.badgeId],
|
||||
foreignColumns: [problemsetBadge.id],
|
||||
@@ -631,8 +660,6 @@ export const acmContestRank = pgTable("acm_contest_rank", {
|
||||
contestId: integer("contest_id").notNull(),
|
||||
userId: integer("user_id").notNull(),
|
||||
}, (table) => [
|
||||
index("acm_contest_rank_contest_id_21030ccd").using("btree", table.contestId.asc().nullsLast().op("int4_ops")),
|
||||
index("acm_contest_rank_user_id_40391ab2").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
|
||||
index("acm_rank_contest_user_idx").using("btree", table.contestId.asc().nullsLast().op("int4_ops"), table.userId.asc().nullsLast().op("int4_ops")),
|
||||
index("acm_rank_order_idx").using("btree", table.contestId.asc().nullsLast().op("int4_ops"), table.acceptedNumber.asc().nullsLast().op("int4_ops"), table.totalTime.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
|
||||
@@ -325,6 +325,31 @@ async function markSystemError(submissionId: string, userId: number, error: unkn
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判题任务在 `judgeSubmission` 之外失败时的兜底。
|
||||
*
|
||||
* 正常路径上的异常都被 judgeSubmission 自己的 try/catch 接住、落成 SYSTEM_ERROR
|
||||
* 并且推给前端,所以能走到队列 `failed` 事件的只剩两种:取提交那一步就炸了,
|
||||
* 以及**worker 进程中途死掉** —— 机房断电、容器 OOM 被杀、部署时重启。后一种
|
||||
* BullMQ 会先按 stalled 重入队一次,再没人接就彻底放手;判题队列又没配 attempts,
|
||||
* 失败即终局。没有这个兜底,那条提交就永远停在「等待评分」,学生看着转圈,
|
||||
* 教师统计里它还占着一个「判题中」的名额。生产库里 3 条卡死的 PENDING
|
||||
* (2022-11 / 2026-03 / 2026-04,都是旧栈时代留下的)就是这么来的。
|
||||
*
|
||||
* `markSystemError` 只动 PENDING / JUDGING 两个状态,所以判完了的、被重判改过的
|
||||
* 都不会被它覆盖。唯一能撞上的是「重判刚把状态置回 PENDING,同一刻上一个被遗弃的
|
||||
* 任务才失败」——结果是这次重判被吃掉、显示成系统错误,比静默卡死看得见。
|
||||
*/
|
||||
export async function failAbandonedSubmission(submissionId: string, error: unknown) {
|
||||
const [row] = await db
|
||||
.select({ userId: schema.submission.userId })
|
||||
.from(schema.submission)
|
||||
.where(eq(schema.submission.id, submissionId))
|
||||
.limit(1)
|
||||
if (!row) return
|
||||
await markSystemError(submissionId, row.userId, error)
|
||||
}
|
||||
|
||||
export async function judgeSubmission(job: JudgeJobData) {
|
||||
const [row] = await db
|
||||
.select({
|
||||
|
||||
@@ -260,7 +260,14 @@ async function myLeaderboardRank(userId: number | undefined) {
|
||||
accountRoutes.get("/rankings/activity", async (c) => {
|
||||
const start = c.req.query("start")
|
||||
if (!start || Number.isNaN(Date.parse(start))) return failure(c, 400, "invalid-start", "start time is required")
|
||||
const rows = await db.select({ username: schema.submission.username, value: countDistinct(schema.submission.problemId) })
|
||||
/**
|
||||
* 按 **user_id** 聚合,名字从 user 表取。按 `submission.username` 分组的话,
|
||||
* 改过名的学生会裂成新旧两条各算各的 AC 题数 —— 排名被拆低,运气不好还会以
|
||||
* 两个名字同时挂在榜上。同 `/submissions/statistics` 那批。
|
||||
*
|
||||
* innerJoin user 顺带把已删号学生的孤儿提交挡在外面,不用再兜底名字。
|
||||
*/
|
||||
const rows = await db.select({ username: schema.user.username, value: countDistinct(schema.submission.problemId) })
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.user, eq(schema.submission.userId, schema.user.id))
|
||||
.where(and(
|
||||
@@ -270,7 +277,8 @@ accountRoutes.get("/rankings/activity", async (c) => {
|
||||
eq(schema.user.isDisabled, false),
|
||||
ne(schema.user.adminType, "Super Admin"),
|
||||
))
|
||||
.groupBy(schema.submission.username).orderBy(desc(countDistinct(schema.submission.problemId))).limit(10)
|
||||
.groupBy(schema.submission.userId, schema.user.username)
|
||||
.orderBy(desc(countDistinct(schema.submission.problemId))).limit(10)
|
||||
return success(c, rows.map((row) => activityRankItemSchema.parse({ username: row.username, count: row.value })))
|
||||
})
|
||||
|
||||
|
||||
@@ -233,10 +233,20 @@ adminAccountRoutes.put("/users/:id", requireSuperAdmin, async (c) => {
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(schema.user).set(patch).where(eq(schema.user.id, id))
|
||||
// submission.username 是冗余列(判题历史按用户名查),改名后必须一起改,否则历史提交查不到
|
||||
/**
|
||||
* submission.username 是冗余列,改名后跟着改。
|
||||
*
|
||||
* 条件按 **user_id** 而不是「等于旧用户名」:后者只改得动「当前正好还等于旧名」
|
||||
* 的行,一个已经漂移过的账号再改一次名,更早那批仍然改不动 —— 生产库里 726 条
|
||||
* 挂着旧名字的提交就是旧栈时代这么留下的,之后每次改名都从它身边绕过去。
|
||||
* 按 user_id 写是幂等的,顺带把这个人的历史行一次性拉平。
|
||||
*
|
||||
* 读路径本身已经不依赖这一列了(列表和统计都从 user 表取当前名字),
|
||||
* 这里保持同步是为了「已删号回退显示」和按名字搜索那两条路。
|
||||
*/
|
||||
if (existing.user.username !== username) {
|
||||
await tx.update(schema.submission).set({ username })
|
||||
.where(eq(schema.submission.username, existing.user.username))
|
||||
.where(eq(schema.submission.userId, id))
|
||||
}
|
||||
await tx.update(schema.userProfile).set({ realName: data.realName })
|
||||
.where(eq(schema.userProfile.userId, id))
|
||||
@@ -373,6 +383,9 @@ function isForeignKeyViolation(error: unknown) {
|
||||
return false
|
||||
}
|
||||
|
||||
/** 「这人还有提交」的信号。提交那张表没有外键,拦不住,只能自己查出来再把事务掀了 */
|
||||
class UserHasSubmissionsError extends Error {}
|
||||
|
||||
adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
|
||||
const parsed = deleteUsersRequestSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!parsed.success) return failure(c, 400, "invalid-request", "ids is required")
|
||||
@@ -390,13 +403,33 @@ adminAccountRoutes.delete("/users", requireSuperAdmin, async (c) => {
|
||||
// 别顺手把这里也改成全 CASCADE:submission.user_id 压根没有外键(Django 那边就是个
|
||||
// 裸 IntegerField),全连坐的结果是成就没了、提交却留成孤儿行,一半删一半留。
|
||||
try {
|
||||
const deleted = await db.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids))
|
||||
.returning({ id: schema.user.id })
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
/**
|
||||
* 外键拦得住成就、题单进度、比赛排名这些,**唯独提交拦不住** ——
|
||||
* `submission.user_id` 没有外键(Django 那边就是个裸 IntegerField,上面已经
|
||||
* 说了为什么不补)。所以下面那句报错里写的「还有提交」一直是空头支票:
|
||||
* 只交过题、没拿过成就没进过题单的学生照样删得掉,提交留在库里成了孤儿 ——
|
||||
* 用户没了、`submission.user_id` 还指着一个不存在的 id。生产快照实测:
|
||||
* 28 个已删账号留下 935 条这样的提交。
|
||||
*
|
||||
* 补一次查询把它拦下来,口径和外键那批一致:有历史数据就该禁用,不该删。
|
||||
* 和 delete 放同一个事务里,免得中间正好交了一发。
|
||||
*/
|
||||
const [withSubmission] = await tx
|
||||
.select({ userId: schema.submission.userId })
|
||||
.from(schema.submission)
|
||||
.where(inArray(schema.submission.userId, parsed.data.ids))
|
||||
.limit(1)
|
||||
if (withSubmission) throw new UserHasSubmissionsError()
|
||||
|
||||
return tx.delete(schema.user).where(inArray(schema.user.id, parsed.data.ids))
|
||||
.returning({ id: schema.user.id })
|
||||
})
|
||||
return success(c, { deleted: deleted.length })
|
||||
} catch (error) {
|
||||
// 只有外键冲突(23503)才是「这人还有历史数据」。以前这里是裸 catch,
|
||||
// 连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见
|
||||
if (!isForeignKeyViolation(error)) throw error
|
||||
// 只有外键冲突(23503)和上面那条提交检查才是「这人还有历史数据」。以前这里是裸
|
||||
// catch,连接断了、语句超时也照报这句,超管会照着提示去禁用账号,真正的故障一直没人看见
|
||||
if (!(error instanceof UserHasSubmissionsError) && !isForeignKeyViolation(error)) throw error
|
||||
return failure(c, 409, "user-in-use", "该用户还有提交、题目等历史数据,无法删除;请改为禁用账号")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -192,6 +192,19 @@ adminTagRoutes.get("/problem-analytics/stuck", requireTeacher, async (c) => {
|
||||
failedUsers: sql<number>`count(distinct ${schema.submission.userId}) ${failedFilter}`.mapWith(Number),
|
||||
}).from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
/**
|
||||
* 只看公共题,和隔壁 ac-trend 同一个口径。原来这里一个 where 都没有,比赛题
|
||||
* 也进榜 —— 而比赛题的题号是每场比赛各自从 1 开始编的(快照里 61 道不同的题
|
||||
* 都叫「1」),一旦挤进前 40,那一行显示的题号会指向一道根本不存在的公共题。
|
||||
*
|
||||
* 眼下还没发生:前 40 的门槛是 97 人卡住,比赛题最多的一道是 59 人。但两个班
|
||||
* 一起考的场次有 95 人,撞上一道难题就够得着了。
|
||||
*
|
||||
* 加了这条对公共题的数字**没有任何影响**:比赛提交挂的是比赛自己的 problem 行
|
||||
* (快照实测两个方向的交叉都是 0 条),公共题那一行本来就只统计自己的提交。
|
||||
* 顺带让这条查询能用上 0013 的 submission_public_metrics_idx,173ms → 82ms。
|
||||
*/
|
||||
.where(isNull(schema.submission.contestId))
|
||||
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
|
||||
.having(sql`count(distinct ${schema.submission.userId}) ${failedFilter} > 0`)
|
||||
.orderBy(desc(sql`count(distinct ${schema.submission.userId}) ${failedFilter}`))
|
||||
|
||||
@@ -257,41 +257,41 @@ const FAILURE_MESSAGE_LIMIT = 400
|
||||
* 点一下名字就知道是编译错了还是答案错了、报的什么。err_info 是判题机塞进
|
||||
* statistic_info 的那一段,提交详情页读的也是它。
|
||||
*/
|
||||
async function lastFailureByUser(where: SQL | undefined, usernames: string[]) {
|
||||
async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
|
||||
const byUser = new Map<
|
||||
string,
|
||||
number,
|
||||
{ id: string; problem: string; result: number; error: string | null }
|
||||
>()
|
||||
if (!usernames.length) return byUser
|
||||
if (!userIds.length) return byUser
|
||||
|
||||
// 不给 submission 起别名:where 里的条件是 drizzle 拼的,引用的是 "submission"."x"
|
||||
const rows = await db.execute<{
|
||||
username: string
|
||||
user_id: number
|
||||
id: string
|
||||
problem: string
|
||||
result: number
|
||||
error: string | null
|
||||
}>(sql`
|
||||
select username, id, problem, result, error from (
|
||||
select user_id, id, problem, result, error from (
|
||||
select
|
||||
${schema.submission.username} as username,
|
||||
${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.username}
|
||||
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.username, usernames))}
|
||||
where ${and(where, inArray(schema.submission.userId, userIds))}
|
||||
) t
|
||||
where rn = 1
|
||||
`)
|
||||
|
||||
for (const row of rows) {
|
||||
byUser.set(row.username, {
|
||||
byUser.set(row.user_id, {
|
||||
id: row.id,
|
||||
problem: row.problem,
|
||||
result: row.result,
|
||||
@@ -311,47 +311,78 @@ async function lastFailureByUser(where: SQL | undefined, usernames: string[]) {
|
||||
* 口径本身不动 —— AST_CHECK_FAILED 仍然算通过(答案确实对了,全站一致)。这里只是
|
||||
* 让教师看得见「这几个人是绕过要求做出来的」,教学上那不算达标。
|
||||
*/
|
||||
async function astOnlyByUser(where: SQL | undefined, usernames: string[]) {
|
||||
const byUser = new Map<string, number>()
|
||||
if (!usernames.length) return byUser
|
||||
async function astOnlyByUser(where: SQL | undefined, userIds: number[]) {
|
||||
const byUser = new Map<number, number>()
|
||||
if (!userIds.length) return byUser
|
||||
|
||||
const rows = await db.execute<{ username: string; n: number }>(sql`
|
||||
select username, count(*)::int as n from (
|
||||
const rows = await db.execute<{ user_id: number; n: number }>(sql`
|
||||
select user_id, count(*)::int as n from (
|
||||
select
|
||||
${schema.submission.username} as username,
|
||||
${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.username, usernames))}
|
||||
group by ${schema.submission.username}, ${schema.submission.problemId}
|
||||
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 username
|
||||
group by user_id
|
||||
`)
|
||||
for (const row of rows) byUser.set(row.username, row.n)
|
||||
for (const row of rows) byUser.set(row.user_id, row.n)
|
||||
return byUser
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户名模糊匹配到的在册学生,用来算「班级人数」和「谁没做」。
|
||||
* 只算未禁用的普通用户 —— 教师和管理员不该出现在完成度分母里。
|
||||
* 用户名模糊匹配到的账号。统计的两件事都从它出发:**筛哪些提交**(拿 id),
|
||||
* 以及**花名册**(班级人数、谁没做,见下面的过滤)。
|
||||
*
|
||||
* 这里必须查 `user` 表而不是 `submission.username` —— 后者是提交那一刻冻结的
|
||||
* 快照,学生改名之后旧提交还挂着旧名字,`ilike submission.username` 匹配不上。
|
||||
*
|
||||
* 生产快照实测(2026-09-08):24 级数媒两个班改成编号制用户名之后,85 人的
|
||||
* 提交挂在旧名下。查 `ks249` 旧口径 0 条 / 新口径 7 条 —— 整个班 48 人全掉进
|
||||
* 「一条没交」;查 `ks248` 20 条 / 54 条,13 个人的成绩查不出来。
|
||||
*
|
||||
* 返回**全部**匹配到的账号,禁用的和教师也在内 —— 「谁交过」不该受这两个条件
|
||||
* 影响。花名册那一份在调用处再筛(未禁用 + 普通用户),教师和管理员不进分母。
|
||||
*/
|
||||
async function matchedStudents(username: string) {
|
||||
async function matchedUsers(username: string) {
|
||||
return db
|
||||
.select({ username: schema.user.username, className: schema.user.className })
|
||||
.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(
|
||||
ilike(schema.user.username, `%${username}%`),
|
||||
eq(schema.user.isDisabled, false),
|
||||
eq(schema.user.adminType, "Regular User"),
|
||||
),
|
||||
)
|
||||
.where(ilike(schema.user.username, `%${username}%`))
|
||||
}
|
||||
|
||||
/**
|
||||
* 两条提交列表的用户名筛选。**两边都要匹配**:
|
||||
*
|
||||
* - `user_id in (改过名的当前用户名匹配到的账号)` —— 老师用现在的班级前缀查
|
||||
* `ks248`,要能查出这个人改名之前交的那些(生产快照:比赛提交里有 685 条
|
||||
* 挂在旧名字下);
|
||||
* - `submission.username ilike` —— 已删号的学生在 `user` 表里没有行,只剩提交里
|
||||
* 冻结的那份名字;顺带也让「按记得的旧名字查」还查得到。
|
||||
*
|
||||
* 统计接口那边只按 user_id 筛(口径是「花名册上这个班谁做完了」,已删号的人本来
|
||||
* 就不在花名册里);这两条是公开列表,不该因为改名或删号少给记录,所以取并集。
|
||||
*/
|
||||
function usernameFilter(username: string) {
|
||||
const like = `%${username}%`
|
||||
return or(
|
||||
sql`${schema.submission.userId} in (select ${schema.user.id} from ${schema.user} where ${ilike(schema.user.username, like)})`,
|
||||
ilike(schema.submission.username, like),
|
||||
)!
|
||||
}
|
||||
|
||||
/**
|
||||
* 两个统计接口共用的范围:时间窗 + 题号。**用户名不在里面** —— 统计那边是
|
||||
* ilike 模糊匹配(填 ks251 要匹配整个班),明细那边必须精确到人,口径不同。
|
||||
* 两边都是先拿用户名去 `user` 表解析成 user_id,再按 user_id 筛提交。
|
||||
*/
|
||||
type StatisticsScope =
|
||||
| { ok: true; filters: SQL[]; problemCount: number }
|
||||
@@ -402,8 +433,24 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
||||
const filters = scope.filters
|
||||
|
||||
const username = c.req.query("username")?.trim()
|
||||
if (username) filters.push(ilike(schema.submission.username, `%${username}%`))
|
||||
// 用户名先解析成账号,再拿 user_id 去筛提交。这一趟查询挡在 Promise.all 前面,
|
||||
// 但换掉的是下面**四条**语句各一次的 submission 全表扫:`ilike` 走不了索引,
|
||||
// 换成 `user_id in (...)` 之后四条全走索引(生产快照实测单条 18448 → 537
|
||||
// buffers;同一个快照上整个接口查一个班 120~250ms → 10ms 上下),多这一次往返是赚的。
|
||||
const matched = username ? await matchedUsers(username) : []
|
||||
if (username) {
|
||||
const matchedIds = matched.map((row) => row.id)
|
||||
// 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失,
|
||||
// 「查无此班」会变成「全站统计」
|
||||
filters.push(
|
||||
matchedIds.length ? inArray(schema.submission.userId, matchedIds) : sql`false`,
|
||||
)
|
||||
}
|
||||
const where = and(...filters)
|
||||
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
|
||||
const rosterRows = matched.filter(
|
||||
(row) => !row.isDisabled && row.adminType === "Regular User",
|
||||
)
|
||||
|
||||
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
||||
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
|
||||
@@ -415,7 +462,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
||||
*/
|
||||
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
||||
|
||||
const [[totals], perUser, rosterRows] = await Promise.all([
|
||||
const [[totals], perUser] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: count(),
|
||||
@@ -426,18 +473,28 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
||||
.where(where),
|
||||
db
|
||||
.select({
|
||||
username: schema.submission.username,
|
||||
userId: schema.submission.userId,
|
||||
/**
|
||||
* 显示的是**当前**用户名,从 user 表 join 出来 —— 按 submission.username
|
||||
* 分组的话,改过名的学生会裂成新旧两行,两边各算各的,谁都够不到「全做完」。
|
||||
*
|
||||
* 已删号的学生 user 表里没有行,退回提交里冻结的那份名字(下面的
|
||||
* personCount 兜底就是给这种情况的)。
|
||||
*/
|
||||
username: sql<string>`coalesce(${schema.user.username}, max(${schema.submission.username}))`,
|
||||
className: schema.user.className,
|
||||
submissionCount: count(),
|
||||
acceptedCount: acceptedFilter.mapWith(Number),
|
||||
solvedCount: solvedFilter.mapWith(Number),
|
||||
judgingCount: judgingFilter.mapWith(Number),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(where)
|
||||
.groupBy(schema.submission.username)
|
||||
// user_id 定了 user 那一行就定了,把 username / class_name 一起放进 group by
|
||||
// 不会多分出组来,但省掉再对它们套一层聚合函数
|
||||
.groupBy(schema.submission.userId, schema.user.username, schema.user.className)
|
||||
.orderBy(desc(count())),
|
||||
// 只有指定了用户名才有「班级人数」这个概念;不指定时分母无意义,旧后端也返回 0
|
||||
username ? matchedStudents(username) : Promise.resolve([]),
|
||||
])
|
||||
|
||||
const submissionCount = totals?.total ?? 0
|
||||
@@ -463,32 +520,24 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
||||
// 要等 acceptedUsers 定下来才能查,所以进不了上面那个 Promise.all
|
||||
const astOnlyByUserMap = await astOnlyByUser(
|
||||
where,
|
||||
acceptedUsers.map((row) => row.username),
|
||||
acceptedUsers.map((row) => row.userId),
|
||||
)
|
||||
|
||||
const submittedUsernames = new Set(perUser.map((row) => row.username))
|
||||
const classNames = new Map<string, string | null>()
|
||||
if (submittedUsernames.size) {
|
||||
const rows = await db
|
||||
.select({ username: schema.user.username, className: schema.user.className })
|
||||
.from(schema.user)
|
||||
.where(inArray(schema.user.username, [...submittedUsernames]))
|
||||
for (const row of rows) classNames.set(row.username, row.className)
|
||||
}
|
||||
const submittedUserIds = new Set(perUser.map((row) => row.userId))
|
||||
|
||||
const data = acceptedUsers.map((row) => ({
|
||||
username: row.username,
|
||||
className: classNames.get(row.username) ?? null,
|
||||
className: row.className,
|
||||
submissionCount: row.submissionCount,
|
||||
acceptedCount: row.acceptedCount,
|
||||
solvedCount: row.solvedCount,
|
||||
astOnlyCount: astOnlyByUserMap.get(row.username) ?? 0,
|
||||
astOnlyCount: astOnlyByUserMap.get(row.userId) ?? 0,
|
||||
judgingCount: row.judgingCount,
|
||||
correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount),
|
||||
}))
|
||||
|
||||
const dataUnaccepted = rosterRows
|
||||
.filter((row) => !submittedUsernames.has(row.username))
|
||||
.filter((row) => !submittedUserIds.has(row.id))
|
||||
.map((row) => ({
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
@@ -496,21 +545,21 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
||||
|
||||
// 交了但一次没对的。**按花名册取**,和 dataUnaccepted 同一个范围 ——
|
||||
// 不指定用户名时没有花名册,这一栏也就跟着为空,不会冒出一堆别的班的人。
|
||||
const rosterNames = new Map(rosterRows.map((row) => [row.username, row.className]))
|
||||
const rosterClassNames = new Map(rosterRows.map((row) => [row.id, row.className]))
|
||||
// 交了但没做完的:包括一道都没对的,也包括三道里做出两道的
|
||||
const attemptedRows = perUser.filter(
|
||||
(row) => !isDone(row) && rosterNames.has(row.username),
|
||||
(row) => !isDone(row) && rosterClassNames.has(row.userId),
|
||||
)
|
||||
const failureByUser = await lastFailureByUser(
|
||||
where,
|
||||
attemptedRows.map((row) => row.username),
|
||||
attemptedRows.map((row) => row.userId),
|
||||
)
|
||||
const dataAttempted = attemptedRows.map((row) => ({
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, rosterNames.get(row.username) ?? null),
|
||||
realName: stripClassPrefix(row.username, rosterClassNames.get(row.userId) ?? null),
|
||||
submissionCount: row.submissionCount,
|
||||
solvedCount: row.solvedCount,
|
||||
lastFailure: failureByUser.get(row.username) ?? null,
|
||||
lastFailure: failureByUser.get(row.userId) ?? null,
|
||||
}))
|
||||
|
||||
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
|
||||
@@ -548,11 +597,28 @@ submissionRoutes.get("/submissions/statistics/items", requireTeacher, async (c)
|
||||
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)
|
||||
|
||||
// 多取一条,好知道是不是被截断了
|
||||
const rows = await db
|
||||
.select({ id: schema.submission.id, result: schema.submission.result })
|
||||
.from(schema.submission)
|
||||
.where(and(...scope.filters, eq(schema.submission.username, username)))
|
||||
.where(and(...scope.filters, identity))
|
||||
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
|
||||
.limit(STATISTICS_ITEMS_LIMIT + 1)
|
||||
|
||||
@@ -690,7 +756,14 @@ const submissionListColumns = {
|
||||
userId: schema.submission.userId,
|
||||
// 题单闸门要按题定位,序列化本身用不到它
|
||||
problemId: schema.submission.problemId,
|
||||
username: schema.submission.username,
|
||||
/**
|
||||
* 显示**当前**用户名,和统计面板、个人主页对齐。列表里读的那份是提交时冻结的
|
||||
* 快照,改过名的学生会显示旧名字 —— 按 `ks248` 筛出来的行却写着
|
||||
* `ks24数媒1班ksXXX`,看着像筛错了。
|
||||
*
|
||||
* 已删号的学生 user 表里没有行,退回冻结的那份(否则整列空着)。
|
||||
*/
|
||||
username: sql<string>`coalesce(${schema.user.username}, ${schema.submission.username})`,
|
||||
result: schema.submission.result,
|
||||
language: schema.submission.language,
|
||||
statisticInfo: schema.submission.statisticInfo,
|
||||
@@ -774,6 +847,9 @@ async function paginateSubmissionRows(
|
||||
.select(submissionListColumns)
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
// 取当前用户名用。left join 不是 inner —— 已删号的学生这边没有行,
|
||||
// inner join 会把他们的提交整条从列表里抹掉
|
||||
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||
.where(cursor ? and(where, cursor) : where)
|
||||
.orderBy(...order)
|
||||
|
||||
@@ -823,7 +899,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
||||
const language = c.req.query("language")?.trim()
|
||||
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
|
||||
if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
|
||||
else if (username) filters.push(ilike(schema.submission.username, `%${username}%`))
|
||||
else if (username) filters.push(usernameFilter(username))
|
||||
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, Number(result)))
|
||||
if (language) filters.push(eq(schema.submission.language, language))
|
||||
if (c.req.query("today") === "1") filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`)
|
||||
@@ -880,7 +956,7 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
|
||||
const result = c.req.query("result")
|
||||
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
|
||||
if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
|
||||
else if (username) filters.push(ilike(schema.submission.username, `%${username}%`))
|
||||
else if (username) filters.push(usernameFilter(username))
|
||||
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, Number(result)))
|
||||
if (contestStatus(contest) !== "1") filters.push(sql`${schema.submission.createTime} >= ${contest.startTime}`)
|
||||
const where = and(...filters)
|
||||
@@ -893,7 +969,8 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
totalQuery,
|
||||
db.select(submissionListColumns).from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId)).where(where)
|
||||
.orderBy(desc(schema.submission.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
// 这里不挂题单防作弊闸门(对比公开列表):题单里的题必定是非比赛题——加题时卡了
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Worker } from "bullmq"
|
||||
|
||||
import { config } from "./config"
|
||||
import { judgeQueueName, type JudgeJobData } from "./judge/job"
|
||||
import { judgeSubmission } from "./judge/run"
|
||||
import { failAbandonedSubmission, judgeSubmission } from "./judge/run"
|
||||
import { flowchartQueueName, type FlowchartJobData } from "./flowchart/job"
|
||||
import { evaluateFlowchart } from "./flowchart/run"
|
||||
import { createBlockingRedis } from "./redis"
|
||||
@@ -29,8 +29,17 @@ const flowchartWorker = new Worker<FlowchartJobData>(
|
||||
worker.on("ready", () => {
|
||||
console.log(`Judge worker ready (concurrency=${config.judgeConcurrency})`)
|
||||
})
|
||||
worker.on("failed", (job, error) => {
|
||||
worker.on("failed", async (job, error) => {
|
||||
console.error(`Judge job ${job?.id ?? "unknown"} failed`, error)
|
||||
// 队列没配 attempts,失败即终局;worker 被杀掉那种 BullMQ 走完 stalled 重试也会
|
||||
// 落到这里。不在这里写一个终态,提交就永远停在「等待评分」,没有任何人会再管它。
|
||||
const submissionId = job?.data.submissionId
|
||||
if (!submissionId) return
|
||||
try {
|
||||
await failAbandonedSubmission(submissionId, error)
|
||||
} catch (markError) {
|
||||
console.error(`Failed to mark submission ${submissionId} as system error`, markError)
|
||||
}
|
||||
})
|
||||
worker.on("error", (error) => {
|
||||
console.error("Judge worker error", error)
|
||||
|
||||
Reference in New Issue
Block a user