feat(统计): 统计接口补上每人每道题的每一次提交、流程图每人最好的一次,流程图也能一次查几道题
- 新增 GET /submissions/statistics/grid:范围内每个学生的每次提交(只含普通学生,最多 5000 条), 没选班级时附带各班花名册人数,统计页按「人 × 题」画方块串、按班级汇总 - /flowcharts/statistics 题号可以一次填几道,多返回 people(每人分数最高的那张) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
99c158498c
commit
f09da88ff7
8 files changed
+288
-1492
No files matched your search
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user