Files
OJ2/apps/api/src/routes/submission-statistics.ts
T
xuyueandClaude Opus 5.5 2bda05a978
Deploy / deploy (push) Canceled after 0s
feat(名字): 界面上不再显示 ks + 班号前缀,混着几个班的地方名字后面写班级
- 学生名字一律只显示名字(老号 ks232张三 显示成 张三),顶栏、首页问候、悬停提示、请假名单、比赛成绩表都一样
- 全服 / 年级排名、全部提交、几个班同时举手时,名字后面淡写班级;提交列表的名字列窄,只写班号(陈长 258)
- AI 班级分析点名「要多关心的同学」时不再带 ks 前缀
- 建号查重补漏:同班同名也比真名(加过数字的张三2、编号制老号);名字自己带数字(王芳1)也认得出被占;
  导入时后端再拦一次同班同名,建号页要等查重结果对上当前名单才能点
- 登录名单换成新接口 /classes/:c/members(名字按界面规则剥,换过班的老号不再露出全名),
  旧接口原样留给上线时还开着旧登录页的机房电脑
- 后台手写老格式 ks232张三 新建用户、没选班时按前缀补班级;PUT /me/profile 只带真名时不再 500

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:41:49 +08:00

978 lines
42 KiB
TypeScript

/**
* 教师统计:今日提交分布、按学生/题目的统计面板、展开行的提交明细。
*
* 从 submission.ts 拆出来的一整块。**挂载位置不能动**:submission.ts 在原位置
* `route("/", submissionStatisticsRoutes)`,必须排在 `/submissions/:id` 之前,
* 否则 `/submissions/statistics` 会被当成 id 吞掉(Hono 按注册顺序匹配)。
*/
import {
displayName,
FLOWCHART_PASS_GRADES,
NO_CLASS,
STUDENT_ROLES,
type ProblemLanguage,
type SubmissionLessons,
type SubmissionStatistics,
type SubmissionStatisticsGrid,
type TodaySubmissionStatistics,
} from '@oj2/contract'
import { and, count, desc, eq, gte, inArray, isNull, lte, 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 { type ContestEnv } from '../services/contest'
import { getBooleanOption } from '../services/options'
import { lessonDoneCounts } from '../services/lesson'
import { calendarDay, dayStart, localTime } from '../time'
import { isAdminRole, parseDisplayIds, rounded, scopedUsers } from './helpers'
export const submissionStatisticsRoutes = new Hono<ContestEnv>()
const ACCEPTED_RESULTS: JudgeStatusValue[] = [JudgeStatus.ACCEPTED, JudgeStatus.AST_CHECK_FAILED]
/** 正确率。分母是判完的条数,一条都还没判完时给 0 而不是 NaN */
function judgedRate(accepted: number, judged: number) {
return judged > 0 ? rounded((accepted / judged) * 100) : 0
}
type TodayRow = {
problem_id: number
display_id: string
title: string
visible: boolean
user_id: number
result: JudgeStatusValue
language: ProblemLanguage
}
/**
* 「今日统计」(提交列表顶上那颗标签点开的),只剩学生版 —— 老师那一版并进了统计的
* 「一行一节课」(下面的 /submissions/statistics/lessons)。口径和那颗标签一致:东八区今天 + 非比赛提交。
*
* 今天的提交一天也就几百到一千来条,整批拉回来在进程里分组,比写五六条各自 group by 的
* SQL 好读,也只扫一遍 `create_time` 索引。
*/
submissionStatisticsRoutes.get('/submissions/today-statistics', optionalAuth, async (c) => {
const user = c.get('user')
/**
* 「提交列表对学生全开」关掉时(考试那种场合)不给题目这张表 —— 总数、正确率
* 这些聚合数原本就从公开的 today-count 看得出来,但「哪几道题在被刷」已经贴近
* 提交列表本身的内容了,得跟着同一个开关走。数字照给,不然标签说 21、弹框说 0。
*/
const showProblems = (await getBooleanOption('submission_list_show_all', true)) || isAdminRole(user)
const since = dayStart()
const [rows, [flow]] = await Promise.all([
db.execute<TodayRow>(sql`
select s.problem_id, p._id as display_id, p.title, p.visible,
s.user_id, s.result, s.language
from ${schema.submission} s
join ${schema.problem} p on p.id = s.problem_id
where s.contest_id is null and s.create_time >= ${since}
`),
db
.select({ value: count() })
.from(schema.flowchartSubmission)
.where(sql`${schema.flowchartSubmission.createTime} >= ${since}`),
])
const isAccepted = (row: TodayRow) => ACCEPTED_RESULTS.includes(row.result)
const isJudging = (row: TodayRow) => UNJUDGED_RESULTS.includes(row.result)
const rate = (list: TodayRow[]) => {
const judging = list.filter(isJudging).length
return judgedRate(list.filter(isAccepted).length, list.length - judging)
}
const distinctUsers = (list: TodayRow[]) => new Set(list.map((row) => row.user_id)).size
const acceptedUsers = (list: TodayRow[]) => new Set(list.filter(isAccepted).map((row) => row.user_id)).size
const groupBy = <K>(list: TodayRow[], key: (row: TodayRow) => K) => {
const map = new Map<K, TodayRow[]>()
for (const row of list) map.set(key(row), [...(map.get(key(row)) ?? []), row])
return map
}
const countBy = <K>(list: TodayRow[], key: (row: TodayRow) => K) =>
[...groupBy(list, key)].map(([value, group]) => ({ value, count: group.length })).sort((a, b) => b.count - a.count)
const judging = rows.filter(isJudging).length
const byProblem = groupBy(rows, (row) => row.problem_id)
const mineRows = user ? rows.filter((row) => row.user_id === user.id) : []
// 大家都在做
const problems = showProblems
? [...byProblem.values()]
.filter((list) => list[0]!.visible)
.map((list) => {
const mine = user ? list.filter((row) => row.user_id === user.id) : []
return {
problemDisplayId: list[0]!.display_id,
problemTitle: list[0]!.title,
userCount: distinctUsers(list),
acceptedUsers: acceptedUsers(list),
mine: !user ? null : mine.some(isAccepted) ? ('accepted' as const) : mine.length ? ('tried' as const) : ('none' as const),
}
})
.sort((a, b) => b.userCount - a.userCount)
.slice(0, 8)
: []
return success(c, {
asOf: new Date().toISOString(),
total: rows.length,
accepted: rows.filter(isAccepted).length,
judging,
correctRate: rate(rows),
userCount: distinctUsers(rows),
flowchartCount: flow?.value ?? 0,
languages: countBy(rows, (row) => row.language).map(({ value, count }) => ({
language: value,
count,
})),
results: countBy(rows, (row) => row.result).map(({ value, count }) => ({
result: value,
count,
})),
me: user
? {
total: mineRows.length,
solved: new Set(mineRows.filter(isAccepted).map((row) => row.problem_id)).size,
}
: null,
problems,
} satisfies TodaySubmissionStatistics)
})
/**
* 统计接口共用的时间窗解析。旧后端 `end` 必填、`start` 可选(不给就是「全部时段」)。
*/
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()
return { start: start || null, end }
}
/** 一次最多查几道题。课堂上一节课布置三五道,20 是留足了余量的上限 */
const STATISTICS_MAX_PROBLEMS = 20
/**
* 按题号(展示用的 _id)定位公开题目。**有一个找不到就整体报错**,不退化成「全部题目」——
* 否则教师打错一个字就会看到全站数据还以为是这几道题的。
*/
async function findPublicProblemsByDisplayIds(displayIds: string[]) {
const lowered = displayIds.map((id) => id.toLowerCase())
const rows = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(and(inArray(sql`lower(${schema.problem.displayId})`, lowered), isNull(schema.problem.contestId), eq(schema.problem.visible, true)))
const found = new Set(rows.map((row) => row.displayId.toLowerCase()))
const missing = displayIds.find((id) => !found.has(id.toLowerCase()))
return { ids: rows.map((row) => row.id), missing: missing ?? null }
}
/**
* 两个统计接口共用的范围:时间窗 + 题号。**用户名不在里面** —— 统计那边是
* ilike 模糊匹配(填 ks251 要匹配整个班),明细那边必须精确到人,口径不同。
* 两边都是先拿用户名去 `user` 表解析成 user_id,再按 user_id 筛提交。
*/
type StatisticsScope = { ok: true; filters: SQL[]; problemCount: number } | { ok: false; status: 400 | 404; code: string; message: string }
async function statisticsScope(c: { req: { query(name: string): string | undefined } }): Promise<StatisticsScope> {
const range = statisticsRange(c)
if (!range) {
return {
ok: false,
status: 400,
code: 'invalid-request',
message: 'end is required',
}
}
const filters = [isNull(schema.submission.contestId), sql`${schema.submission.createTime} <= ${range.end}`]
if (range.start) filters.push(sql`${schema.submission.createTime} >= ${range.start}`)
const displayIds = parseDisplayIds(c.req.query('problemDisplayId') ?? '')
if (displayIds.length > STATISTICS_MAX_PROBLEMS) {
return {
ok: false,
status: 400,
code: 'invalid-request',
message: `At most ${STATISTICS_MAX_PROBLEMS} problems`,
}
}
if (displayIds.length) {
const { ids, missing } = await findPublicProblemsByDisplayIds(displayIds)
if (missing) {
return {
ok: false,
status: 404,
code: 'problem-not-found',
message: `Problem ${missing} does not exist`,
}
}
filters.push(inArray(schema.submission.problemId, ids))
}
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
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 scoped = await scopedUsers(username, className)
const matched = scoped ?? []
if (scoped) {
const matchedIds = matched.map((row) => row.id)
// 一个账号都没匹配上时得留个恒假条件。少推一个 filter 的话过滤条件整个消失,
// 「查无此班」会变成「全站统计」
filters.push(matchedIds.length ? inArray(schema.submission.userId, matchedIds) : sql`false`)
}
const where = and(...filters)
// 花名册:只有未禁用的学生(含学生管理员)算进班级人数和「谁没做」,老师不进分母
const enrolled = matched.filter((row) => !row.isDisabled && STUDENT_ROLES.includes(row.adminType))
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, results] = 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())),
// 数字行里那根结果条(原来今日统计底部那根,并进来之后哪个时间段都有)
db
.select({ result: schema.submission.result, count: count() })
.from(schema.submission)
.where(where)
.groupBy(schema.submission.result)
.orderBy(desc(count())),
])
const submissionCount = totals?.total ?? 0
const acceptedCount = totals?.accepted ?? 0
const judgingCount = totals?.judging ?? 0
// 正确率的分母是**判完的条数**,不是总条数
const judgedCount = submissionCount - judgingCount
/**
* 「做完了」的判定。**指定了几道题,就要几道都解决**(这是教师选的口径:
* 「今天布置三道,谁全做完了」)—— 做出两道差一道的人落在「交了没全对」那一栏,
* 那里带着 `solvedCount`,老师看得出他差几道。
*
* 只填一道题时 `solvedCount >= 1` 和原来的 `acceptedCount > 0` 完全等价;
* 不填题号时无所谓「全部」,退回「至少做出一道」。
*/
const requiredSolved = scope.problemCount
const isDone = (row: { solvedCount: number; acceptedCount: number }) => (requiredSolved > 0 ? row.solvedCount >= requiredSolved : row.acceptedCount > 0)
/**
* 「提交记录」那张表列的是**窗口里交过东西的所有人**,`done` 标出谁做完了 ——
* 原来只给做完的人,于是一次没对的学生连同他的提交在这张表里根本不存在,
* 教师想看「他到底错在哪」得切到提交列表再翻。展开一行拉的是那个人的全部
* 提交,对错都在里面。
*
* 「完成人数」这些数字跟着 `done` 算,不是 `data.length`。
*/
const doneCount = perUser.filter(isDone).length
const submittedUserIds = new Set(perUser.map((row) => row.userId))
/**
* 「没填班级」不是一个真的班:两百来个夏令营、兴趣班、自己注册的号混在一起,「还没交」
* 列出来全是不相干的人,分母也跟着失真。花名册只取这段时间交过的人 —— 「交了没对」照常有,
* 「还没交」为空,和不选班时一样
*/
const rosterRows = className === NO_CLASS ? enrolled.filter((row) => submittedUserIds.has(row.id)) : enrolled
const data = perUser.map((row) => ({
username: row.username,
className: row.className,
submissionCount: row.submissionCount,
acceptedCount: row.acceptedCount,
solvedCount: row.solvedCount,
judgingCount: row.judgingCount,
correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount),
done: isDone(row),
}))
const dataUnaccepted = rosterRows
.filter((row) => !submittedUserIds.has(row.id))
.map((row) => ({
username: row.username,
realName: displayName(row.username),
}))
/**
* 交了但没做完的:包括一道都没对的,也包括三道里做出两道的。
*
* **传了用户名时按花名册取**,和 dataUnaccepted 同一个范围,查一个班不会冒出
* 一堆别的班的人。
*
* 不传用户名时没有花名册,这一栏原先跟着空掉 —— 于是只交了错误答案的学生
* 「已完成」那张表进不去(没做完)、「未完成」那一栏也没有,整个人从屏幕上
* 消失,看起来就像统计只认成功的提交。这种情况退回「有提交但没做完的全部人」,
* 教师和禁用账号照样排除(否则老师自己试题留下的错误提交会混进点名名单)。
*
* 「还没交」那一栏没有花名册是真的算不出来(不知道该有谁),仍然为空。
*/
const rosterIds = new Set(rosterRows.map((row) => row.id))
const attemptedRows = perUser.filter((row) => {
if (isDone(row)) return false
if (scoped) return rosterIds.has(row.userId)
return !row.isDisabled && row.adminType !== null && STUDENT_ROLES.includes(row.adminType)
})
const dataAttempted = attemptedRows.map((row) => ({
username: row.username,
/**
* 剥前缀只在**查了某个班**的时候做:那时满屏都是同一个班,留着 `ks251` 是噪音。
* 不传用户名的全站视图里各班混在一起,剥完只剩一串重名的名字,反而认不出谁,
* 所以原样给完整用户名。班名取 perUser join 出来的那一列,和花名册同一份数据。
*/
realName: scoped ? displayName(row.username) : row.username,
submissionCount: row.submissionCount,
solvedCount: row.solvedCount,
}))
// 「学生已删号但提交记录还在」时完成人数会大于花名册人数,分母兜到完成人数为止。
// 旧后端在这之前还先算了一个 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,
results,
} satisfies SubmissionStatistics)
})
/** 方块串一次最多给多少条。一个班一个月七八百条,5000 是全年级查一周的量 */
const GRID_LIMIT = 5000
/**
* 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema)。
* 班级和学生名的匹配和统计接口同一套(scopedUsers:班级精确、名字包含)。
*/
submissionStatisticsRoutes.get('/submissions/statistics/grid', 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() || 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`)
}
const rows = await db
.select({
id: schema.submission.id,
userId: schema.submission.userId,
problemId: schema.submission.problemId,
result: schema.submission.result,
createTime: schema.submission.createTime,
username: schema.user.username,
className: schema.user.className,
})
.from(schema.submission)
// 只要学生(含学生管理员):老师试题的提交不该出现在「谁做了几次」里
.innerJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(and(...filters, inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false)))
.orderBy(desc(schema.submission.createTime))
.limit(GRID_LIMIT + 1)
const truncated = rows.length > GRID_LIMIT
const kept = rows.slice(0, GRID_LIMIT).reverse()
// 题目:传了就按传的顺序,没传就按范围里第一次有人交的时间
const requested = parseDisplayIds(c.req.query('problemDisplayId') ?? '')
const problemIds = [...new Set(kept.map((row) => row.problemId))]
const problems = problemIds.length
? await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.problem)
.where(inArray(schema.problem.id, problemIds))
: []
const problemById = new Map(problems.map((row) => [row.id, row]))
const ordered = requested.length
? requested.flatMap((id) => {
const hit = problems.find((row) => row.displayId.toLowerCase() === id.toLowerCase())
return hit ? [hit] : []
})
: problemIds.map((id) => problemById.get(id)!).filter(Boolean)
// 传了题号但这些题在范围里没人交:表头仍然要有它们
if (requested.length && ordered.length < requested.length) {
const missing = requested.filter((id) => !ordered.some((row) => row.displayId.toLowerCase() === id.toLowerCase()))
const { ids } = await findPublicProblemsByDisplayIds(missing)
if (ids.length) {
const extra = await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
})
.from(schema.problem)
.where(inArray(schema.problem.id, ids))
ordered.push(...extra)
const rank = new Map(requested.map((id, i) => [id.toLowerCase(), i]))
ordered.sort((a, b) => (rank.get(a.displayId.toLowerCase()) ?? 0) - (rank.get(b.displayId.toLowerCase()) ?? 0))
}
}
const byUser = new Map<number, SubmissionStatisticsGrid['rows'][number]>()
for (const row of kept) {
let entry = byUser.get(row.userId)
if (!entry) {
entry = {
username: row.username,
realName: displayName(row.username),
className: row.className,
submissions: [],
}
byUser.set(row.userId, entry)
}
entry.submissions.push({
id: row.id,
problemDisplayId: problemById.get(row.problemId)?.displayId ?? '',
result: row.result,
createTime: row.createTime,
})
}
// 按班级汇总时要知道每个班多少人,「一道没交」才算得出来
const classSizes: Record<string, number> = {}
if (!scoped) {
const names = [...new Set(kept.map((row) => row.className).filter((name) => name !== null))]
if (names.length) {
const sizes = await db
.select({ className: schema.user.className, value: count() })
.from(schema.user)
.where(and(inArray(schema.user.className, names as string[]), inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false)))
.groupBy(schema.user.className)
for (const row of sizes) if (row.className) classSizes[row.className] = row.value
}
}
return success(c, {
problems: ordered.map((row) => ({ problemDisplayId: row.displayId, title: row.title })),
rows: [...byUser.values()],
classSizes,
truncated,
} satisfies SubmissionStatisticsGrid)
})
/** 相邻两条提交隔这么久,就当下课了、后面的是另一段 */
const LESSON_GAP_MINUTES = 30
/** 一段里有一道题这么多人做过,才算一节课(和原来今日统计、首页「班里在做」同一个门槛) */
const LESSON_MIN_USERS = 5
/** 最后一条在这么多分钟内,算「还在上课」 */
const LESSON_LIVE_MINUTES = 15
/** 一次列多少节课。「一节课」「今天」一般一两行,「这学期」「全部」有几百节,先给最近的这些 */
const LESSONS_DEFAULT = 50
const LESSONS_MAX = 500
type StatisticsRange = { start: string | null; end: string }
/** 这段时间里(按东八区日子)老师在课堂任务布置过的课,同一天的按布置先后 */
async function lessonPlans(range: StatisticsRange) {
return db
.select({
id: schema.classLesson.id,
className: schema.classLesson.className,
day: schema.classLesson.day,
problemIds: schema.classLesson.problemIds,
language: schema.classLesson.language,
mode: schema.classLesson.mode,
createdAt: schema.classLesson.createdAt,
})
.from(schema.classLesson)
.where(and(range.start ? gte(schema.classLesson.day, calendarDay(range.start)) : undefined, lte(schema.classLesson.day, calendarDay(range.end))))
.orderBy(schema.classLesson.createdAt, schema.classLesson.id)
}
type LessonPlan = Awaited<ReturnType<typeof lessonPlans>>[number]
/**
* 布置的课各自的数:那天这个班交布置的这几道题的提交(不论隔了多久、几个人;和推断的课
* 一样只算普通学生、不含比赛),加上全做对的人数(口径见 lessonDoneCounts)。时间窗之内一条提交都没有、布置时刻也不在
* 窗里的不列 —— 「那节课」那种按时段筛的,别把同一天另一节课带出来。
*/
async function assignedLessons(plans: LessonPlan[], range: StatisticsRange) {
if (!plans.length) return []
const planRows = JSON.stringify(
plans.flatMap((plan) =>
plan.problemIds.map((problemId) => ({
k: plan.id,
class_name: plan.className,
day: plan.day,
problem_id: problemId,
mode: plan.mode,
})),
),
)
const accepted = sql.join(
ACCEPTED_RESULTS.map((value) => sql`${value}`),
sql`, `,
)
const judging = sql.join(
UNJUDGED_RESULTS.map((value) => sql`${value}`),
sql`, `,
)
const plan = sql`(select * from jsonb_to_recordset(${planRows}::jsonb) as p(k int, class_name text, day text, problem_id int, mode text))`
const [counts, doneByLesson] = await Promise.all([
db.execute<{
k: number
problem_id: number | null
users: number
total: number
accepted: number
judging: number
first: string
last: string
}>(sql`
with p as ${plan},
-- 这节课要写代码就数代码提交,要画流程图就数流程图(画到 A / S 算对、还在评算没判完),
-- 先画再写两种都数。同一天先布置画流程图、再布置同样的题写代码,两节各数各的
s as (
select p.k, s.user_id, s.problem_id, s.result in (${accepted}) as ok, s.result in (${judging}) as pending, s.create_time
from ${schema.submission} s
join ${schema.user} u on u.id = s.user_id
join p on p.class_name = u.class_name and p.problem_id = s.problem_id
and p.day = to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD')
where s.contest_id is null and p.mode <> 'flowchart'
and s.create_time <= ${range.end}
${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
and u.admin_type = 'Regular User' and u.is_disabled = false
union all
select p.k, f.user_id, f.problem_id, (f.status = 2 and coalesce(f.ai_grade in (${sql.join(
FLOWCHART_PASS_GRADES.map((grade) => sql`${grade}`),
sql`, `,
)}), false)) as ok, f.status in (0, 1) as pending, f.create_time
from ${schema.flowchartSubmission} f
join ${schema.user} u on u.id = f.user_id
join p on p.class_name = u.class_name and p.problem_id = f.problem_id
and p.day = to_char(${localTime(sql`f.create_time`)}, 'YYYY-MM-DD')
where p.mode <> 'code'
and f.create_time <= ${range.end}
${range.start ? sql`and f.create_time >= ${range.start}` : sql``}
and u.admin_type = 'Regular User' and u.is_disabled = false
)
select k, problem_id,
count(distinct user_id)::int as users,
count(*)::int as total,
(count(*) filter (where ok))::int as accepted,
(count(*) filter (where pending))::int as judging,
min(create_time) as first,
max(create_time) as last
from s
group by grouping sets ((k), (k, problem_id))
`),
lessonDoneCounts(plans),
])
const totalByLesson = new Map([...counts].filter((row) => row.problem_id === null).map((row) => [row.k, row]))
const byProblem = Map.groupBy(
[...counts].filter((row) => row.problem_id !== null),
(row) => row.k,
)
const inRange = (time: string) => Date.parse(time) <= Date.parse(range.end) && (!range.start || Date.parse(time) >= Date.parse(range.start))
return plans.flatMap((lesson) => {
const total = totalByLesson.get(lesson.id)
if (!total && !inRange(lesson.createdAt)) return []
// 刚布置、还没人交:时段就是布置那一刻
const first = total ? new Date(total.first).toISOString() : lesson.createdAt
const last = total ? new Date(total.last).toISOString() : lesson.createdAt
return [
{
className: lesson.className,
day: lesson.day,
problemIds: lesson.problemIds,
mode: lesson.mode,
start: first,
end: last,
users: total?.users ?? 0,
total: total?.total ?? 0,
accepted: total?.accepted ?? 0,
judging: total?.judging ?? 0,
byProblem: (byProblem.get(lesson.id) ?? []).map((row) => ({
problemId: row.problem_id!,
total: row.total,
accepted: row.accepted,
judging: row.judging,
})),
doneCount: doneByLesson.get(lesson.id) ?? 0,
},
]
})
}
type LessonAggRow = {
level: number
class_name: string | null
day: string | null
sid: number | null
problem_id: number | null
result: JudgeStatusValue | null
/** plan = 布置的课的题(另算,见 assignedLessons);lesson = 推断出来的课;scattered = 零散 */
kind: 'plan' | 'lesson' | 'scattered' | null
users: number
total: number
accepted: number
judging: number
from_ps: number
first: string
last: string
accepted_users: number
}
/**
* 统计「一行一节课」的总览(口径见契约 submissionLessonsSchema)。
*
* 切课、零散提交、错得最多的题全在一条 SQL 里:先按「班 + 东八区哪一天」隔 30 分钟切段,
* 再标出哪几段算课,最后用 grouping sets 一次聚合出下面五种粒度 —— 不走方块串那个明细
* 接口在前端切,是因为那边最多给 5000 条,「这学期」就不止这个数,截断之后课会少算。
*/
submissionStatisticsRoutes.get('/submissions/statistics/lessons', requireTeacher, async (c) => {
const range = statisticsRange(c)
if (!range) return failure(c, 400, 'invalid-request', 'end is required')
const limit = Math.min(LESSONS_MAX, Math.max(1, Number.parseInt(c.req.query('limit') ?? '', 10) || LESSONS_DEFAULT))
const accepted = sql.join(
ACCEPTED_RESULTS.map((value) => sql`${value}`),
sql`, `,
)
const judging = sql.join(
UNJUDGED_RESULTS.map((value) => sql`${value}`),
sql`, `,
)
// 这段时间里老师布置过的课。它们的题那天交的提交另算(assignedLessons),不参与切段
const plans = await lessonPlans(range)
const planRows = JSON.stringify(
plans.flatMap((plan) => plan.problemIds.map((problemId) => ({ class_name: plan.className, day: plan.day, problem_id: problemId }))),
)
// grouping() 的位:class_name 32、day 16、sid 8、problem_id 4、result 2、kind 1。
// 没参与分组的那几列记 1,所以每种粒度对应一个固定的 level
const rowsPromise = db.execute<LessonAggRow>(sql`
with plan as (
select * from jsonb_to_recordset(${planRows}::jsonb) as p(class_name text, day text, problem_id int)
),
s as (
select u.class_name, s.user_id, s.problem_id, s.result, s.problemset_id, s.create_time,
to_char(${localTime(sql`s.create_time`)}, 'YYYY-MM-DD') as day
from ${schema.submission} s
join ${schema.user} u on u.id = s.user_id
where s.contest_id is null
and s.create_time <= ${range.end}
${range.start ? sql`and s.create_time >= ${range.start}` : sql``}
-- 只算普通学生:老师试题的提交不该把一段拼成「一节课」
and u.admin_type = 'Regular User' and u.is_disabled = false
),
g as (
select *,
case when create_time - lag(create_time) over w > ${`${LESSON_GAP_MINUTES} minutes`}::interval
then 1 else 0 end as brk,
exists (
select 1 from plan p
where p.class_name is not distinct from s.class_name and p.day = s.day and p.problem_id = s.problem_id
) as planned
from s
window w as (partition by class_name, day order by create_time)
),
seg as (
select *,
sum(brk) over (partition by class_name, day order by create_time rows unbounded preceding) as sid
from g
),
lesson_seg as (
select class_name, day, sid
from (
select class_name, day, sid, problem_id, count(distinct user_id) as n
from seg
where not planned
group by class_name, day, sid, problem_id
) p
group by class_name, day, sid
having max(n) >= ${LESSON_MIN_USERS}
),
marked as (
select seg.*,
case when seg.planned then 'plan' when l.sid is not null then 'lesson' else 'scattered' end as kind
from seg
left join lesson_seg l
-- 没填班级的号(class_name 为 null)合在一起当一个班切,= 会把它们全漏掉
on l.class_name is not distinct from seg.class_name and l.day = seg.day and l.sid = seg.sid
)
select grouping(class_name, day, sid, problem_id, result, kind) as level,
class_name, day, sid::int as sid, problem_id, result, kind,
count(distinct user_id)::int as users,
count(*)::int as total,
(count(*) filter (where result in (${accepted})))::int as accepted,
(count(*) filter (where result in (${judging})))::int as judging,
(count(*) filter (where problemset_id is not null))::int as from_ps,
min(create_time) as first,
max(create_time) as last,
(count(distinct user_id) filter (where result in (${accepted})))::int as accepted_users
from marked
group by grouping sets (
(class_name, day, sid, problem_id, kind),
(class_name, day, sid, kind),
(problem_id, class_name),
(problem_id, result),
(kind, class_name, day)
)
`)
const [rows, assigned] = await Promise.all([rowsPromise, assignedLessons(plans, range)])
const LEVEL = { segProblem: 2, segment: 6, problemClass: 27, problemResult: 57, dayClass: 14 }
const segKey = (row: LessonAggRow) => `${row.class_name}|${row.day}|${row.sid}`
const failedOf = (row: { total: number; accepted: number; judging: number }) => row.total - row.accepted - row.judging
const problemsOfSeg = new Map<string, LessonAggRow[]>()
for (const row of rows) {
if (row.level !== LEVEL.segProblem || row.kind !== 'lesson') continue
const key = segKey(row)
problemsOfSeg.set(key, [...(problemsOfSeg.get(key) ?? []), row])
}
// 推断的课和布置的课排在一起,按开始时间从近到远取前 limit 节
const allLessons = [
...rows.filter((row) => row.level === LEVEL.segment && row.kind === 'lesson').map((row) => ({ inferred: row, assigned: null, start: row.first })),
...assigned.map((lesson) => ({ inferred: null, assigned: lesson, start: lesson.start })),
].sort((a, b) => b.start.localeCompare(a.start))
const pickedAll = allLessons.slice(0, limit)
const picked = pickedAll.flatMap((item) => (item.inferred ? [item.inferred] : []))
const pickedAssigned = pickedAll.flatMap((item) => (item.assigned ? [item.assigned] : []))
const hasMore = allLessons.length > limit
// 题号、标题一次查齐:课的题、每节课错得最多的那道、整段时间错得最多的几道
const hardCandidates = new Map<number, { rows: LessonAggRow[]; failures: LessonAggRow[] }>()
for (const row of rows) {
if (row.problem_id === null) continue
if (row.level === LEVEL.problemClass || row.level === LEVEL.problemResult) {
const entry = hardCandidates.get(row.problem_id) ?? { rows: [], failures: [] }
if (row.level === LEVEL.problemClass) entry.rows.push(row)
else entry.failures.push(row)
hardCandidates.set(row.problem_id, entry)
}
}
const hardRanked = [...hardCandidates.entries()]
.map(([problemId, entry]) => {
const sum = (key: 'total' | 'accepted' | 'judging' | 'users' | 'accepted_users') => entry.rows.reduce((acc, row) => acc + row[key], 0)
return {
problemId,
className: [...entry.rows].sort((a, b) => b.total - a.total)[0]?.class_name ?? null,
total: sum('total'),
accepted: sum('accepted'),
judging: sum('judging'),
userCount: sum('users'),
acceptedUsers: sum('accepted_users'),
failures: entry.failures
.filter((row) => row.result !== null && !ACCEPTED_RESULTS.includes(row.result) && !UNJUDGED_RESULTS.includes(row.result))
.sort((a, b) => b.total - a.total)
.map((row) => ({ result: row.result!, count: row.total })),
}
})
.filter((row) => failedOf(row) > 0)
.sort((a, b) => failedOf(b) - failedOf(a))
.slice(0, 6)
const lessonProblemRows = picked.map((lesson) =>
(problemsOfSeg.get(segKey(lesson)) ?? []).filter((row) => row.users >= LESSON_MIN_USERS).sort((a, b) => b.users - a.users),
)
const problemIds = [
...new Set([
...lessonProblemRows.flat().map((row) => row.problem_id!),
...hardRanked.map((row) => row.problemId),
...pickedAssigned.flatMap((lesson) => lesson.problemIds),
]),
]
const problems = problemIds.length
? await db
.select({
id: schema.problem.id,
displayId: schema.problem.displayId,
title: schema.problem.title,
visible: schema.problem.visible,
})
.from(schema.problem)
.where(inArray(schema.problem.id, problemIds))
: []
const problemById = new Map(problems.map((row) => [row.id, row]))
const lessonClasses = [
...new Set([...picked.flatMap((row) => (row.class_name === null ? [] : [row.class_name])), ...pickedAssigned.map((lesson) => lesson.className)]),
]
const sizes = lessonClasses.length
? await db
.select({ className: schema.user.className, value: count() })
.from(schema.user)
.where(and(inArray(schema.user.className, lessonClasses), inArray(schema.user.adminType, [...STUDENT_ROLES]), eq(schema.user.isDisabled, false)))
.groupBy(schema.user.className)
: []
const sum = (list: LessonAggRow[], key: 'total' | 'accepted' | 'judging') => list.reduce((acc, row) => acc + row[key], 0)
const liveSince = new Date(Date.now() - LESSON_LIVE_MINUTES * 60_000).toISOString()
const sizeOf = (className: string | null) => (className === null ? 0 : (sizes.find((row) => row.className === className)?.value ?? 0))
const inferredLessons = picked.map((lesson, index) => {
const own = lessonProblemRows[index]!
const hardest = [...own].sort((a, b) => failedOf(b) - failedOf(a))[0]
const hardestProblem = hardest ? problemById.get(hardest.problem_id!) : undefined
return {
className: lesson.class_name,
day: lesson.day!,
start: lesson.first,
end: lesson.last,
userCount: lesson.users,
// 没填班级的谈不上花名册,给 0(界面上就只写「几人」)
classSize: sizeOf(lesson.class_name),
// 点进去就拿这几道去筛:统计接口只认公开题,一次最多 20 道
problems: own
.map((row) => problemById.get(row.problem_id!))
.filter((row) => row?.visible)
.map((row) => row!.displayId)
.slice(0, STATISTICS_MAX_PROBLEMS),
assigned: false,
mode: null,
doneCount: null,
fromProblemSet: lesson.from_ps * 2 > lesson.total,
// 次数和正确率只算这节课的题:点进去筛的就是这几道,两边的数才对得上
// (一段里零星一两个人顺手交的别的题不算)
total: sum(own, 'total'),
correctRate: judgedRate(sum(own, 'accepted'), sum(own, 'total') - sum(own, 'judging')),
live: lesson.last >= liveSince,
hardest:
hardest && hardestProblem && failedOf(hardest) > 0
? {
problemDisplayId: hardestProblem.displayId,
problemTitle: hardestProblem.title,
failed: failedOf(hardest),
}
: null,
}
})
const assignedOut = pickedAssigned.map((lesson) => {
const hardest = [...lesson.byProblem].sort((a, b) => failedOf(b) - failedOf(a))[0]
const hardestProblem = hardest ? problemById.get(hardest.problemId) : undefined
return {
className: lesson.className,
day: lesson.day,
start: lesson.start,
end: lesson.end,
userCount: lesson.users,
classSize: sizeOf(lesson.className),
problems: lesson.problemIds
.map((id) => problemById.get(id))
.filter((row) => row?.visible)
.map((row) => row!.displayId)
.slice(0, STATISTICS_MAX_PROBLEMS),
assigned: true,
mode: lesson.mode,
doneCount: lesson.doneCount,
fromProblemSet: false,
total: lesson.total,
correctRate: judgedRate(lesson.accepted, lesson.total - lesson.judging),
// 刚布置还没人交的也算在上课(没人交时 end 就是布置时刻)
live: Date.parse(lesson.end) >= Date.parse(liveSince),
hardest:
hardest && hardestProblem && failedOf(hardest) > 0
? {
problemDisplayId: hardestProblem.displayId,
problemTitle: hardestProblem.title,
failed: failedOf(hardest),
}
: null,
}
})
const lessons = [...inferredLessons, ...assignedOut].sort((a, b) => b.start.localeCompare(a.start))
// 零散提交只给列出来的这些课覆盖到的日子(「这学期」只列了最近 50 节时,更早的零散不给)
const earliestDay = hasMore ? (lessons.at(-1)?.day ?? null) : null
const scattered = rows
.filter((row) => row.level === LEVEL.dayClass && row.kind === 'scattered' && (earliestDay === null || row.day! >= earliestDay))
.sort((a, b) => (a.day !== b.day ? b.day!.localeCompare(a.day!) : a.class_name === null ? 1 : b.class_name === null ? -1 : b.total - a.total))
.map((row) => ({
day: row.day!,
className: row.class_name,
userCount: row.users,
total: row.total,
}))
return success(c, {
lessons,
scattered,
hardProblems: hardRanked.flatMap((row) => {
const problem = problemById.get(row.problemId)
if (!problem) return []
return [
{
problemDisplayId: problem.displayId,
problemTitle: problem.title,
className: row.className,
total: row.total,
accepted: row.accepted,
failures: row.failures,
userCount: row.userCount,
acceptedUsers: row.acceptedUsers,
},
]
}),
hasMore,
} satisfies SubmissionLessons)
})