feat(统计): 统计接口补上每人每道题的每一次提交、流程图每人最好的一次,流程图也能一次查几道题

- 新增 GET /submissions/statistics/grid:范围内每个学生的每次提交(只含普通学生,最多 5000 条),
  没选班级时附带各班花名册人数,统计页按「人 × 题」画方块串、按班级汇总
- /flowcharts/statistics 题号可以一次填几道,多返回 people(每人分数最高的那张)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-29 18:28:27 +08:00
1 parent 99c158498c
commit f09da88ff7
8 files changed
+288 -1492

No files matched your search

+110 -70
View File
@@ -358,21 +358,30 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
]
if (start) filters.push(sql`${schema.flowchartSubmission.createTime} >= ${start}`)
const displayId = c.req.query("problemDisplayId")?.trim()
if (displayId) {
const [problem] = await db
.select({ id: schema.problem.id })
// 可以一次查几道(统计页的题号框和代码统计同一个),有一道找不到就整体报错,
// 不退化成「全部题目」—— 否则打错一个字看到的是全站数据
const displayIds = parseDisplayIds(c.req.query("problemDisplayId") ?? "")
if (displayIds.length) {
const lowered = displayIds.map((id) => id.toLowerCase())
const problems = await db
.select({ id: schema.problem.id, displayId: schema.problem.displayId })
.from(schema.problem)
.where(
and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
inArray(sql`lower(${schema.problem.displayId})`, lowered),
isNull(schema.problem.contestId),
eq(schema.problem.visible, true),
),
)
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
filters.push(eq(schema.flowchartSubmission.problemId, problem.id))
const found = new Set(problems.map((row) => row.displayId.toLowerCase()))
const missing = displayIds.find((id) => !found.has(id.toLowerCase()))
if (missing) return failure(c, 404, "problem-not-found", `Problem ${missing} does not exist`)
filters.push(
inArray(
schema.flowchartSubmission.problemId,
problems.map((row) => row.id),
),
)
}
const username = c.req.query("username")?.trim()
@@ -400,49 +409,52 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
* (criteria 255 + suggestions 64 + feedback 47),现在 2134 条无感,5 万条就是一次
* 点击 18MB,而老师是开着面板反复切时段、切班的。
*/
const [[totals], gradeRows, criteriaRows, textRows, submittedRows] = await Promise.all([
db
.select({
total: count(),
/**
* 均分拆成 sum / count 两项,不直接用 `avg()`:分母是**有分数的条数**而不是
* 总条数(对齐 Django 的 Avg(),它跳过 NULL),拆开之后这个口径在代码里是
* 写明的,也省掉 avg() 在空集上回 NULL 还要兜底。
*/
scoreSum: sql<number>`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith(
Number,
),
scoreCount: sql<number>`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(Number),
// 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样,
// 按 user_id 就不必 join user
completedCount:
sql<number>`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(Number),
})
.from(schema.flowchartSubmission)
.where(where),
db
.select({ grade: schema.flowchartSubmission.aiGrade, n: count() })
.from(schema.flowchartSubmission)
.where(where)
.groupBy(schema.flowchartSubmission.aiGrade),
/**
* 各项**平均分**。`ai_criteria_details` 是 `{ 项名: { score, max, comment } }`,
* 用 jsonb_each 展开之后按项名分组。分数不是数字的项整项跳过,和原来 JS 那句
* `typeof detail.score !== "number"` 的 continue 一致。
*
* **那道 `jsonb_typeof(...) = 'object'` 的闸不能省,而且要写在 jsonb_each 的参数里。**
* 不能省:撞上标量(历史脏数据)jsonb_each 直接抛错,整个面板 500 ——
* 拿 `'5'::jsonb` 和 `'[1,2]'::jsonb` 各插一行验过。
*
* 写在哪儿则纯是规划器的脸色:挪进 where 当基表过滤条件时,53350 行的探针上
* 实测 180ms → 360ms,因为计划从「并行 Partial HashAggregate」换成了「串行
* GroupAggregate + 21 万行外部归并排序、落盘 26MB」。两种写法都正确,选快的那个。
*
* 每项的**满分**不在这里取,见下面 criteriaMax 的注释:在这条 SQL 里按
* create_time 取「最新那条」要给 21 万行(4 项 × 5 万条)排序,同一个探针上
* 实测 254ms → 842ms,而满分本来就是几个常数。
*/
db.execute<{ key: string; avg: number }>(sql`
const [[totals], gradeRows, criteriaRows, textRows, submittedRows, peopleRows] =
await Promise.all([
db
.select({
total: count(),
/**
* 均分拆成 sum / count 两项,不直接用 `avg()`:分母是**有分数的条数**而不是
* 总条数(对齐 Django 的 Avg(),它跳过 NULL),拆开之后这个口径在代码里是
* 写明的,也省掉 avg() 在空集上回 NULL 还要兜底。
*/
scoreSum: sql<number>`coalesce(sum(${schema.flowchartSubmission.aiScore}), 0)`.mapWith(
Number,
),
scoreCount: sql<number>`count(${schema.flowchartSubmission.aiScore})::int`.mapWith(
Number,
),
// 完成人数。user_id 和 username 一一对应,按哪个 distinct 都一样,
// 按 user_id 就不必 join user
completedCount:
sql<number>`count(distinct ${schema.flowchartSubmission.userId})::int`.mapWith(Number),
})
.from(schema.flowchartSubmission)
.where(where),
db
.select({ grade: schema.flowchartSubmission.aiGrade, n: count() })
.from(schema.flowchartSubmission)
.where(where)
.groupBy(schema.flowchartSubmission.aiGrade),
/**
* 各项**平均分**。`ai_criteria_details` 是 `{ 项名: { score, max, comment } }`,
* 用 jsonb_each 展开之后按项名分组。分数不是数字的项整项跳过,和原来 JS 那句
* `typeof detail.score !== "number"` 的 continue 一致。
*
* **那道 `jsonb_typeof(...) = 'object'` 的闸不能省,而且要写在 jsonb_each 的参数里。**
* 不能省:撞上标量(历史脏数据)jsonb_each 直接抛错,整个面板 500 ——
* 拿 `'5'::jsonb` 和 `'[1,2]'::jsonb` 各插一行验过。
*
* 写在哪儿则纯是规划器的脸色:挪进 where 当基表过滤条件时,53350 行的探针上
* 实测 180ms → 360ms,因为计划从「并行 Partial HashAggregate」换成了「串行
* GroupAggregate + 21 万行外部归并排序、落盘 26MB」。两种写法都正确,选快的那个。
*
* 每项的**满分**不在这里取,见下面 criteriaMax 的注释:在这条 SQL 里按
* create_time 取「最新那条」要给 21 万行(4 项 × 5 万条)排序,同一个探针上
* 实测 254ms → 842ms,而满分本来就是几个常数。
*/
db.execute<{ key: string; avg: number }>(sql`
select e.key as key, avg((e.value->>'score')::double precision) as avg
from ${schema.flowchartSubmission}
cross join lateral jsonb_each(
@@ -453,25 +465,51 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
where ${where} and jsonb_typeof(e.value->'score') = 'number'
group by e.key
`),
// 词云的原料。只有这条要读大列,所以只有它按时间倒序取最近的 N 条
db
.select({
criteria: schema.flowchartSubmission.aiCriteriaDetails,
feedback: schema.flowchartSubmission.aiFeedback,
suggestions: schema.flowchartSubmission.aiSuggestions,
})
.from(schema.flowchartSubmission)
.where(where)
.orderBy(desc(schema.flowchartSubmission.createTime))
.limit(WORDCLOUD_TEXT_LIMIT),
// 「谁没做」只在有花名册时算得出来,行数也就一个班
roster.length
? db
.selectDistinct({ userId: schema.flowchartSubmission.userId })
.from(schema.flowchartSubmission)
.where(where)
: [],
])
// 词云的原料。只有这条要读大列,所以只有它按时间倒序取最近的 N 条
db
.select({
criteria: schema.flowchartSubmission.aiCriteriaDetails,
feedback: schema.flowchartSubmission.aiFeedback,
suggestions: schema.flowchartSubmission.aiSuggestions,
})
.from(schema.flowchartSubmission)
.where(where)
.orderBy(desc(schema.flowchartSubmission.createTime))
.limit(WORDCLOUD_TEXT_LIMIT),
// 「谁没做」只在有花名册时算得出来,行数也就一个班
roster.length
? db
.selectDistinct({ userId: schema.flowchartSubmission.userId })
.from(schema.flowchartSubmission)
.where(where)
: [],
// 每人最好的一次。等级取分数最高那张的等级(同分取最近的),只算普通学生
db
.select({
username: schema.user.username,
className: schema.user.className,
bestScore: sql<number | null>`max(${schema.flowchartSubmission.aiScore})`,
bestGrade: sql<string | null>`(array_agg(${schema.flowchartSubmission.aiGrade}
order by ${schema.flowchartSubmission.aiScore} desc nulls last, ${schema.flowchartSubmission.createTime} desc))[1]`,
count: count(),
})
.from(schema.flowchartSubmission)
.innerJoin(schema.user, eq(schema.user.id, schema.flowchartSubmission.userId))
.where(
and(where, eq(schema.user.adminType, "Regular User"), eq(schema.user.isDisabled, false)),
)
.groupBy(schema.user.id, schema.user.username, schema.user.className)
.orderBy(sql`max(${schema.flowchartSubmission.aiScore}) asc nulls first`)
.limit(500),
])
const people = peopleRows.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,
}))
if (!totals || totals.total === 0) {
return success(c, {
@@ -488,6 +526,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
username: row.username,
realName: stripClassPrefix(row.username, row.className),
})),
people: [],
} satisfies FlowchartStatistics)
}
@@ -551,6 +590,7 @@ flowchartRoutes.get("/flowcharts/statistics", requireTeacher, async (c) => {
username: row.username,
realName: stripClassPrefix(row.username, row.className),
})),
people,
} satisfies FlowchartStatistics)
})
@@ -8,6 +8,7 @@
import {
type SubmissionStatistics,
type SubmissionStatisticsGrid,
type SubmissionStatisticsItems,
type TodaySubmissionStatistics,
} from "@oj2/contract"
@@ -652,3 +653,134 @@ submissionStatisticsRoutes.get("/submissions/statistics/items", requireTeacher,
truncated,
} satisfies SubmissionStatisticsItems)
})
/** 方块串一次最多给多少条。一个班一个月七八百条,5000 是全年级查一周的量 */
const GRID_LIMIT = 5000
/**
* 统计页的方块串:范围内每个学生的每一次提交(口径见契约 submissionStatisticsGridSchema)。
* 用户名的匹配和统计接口同一套(ilike,填 `ks253` 圈一个班)。
*/
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()
if (username) {
const ids = (await matchedUsers(username)).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, eq(schema.user.adminType, "Regular User"), 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: stripClassPrefix(row.username, row.className),
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 (!username) {
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[]),
eq(schema.user.adminType, "Regular User"),
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)
})