1 parent
23844ae301
commit
d25b5dbe6d
268 files changed
+3684
-8861
No files matched your search
@@ -11,28 +11,13 @@ import {
|
||||
type SubmissionStatisticsItems,
|
||||
type TodaySubmissionStatistics,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
ilike,
|
||||
inArray,
|
||||
isNull,
|
||||
or,
|
||||
sql,
|
||||
type SQL,
|
||||
} from "drizzle-orm"
|
||||
import { and, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
|
||||
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, type JudgeStatusValue } from "../judge/status"
|
||||
import { type ContestEnv } from "../services/contest"
|
||||
import { getBooleanOption } from "../services/options"
|
||||
import { localTime, dayStart } from "../time"
|
||||
@@ -54,118 +39,96 @@ function judgedRate(accepted: number, judged: number) {
|
||||
* 按钟点切用 `localTime()`,不能写 `extract(hour from create_time)` ——
|
||||
* 后者按数据库会话时区算,容器是 UTC,整张分布图会整体左移 8 小时。
|
||||
*/
|
||||
submissionStatisticsRoutes.get(
|
||||
"/submissions/today-statistics",
|
||||
optionalAuth,
|
||||
async (c) => {
|
||||
/**
|
||||
* 「提交列表对学生全开」关掉时(考试那种场合)不给热门题这张表 —— 总数、正确率
|
||||
* 这些聚合数原本就从公开的 today-count 看得出来,但「哪几道题在被刷」已经贴近
|
||||
* 提交列表本身的内容了,得跟着同一个开关走。数字照给,不然标签说 21、弹框说 0。
|
||||
*/
|
||||
const showProblems =
|
||||
(await getBooleanOption("submission_list_show_all", true)) ||
|
||||
isAdminRole(c.get("user"))
|
||||
const where = and(
|
||||
isNull(schema.submission.contestId),
|
||||
sql`${schema.submission.createTime} >= ${dayStart()}`,
|
||||
)
|
||||
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
||||
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
|
||||
const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int`
|
||||
submissionStatisticsRoutes.get("/submissions/today-statistics", optionalAuth, async (c) => {
|
||||
/**
|
||||
* 「提交列表对学生全开」关掉时(考试那种场合)不给热门题这张表 —— 总数、正确率
|
||||
* 这些聚合数原本就从公开的 today-count 看得出来,但「哪几道题在被刷」已经贴近
|
||||
* 提交列表本身的内容了,得跟着同一个开关走。数字照给,不然标签说 21、弹框说 0。
|
||||
*/
|
||||
const showProblems =
|
||||
(await getBooleanOption("submission_list_show_all", true)) || isAdminRole(c.get("user"))
|
||||
const where = and(
|
||||
isNull(schema.submission.contestId),
|
||||
sql`${schema.submission.createTime} >= ${dayStart()}`,
|
||||
)
|
||||
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
||||
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
|
||||
const hour = sql<number>`extract(hour from ${localTime(schema.submission.createTime)})::int`
|
||||
|
||||
const [[totals], hourRows, languageRows, resultRows, problemRows] =
|
||||
await Promise.all([
|
||||
db
|
||||
const [[totals], hourRows, languageRows, resultRows, problemRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: count(),
|
||||
accepted: acceptedFilter.mapWith(Number),
|
||||
judging: judgingFilter.mapWith(Number),
|
||||
userCount: sql<number>`count(distinct ${schema.submission.userId})`.mapWith(Number),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(where),
|
||||
db.select({ hour, value: count() }).from(schema.submission).where(where).groupBy(hour),
|
||||
db
|
||||
.select({ language: schema.submission.language, value: count() })
|
||||
.from(schema.submission)
|
||||
.where(where)
|
||||
.groupBy(schema.submission.language)
|
||||
.orderBy(desc(count())),
|
||||
db
|
||||
.select({ result: schema.submission.result, value: count() })
|
||||
.from(schema.submission)
|
||||
.where(where)
|
||||
.groupBy(schema.submission.result)
|
||||
.orderBy(desc(count())),
|
||||
showProblems
|
||||
? db
|
||||
.select({
|
||||
total: count(),
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
value: count(),
|
||||
accepted: acceptedFilter.mapWith(Number),
|
||||
judging: judgingFilter.mapWith(Number),
|
||||
userCount:
|
||||
sql<number>`count(distinct ${schema.submission.userId})`.mapWith(
|
||||
Number,
|
||||
),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(where),
|
||||
db
|
||||
.select({ hour, value: count() })
|
||||
.from(schema.submission)
|
||||
.where(where)
|
||||
.groupBy(hour),
|
||||
db
|
||||
.select({ language: schema.submission.language, value: count() })
|
||||
.from(schema.submission)
|
||||
.where(where)
|
||||
.groupBy(schema.submission.language)
|
||||
.orderBy(desc(count())),
|
||||
db
|
||||
.select({ result: schema.submission.result, value: count() })
|
||||
.from(schema.submission)
|
||||
.where(where)
|
||||
.groupBy(schema.submission.result)
|
||||
.orderBy(desc(count())),
|
||||
showProblems
|
||||
? db
|
||||
.select({
|
||||
displayId: schema.problem.displayId,
|
||||
title: schema.problem.title,
|
||||
value: count(),
|
||||
accepted: acceptedFilter.mapWith(Number),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.innerJoin(
|
||||
schema.problem,
|
||||
eq(schema.problem.id, schema.submission.problemId),
|
||||
)
|
||||
// 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西
|
||||
.where(and(where, eq(schema.problem.visible, true)))
|
||||
.groupBy(
|
||||
schema.problem.id,
|
||||
schema.problem.displayId,
|
||||
schema.problem.title,
|
||||
)
|
||||
.orderBy(desc(count()))
|
||||
.limit(10)
|
||||
: [],
|
||||
])
|
||||
.innerJoin(schema.problem, eq(schema.problem.id, schema.submission.problemId))
|
||||
// 隐藏题目不出现在这张表里:接口不需要登录,标题本身就是不该外露的东西
|
||||
.where(and(where, eq(schema.problem.visible, true)))
|
||||
.groupBy(schema.problem.id, schema.problem.displayId, schema.problem.title)
|
||||
.orderBy(desc(count()))
|
||||
.limit(10)
|
||||
: [],
|
||||
])
|
||||
|
||||
const total = totals?.total ?? 0
|
||||
const judging = totals?.judging ?? 0
|
||||
const hours = Array.from({ length: 24 }, () => 0)
|
||||
for (const row of hourRows) hours[row.hour] = row.value
|
||||
const total = totals?.total ?? 0
|
||||
const judging = totals?.judging ?? 0
|
||||
const hours = Array.from({ length: 24 }, () => 0)
|
||||
for (const row of hourRows) hours[row.hour] = row.value
|
||||
|
||||
return success(c, {
|
||||
total,
|
||||
accepted: totals?.accepted ?? 0,
|
||||
judging,
|
||||
correctRate: judgedRate(totals?.accepted ?? 0, total - judging),
|
||||
userCount: totals?.userCount ?? 0,
|
||||
hours,
|
||||
languages: languageRows.map((row) => ({
|
||||
language: row.language,
|
||||
count: row.value,
|
||||
})),
|
||||
results: resultRows.map((row) => ({
|
||||
result: row.result,
|
||||
count: row.value,
|
||||
})),
|
||||
problems: problemRows.map((row) => ({
|
||||
problemDisplayId: row.displayId,
|
||||
problemTitle: row.title,
|
||||
count: row.value,
|
||||
acceptedCount: row.accepted,
|
||||
})),
|
||||
} satisfies TodaySubmissionStatistics)
|
||||
},
|
||||
)
|
||||
return success(c, {
|
||||
total,
|
||||
accepted: totals?.accepted ?? 0,
|
||||
judging,
|
||||
correctRate: judgedRate(totals?.accepted ?? 0, total - judging),
|
||||
userCount: totals?.userCount ?? 0,
|
||||
hours,
|
||||
languages: languageRows.map((row) => ({
|
||||
language: row.language,
|
||||
count: row.value,
|
||||
})),
|
||||
results: resultRows.map((row) => ({
|
||||
result: row.result,
|
||||
count: row.value,
|
||||
})),
|
||||
problems: problemRows.map((row) => ({
|
||||
problemDisplayId: row.displayId,
|
||||
problemTitle: row.title,
|
||||
count: row.value,
|
||||
acceptedCount: row.accepted,
|
||||
})),
|
||||
} satisfies TodaySubmissionStatistics)
|
||||
})
|
||||
|
||||
/**
|
||||
* 统计接口共用的时间窗解析。旧后端 `end` 必填、`start` 可选(不给就是「全部时段」)。
|
||||
*/
|
||||
function statisticsRange(c: {
|
||||
req: { query(name: string): string | undefined }
|
||||
}) {
|
||||
function statisticsRange(c: { req: { query(name: string): string | undefined } }) {
|
||||
const end = c.req.query("end")?.trim()
|
||||
if (!end) return null
|
||||
const start = c.req.query("start")?.trim()
|
||||
@@ -360,10 +323,7 @@ export async function usernameFilter(username: string) {
|
||||
* 和公开题撞号是常态),而公开提交从不指向比赛题(快照核过,0 条)。
|
||||
* 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。
|
||||
*/
|
||||
export async function problemFilter(
|
||||
displayId: string,
|
||||
contestId: number | null,
|
||||
) {
|
||||
export async function problemFilter(displayId: string, contestId: number | null) {
|
||||
const problems = await db
|
||||
.select({ id: schema.problem.id })
|
||||
.from(schema.problem)
|
||||
@@ -409,8 +369,7 @@ async function statisticsScope(c: {
|
||||
isNull(schema.submission.contestId),
|
||||
sql`${schema.submission.createTime} <= ${range.end}`,
|
||||
]
|
||||
if (range.start)
|
||||
filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
|
||||
if (range.start) filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
|
||||
|
||||
const displayIds = parseDisplayIds(c.req.query("problemDisplayId") ?? "")
|
||||
if (displayIds.length > STATISTICS_MAX_PROBLEMS) {
|
||||
@@ -437,207 +396,190 @@ async function statisticsScope(c: {
|
||||
return { ok: true, filters, problemCount: displayIds.length }
|
||||
}
|
||||
|
||||
submissionStatisticsRoutes.get(
|
||||
"/submissions/statistics",
|
||||
requireTeacher,
|
||||
async (c) => {
|
||||
const scope = await statisticsScope(c)
|
||||
if (!scope.ok) return failure(c, scope.status, scope.code, scope.message)
|
||||
const filters = scope.filters
|
||||
submissionStatisticsRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
||||
const scope = await statisticsScope(c)
|
||||
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 前面,
|
||||
// 但换掉的是下面**四条**语句各一次的 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 username = c.req.query("username")?.trim()
|
||||
// 用户名先解析成账号,再拿 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)})`
|
||||
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
|
||||
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
|
||||
/**
|
||||
* **解决的题数**,不是通过的提交条数。同一道题重复 AC(改完再交一次仍然对)
|
||||
* 在这里只算一道 —— 表格那一列叫「已解决」,数条数就名不副实了。
|
||||
* 指定了题号时它最多是 1,不指定时才看得出差别(老师查「这节课全班」就是这种)。
|
||||
*/
|
||||
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
||||
|
||||
const [[totals], perUser] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: count(),
|
||||
accepted: acceptedFilter.mapWith(Number),
|
||||
judging: judgingFilter.mapWith(Number),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(where),
|
||||
db
|
||||
.select({
|
||||
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,
|
||||
// 不传用户名时「交了没全对」那一栏靠它把教师和禁用账号挡在外面 ——
|
||||
// 传了用户名时这件事是花名册(rosterRows)做的
|
||||
isDisabled: schema.user.isDisabled,
|
||||
adminType: schema.user.adminType,
|
||||
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)
|
||||
// user_id 定了 user 那一行就定了,把 username / class_name 一起放进 group by
|
||||
// 不会多分出组来,但省掉再对它们套一层聚合函数
|
||||
.groupBy(
|
||||
schema.submission.userId,
|
||||
schema.user.username,
|
||||
schema.user.className,
|
||||
schema.user.isDisabled,
|
||||
schema.user.adminType,
|
||||
)
|
||||
}
|
||||
const where = and(...filters)
|
||||
// 花名册:只有未禁用的普通用户算进班级人数和「谁没做」,教师和管理员不进分母
|
||||
const rosterRows = matched.filter(
|
||||
(row) => !row.isDisabled && row.adminType === "Regular User",
|
||||
)
|
||||
.orderBy(desc(count())),
|
||||
])
|
||||
|
||||
const acceptedFilter = sql`count(*) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
||||
// 判题中的条数。要单独数出来,正确率的分母才能把它们摘掉
|
||||
const judgingFilter = sql`count(*) filter (where ${inArray(schema.submission.result, UNJUDGED_RESULTS)})`
|
||||
/**
|
||||
* **解决的题数**,不是通过的提交条数。同一道题重复 AC(改完再交一次仍然对)
|
||||
* 在这里只算一道 —— 表格那一列叫「已解决」,数条数就名不副实了。
|
||||
* 指定了题号时它最多是 1,不指定时才看得出差别(老师查「这节课全班」就是这种)。
|
||||
*/
|
||||
const solvedFilter = sql`count(distinct ${schema.submission.problemId}) filter (where ${inArray(schema.submission.result, ACCEPTED_RESULTS)})`
|
||||
const submissionCount = totals?.total ?? 0
|
||||
const acceptedCount = totals?.accepted ?? 0
|
||||
const judgingCount = totals?.judging ?? 0
|
||||
// 正确率的分母是**判完的条数**,不是总条数
|
||||
const judgedCount = submissionCount - judgingCount
|
||||
|
||||
const [[totals], perUser] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
total: count(),
|
||||
accepted: acceptedFilter.mapWith(Number),
|
||||
judging: judgingFilter.mapWith(Number),
|
||||
})
|
||||
.from(schema.submission)
|
||||
.where(where),
|
||||
db
|
||||
.select({
|
||||
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,
|
||||
// 不传用户名时「交了没全对」那一栏靠它把教师和禁用账号挡在外面 ——
|
||||
// 传了用户名时这件事是花名册(rosterRows)做的
|
||||
isDisabled: schema.user.isDisabled,
|
||||
adminType: schema.user.adminType,
|
||||
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)
|
||||
// user_id 定了 user 那一行就定了,把 username / class_name 一起放进 group by
|
||||
// 不会多分出组来,但省掉再对它们套一层聚合函数
|
||||
.groupBy(
|
||||
schema.submission.userId,
|
||||
schema.user.username,
|
||||
schema.user.className,
|
||||
schema.user.isDisabled,
|
||||
schema.user.adminType,
|
||||
)
|
||||
.orderBy(desc(count())),
|
||||
])
|
||||
/**
|
||||
* 「做完了」的判定。**指定了几道题,就要几道都解决**(这是教师选的口径:
|
||||
* 「今天布置三道,谁全做完了」)—— 做出两道差一道的人落在「交了没全对」那一栏,
|
||||
* 那里带着 `solvedCount`,老师看得出他差几道。
|
||||
*
|
||||
* 只填一道题时 `solvedCount >= 1` 和原来的 `acceptedCount > 0` 完全等价;
|
||||
* 不填题号时无所谓「全部」,退回「至少做出一道」。
|
||||
*/
|
||||
const requiredSolved = scope.problemCount
|
||||
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
|
||||
requiredSolved > 0 ? row.solvedCount >= requiredSolved : row.acceptedCount > 0
|
||||
|
||||
const submissionCount = totals?.total ?? 0
|
||||
const acceptedCount = totals?.accepted ?? 0
|
||||
const judgingCount = totals?.judging ?? 0
|
||||
// 正确率的分母是**判完的条数**,不是总条数
|
||||
const judgedCount = submissionCount - judgingCount
|
||||
/**
|
||||
* 「提交记录」那张表列的是**窗口里交过东西的所有人**,`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),
|
||||
)
|
||||
|
||||
/**
|
||||
* 「做完了」的判定。**指定了几道题,就要几道都解决**(这是教师选的口径:
|
||||
* 「今天布置三道,谁全做完了」)—— 做出两道差一道的人落在「交了没全对」那一栏,
|
||||
* 那里带着 `solvedCount`,老师看得出他差几道。
|
||||
*
|
||||
* 只填一道题时 `solvedCount >= 1` 和原来的 `acceptedCount > 0` 完全等价;
|
||||
* 不填题号时无所谓「全部」,退回「至少做出一道」。
|
||||
*/
|
||||
const requiredSolved = scope.problemCount
|
||||
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
|
||||
requiredSolved > 0
|
||||
? row.solvedCount >= requiredSolved
|
||||
: row.acceptedCount > 0
|
||||
const submittedUserIds = new Set(perUser.map((row) => row.userId))
|
||||
|
||||
/**
|
||||
* 「提交记录」那张表列的是**窗口里交过东西的所有人**,`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 data = perUser.map((row) => ({
|
||||
username: row.username,
|
||||
className: row.className,
|
||||
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),
|
||||
}))
|
||||
|
||||
const submittedUserIds = new Set(perUser.map((row) => row.userId))
|
||||
|
||||
const data = perUser.map((row) => ({
|
||||
const dataUnaccepted = rosterRows
|
||||
.filter((row) => !submittedUserIds.has(row.id))
|
||||
.map((row) => ({
|
||||
username: row.username,
|
||||
className: row.className,
|
||||
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),
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
}))
|
||||
|
||||
const dataUnaccepted = rosterRows
|
||||
.filter((row) => !submittedUserIds.has(row.id))
|
||||
.map((row) => ({
|
||||
username: row.username,
|
||||
realName: stripClassPrefix(row.username, row.className),
|
||||
}))
|
||||
|
||||
/**
|
||||
* 交了但没做完的:包括一道都没对的,也包括三道里做出两道的。
|
||||
*
|
||||
* **传了用户名时按花名册取**,和 dataUnaccepted 同一个范围,查一个班不会冒出
|
||||
* 一堆别的班的人。
|
||||
*
|
||||
* 不传用户名时没有花名册,这一栏原先跟着空掉 —— 于是只交了错误答案的学生
|
||||
* 「已完成」那张表进不去(没做完)、「未完成」那一栏也没有,整个人从屏幕上
|
||||
* 消失,看起来就像统计只认成功的提交。这种情况退回「有提交但没做完的全部人」,
|
||||
* 教师和禁用账号照样排除(否则老师自己试题留下的错误提交会混进点名名单)。
|
||||
*
|
||||
* 「还没交」那一栏没有花名册是真的算不出来(不知道该有谁),仍然为空。
|
||||
*/
|
||||
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"
|
||||
})
|
||||
const failureByUser = await lastFailureByUser(
|
||||
where,
|
||||
attemptedRows.map((row) => row.userId),
|
||||
)
|
||||
const dataAttempted = attemptedRows.map((row) => ({
|
||||
username: row.username,
|
||||
/**
|
||||
* 交了但没做完的:包括一道都没对的,也包括三道里做出两道的。
|
||||
*
|
||||
* **传了用户名时按花名册取**,和 dataUnaccepted 同一个范围,查一个班不会冒出
|
||||
* 一堆别的班的人。
|
||||
*
|
||||
* 不传用户名时没有花名册,这一栏原先跟着空掉 —— 于是只交了错误答案的学生
|
||||
* 「已完成」那张表进不去(没做完)、「未完成」那一栏也没有,整个人从屏幕上
|
||||
* 消失,看起来就像统计只认成功的提交。这种情况退回「有提交但没做完的全部人」,
|
||||
* 教师和禁用账号照样排除(否则老师自己试题留下的错误提交会混进点名名单)。
|
||||
*
|
||||
* 「还没交」那一栏没有花名册是真的算不出来(不知道该有谁),仍然为空。
|
||||
* 剥前缀只在**查了某个班**的时候做:那时满屏都是同一个班,留着 `ks251` 是噪音。
|
||||
* 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁,
|
||||
* 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。
|
||||
*/
|
||||
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"
|
||||
})
|
||||
const failureByUser = await lastFailureByUser(
|
||||
where,
|
||||
attemptedRows.map((row) => row.userId),
|
||||
)
|
||||
const dataAttempted = attemptedRows.map((row) => ({
|
||||
username: row.username,
|
||||
/**
|
||||
* 剥前缀只在**查了某个班**的时候做:那时满屏都是同一个班,留着 `ks251` 是噪音。
|
||||
* 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁,
|
||||
* 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。
|
||||
*/
|
||||
realName: username
|
||||
? stripClassPrefix(row.username, row.className)
|
||||
: row.username,
|
||||
submissionCount: row.submissionCount,
|
||||
solvedCount: row.solvedCount,
|
||||
lastFailure: failureByUser.get(row.userId) ?? null,
|
||||
}))
|
||||
realName: username ? stripClassPrefix(row.username, row.className) : row.username,
|
||||
submissionCount: row.submissionCount,
|
||||
solvedCount: row.solvedCount,
|
||||
lastFailure: failureByUser.get(row.userId) ?? null,
|
||||
}))
|
||||
|
||||
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
|
||||
// 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是
|
||||
// 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。
|
||||
let personCount = rosterRows.length
|
||||
if (personCount && personCount < doneCount) personCount = doneCount
|
||||
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
|
||||
// 旧后端在这之前还先算了一个 person_rate 一起下发,前端从来没读过它(完成度是
|
||||
// 前端自己按「减掉请假人数之后的分母」重算的),所以这条链路上只留 person_count。
|
||||
let personCount = rosterRows.length
|
||||
if (personCount && personCount < doneCount) personCount = doneCount
|
||||
|
||||
return success(c, {
|
||||
submissionCount,
|
||||
acceptedCount,
|
||||
judgingCount,
|
||||
correctRate: judgedRate(acceptedCount, judgedCount),
|
||||
personCount,
|
||||
data,
|
||||
dataUnaccepted,
|
||||
dataAttempted,
|
||||
} satisfies SubmissionStatistics)
|
||||
},
|
||||
)
|
||||
return success(c, {
|
||||
submissionCount,
|
||||
acceptedCount,
|
||||
judgingCount,
|
||||
correctRate: judgedRate(acceptedCount, judgedCount),
|
||||
personCount,
|
||||
data,
|
||||
dataUnaccepted,
|
||||
dataAttempted,
|
||||
} satisfies SubmissionStatistics)
|
||||
})
|
||||
|
||||
/**
|
||||
* 统计面板展开一行时拉这个人的提交明细。
|
||||
@@ -646,58 +588,50 @@ submissionStatisticsRoutes.get(
|
||||
* 这边是「点开的这一行是谁」。时间窗和题号沿用同一个 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")
|
||||
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)
|
||||
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)
|
||||
/**
|
||||
* 展开的那一行给的是**当前**用户名,先换成 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)
|
||||
// 多取一条,好知道是不是被截断了
|
||||
// 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)
|
||||
},
|
||||
)
|
||||
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
|
||||
return success(c, {
|
||||
items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
|
||||
truncated,
|
||||
} satisfies SubmissionStatisticsItems)
|
||||
})
|
||||
Reference in new issue
Block a user