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:
1 parent
ed55ee5af6
commit
26c8d65e89
10 files changed
+156
-266
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user