perf(教师统计): 展开行的明细改成按需拉,统计响应不再背着 4.9 万行没人看的数据
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
上一版把明细收成「只取有 AC 的人、每人最近 50 条」,拿生产快照(12.4 万条提交) 实测下来只从 105631 行降到 49108 行 —— 2.1 倍,不是一个数量级。原因是绝大多数人 本来就不到 50 条,每人截断那道闸在真实分布上基本没咬到,最坏情况响应体仍有 ~2.4MB。 真正的问题是形状不对:表格一次只展开一行(updateExpandedRowKeys 只留最后一个 key), 却给 1900 个人各准备了一份。所以明细整个从统计响应里拿掉,改成展开时按需拉: - 新端点 `GET /submissions/statistics/items`,要用户名 + 同一套时间窗和题号。 用户名这里是**精确匹配**,不是统计接口那种 ilike —— 那边填 ks251 要圈出整个班, 这边是「点开的这一行是谁」。上限 200 条,多取一条来判断 truncated,被截断时 展开行里说明「只显示最近 200 条」,免得老师以为这人就交了这么多。 - 时间窗和题号抽成共用的 statisticsScope,两个接口必须同一个范围,否则展开行 看到的是另一个窗口的数据。 - 前端按人缓存,收起再展开不重拉;每次重新统计(含 15 秒自动刷新)清缓存, 并把当前展开着的那一行重拉一遍 —— 展开行跟着一起活着,不然刷新之后上面的数 变了、下面的明细还是老的。 - 去重放在 loadItems 里:点一行会同时走 rowProps 的 onClick 和表格的 update:expanded-row-keys,两边都想拉,改之前真发出了两条一模一样的请求。 顺带把 submissionItems 从 submissionStatisticsUserSchema 里删掉。 生产快照上的验证(12.4 万条提交 / 1956 用户 / 961 题,恢复进一次性容器跑完即删): - 最坏情况(全部时段 + 不填条件)少搬 49108 行,约 2.4MB - 真实课堂量级(最忙的一小时:583 条提交 / 81 人)四条查询分别是 主聚合 45ms、明细 6.5ms、语法未过 2.4ms、最近错因 <1ms - 「已解决」那条口径修正的实际影响:13.3% 的「人×题」有重复 AC,同一题最多 AC 45 次; 按人看最夸张的是 419 条 AC 其实只有 38 道题 - 语法要求的题 15 道、result=10 共 57 条,其中「最后也没改对」的 18 个人×题 —— 角标会出现,但稀有 浏览器实跑:展开前不发明细请求;展开后 1 条、12 个按钮;收起 +0、再展开 +0(走缓存); 自动刷新时统计与明细 1:1 配对、间隔 15 秒,没有重复请求。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KqjE6qPo67fqVDKn6Bx7yd
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
submissionDetailSchema,
|
||||
submissionListItemSchema,
|
||||
submissionListSchema,
|
||||
submissionStatisticsItemsSchema,
|
||||
submissionStatisticsSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
|
||||
@@ -238,46 +239,13 @@ async function findPublicProblemsByDisplayIds(displayIds: string[]) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格展开行要看的「这个人交了哪几次」。两道闸都是为了不让「全部时段 + 不填条件」
|
||||
* 把十几万条提交整个搬进响应体:
|
||||
* 展开行一次只看一个人(表格的 updateExpandedRowKeys 只留最后一个 key),所以明细
|
||||
* **按需拉**,不再随统计一起下发。
|
||||
*
|
||||
* - **只取有 AC 的人**。明细只挂在 `data` 里,而 `data` 本来就只留有 AC 的人,
|
||||
* 原来给「一次没对的人」也捞一份明细,捞完直接扔掉。
|
||||
* - **每人只留最近 50 条**。展开行是一排 120px 的按钮,几十个就已经翻不动了。
|
||||
* 表格「提交数」那一列走的是 perUser 的 count,仍然是真实总数,不受这里截断影响。
|
||||
* 原来是随 data 一起给所有人各带一份:生产快照实测,「全部时段 + 不填条件」要搬
|
||||
* 49108 行(最早那版不截断是 105631 行),而其中真正被人看到的最多一个人的那几十条。
|
||||
*/
|
||||
const STATISTICS_ITEMS_PER_USER = 50
|
||||
|
||||
async function submissionItemsByUser(where: SQL | undefined, usernames: string[]) {
|
||||
const byUser = new Map<string, { id: string; result: number }[]>()
|
||||
if (!usernames.length) return byUser
|
||||
|
||||
// 走窗口函数而不是「查全量再在 JS 里截断」:截断要发生在数据库那边才省得下来。
|
||||
const rows = await db.execute<{ username: string; id: string; result: number }>(sql`
|
||||
select username, id, result from (
|
||||
select
|
||||
${schema.submission.username} as username,
|
||||
${schema.submission.id} as id,
|
||||
${schema.submission.result} as result,
|
||||
row_number() over (
|
||||
partition by ${schema.submission.username}
|
||||
order by ${schema.submission.createTime} desc
|
||||
) as rn
|
||||
from ${schema.submission}
|
||||
where ${and(where, inArray(schema.submission.username, usernames))}
|
||||
) t
|
||||
where rn <= ${STATISTICS_ITEMS_PER_USER}
|
||||
-- rn 就是「这个人的第几新」,外层不排的话展开行里的按钮是乱序的
|
||||
order by username, rn
|
||||
`)
|
||||
|
||||
for (const row of rows) {
|
||||
const bucket = byUser.get(row.username)
|
||||
if (bucket) bucket.push({ id: row.id, result: row.result })
|
||||
else byUser.set(row.username, [{ id: row.id, result: row.result }])
|
||||
}
|
||||
return byUser
|
||||
}
|
||||
const STATISTICS_ITEMS_LIMIT = 200
|
||||
|
||||
/** 错误摘要截断长度。编译错误能刷几十行,弹层里放不下,也没必要 */
|
||||
const FAILURE_MESSAGE_LIMIT = 400
|
||||
@@ -381,9 +349,21 @@ async function matchedStudents(username: string) {
|
||||
)
|
||||
}
|
||||
|
||||
submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
||||
/**
|
||||
* 两个统计接口共用的范围:时间窗 + 题号。**用户名不在里面** —— 统计那边是
|
||||
* ilike 模糊匹配(填 ks251 要匹配整个班),明细那边必须精确到人,口径不同。
|
||||
*/
|
||||
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 failure(c, 400, "invalid-request", "end is required")
|
||||
if (!range) {
|
||||
return { ok: false, status: 400, code: "invalid-request", message: "end is required" }
|
||||
}
|
||||
|
||||
const filters = [
|
||||
isNull(schema.submission.contestId),
|
||||
@@ -393,16 +373,34 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
||||
|
||||
const displayIds = parseDisplayIds(c.req.query("problemId") ?? "")
|
||||
if (displayIds.length > STATISTICS_MAX_PROBLEMS) {
|
||||
return failure(c, 400, "invalid-request", `At most ${STATISTICS_MAX_PROBLEMS} 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 failure(c, 404, "problem-not-found", `Problem ${missing} does not exist`)
|
||||
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 }
|
||||
}
|
||||
|
||||
submissionRoutes.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()
|
||||
if (username) filters.push(ilike(schema.submission.username, `%${username}%`))
|
||||
const where = and(...filters)
|
||||
@@ -456,18 +454,17 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
||||
* 只填一道题时 `solvedCount >= 1` 和原来的 `acceptedCount > 0` 完全等价;
|
||||
* 不填题号时无所谓「全部」,退回「至少做出一道」。
|
||||
*/
|
||||
const requiredSolved = displayIds.length
|
||||
const requiredSolved = scope.problemCount
|
||||
const isDone = (row: { solvedCount: number; acceptedCount: number }) =>
|
||||
requiredSolved > 0 ? row.solvedCount >= requiredSolved : row.acceptedCount > 0
|
||||
|
||||
// 表格列的是做完了的人。没做完的(一条没交 / 交了没全对)在「未完成」那一栏
|
||||
const acceptedUsers = perUser.filter(isDone)
|
||||
// 这两个都要等 acceptedUsers 定下来才能查,所以进不了上面那个 Promise.all
|
||||
const acceptedNames = acceptedUsers.map((row) => row.username)
|
||||
const [itemsByUser, astOnlyByUserMap] = await Promise.all([
|
||||
submissionItemsByUser(where, acceptedNames),
|
||||
astOnlyByUser(where, acceptedNames),
|
||||
])
|
||||
// 要等 acceptedUsers 定下来才能查,所以进不了上面那个 Promise.all
|
||||
const astOnlyByUserMap = await astOnlyByUser(
|
||||
where,
|
||||
acceptedUsers.map((row) => row.username),
|
||||
)
|
||||
|
||||
const submittedUsernames = new Set(perUser.map((row) => row.username))
|
||||
const classNames = new Map<string, string | null>()
|
||||
@@ -488,7 +485,6 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
||||
astOnlyCount: astOnlyByUserMap.get(row.username) ?? 0,
|
||||
judgingCount: row.judgingCount,
|
||||
correctRate: judgedRate(row.acceptedCount, row.submissionCount - row.judgingCount),
|
||||
submissionItems: itemsByUser.get(row.username) ?? [],
|
||||
}))
|
||||
|
||||
const dataUnaccepted = rosterRows
|
||||
@@ -538,6 +534,38 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 统计面板展开一行时拉这个人的提交明细。
|
||||
*
|
||||
* 用户名这里是**精确匹配**,不是统计接口那种 ilike —— 那边填 `ks251` 要圈出整个班,
|
||||
* 这边是「点开的这一行是谁」。时间窗和题号沿用同一个 scope,不然展开行看到的
|
||||
* 会是另一个范围的数据。
|
||||
*/
|
||||
submissionRoutes.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 rows = await db
|
||||
.select({ id: schema.submission.id, result: schema.submission.result })
|
||||
.from(schema.submission)
|
||||
.where(and(...scope.filters, eq(schema.submission.username, username)))
|
||||
.orderBy(desc(schema.submission.createTime), desc(schema.submission.id))
|
||||
.limit(STATISTICS_ITEMS_LIMIT + 1)
|
||||
|
||||
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
|
||||
return success(
|
||||
c,
|
||||
submissionStatisticsItemsSchema.parse({
|
||||
items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
|
||||
truncated,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
submissionRoutes.post("/submissions/:id/rejudge", requireSuperAdmin, async (c) => {
|
||||
const [row] = await db
|
||||
.select({ id: schema.submission.id, problemId: schema.submission.problemId })
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
submissionDetailSchema,
|
||||
type FlowchartStatistics,
|
||||
type SubmissionStatistics,
|
||||
type SubmissionStatisticsItems,
|
||||
} from "@oj2/contract"
|
||||
import api from "utils/api"
|
||||
import { filterResult } from "oj/transforms"
|
||||
@@ -159,6 +160,20 @@ export function adminRejudge(id: string) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计面板展开一行时拉这个人的明细。username 这里要**精确**到人,
|
||||
* 和上面那个按班级模糊匹配的不是一回事。
|
||||
*/
|
||||
export function getSubmissionStatisticsItems(
|
||||
duration: { start?: string; end: string },
|
||||
username: string,
|
||||
problemID?: string,
|
||||
) {
|
||||
return api.get<SubmissionStatisticsItems>("submissions/statistics/items", {
|
||||
params: { ...duration, problemId: problemID, username },
|
||||
})
|
||||
}
|
||||
|
||||
export function getSubmissionStatistics(
|
||||
duration: { start?: string; end: string },
|
||||
problemID?: string,
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
<script setup lang="ts">
|
||||
import { h } from "vue"
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import { getSubmissionStatistics } from "oj/api"
|
||||
import { getSubmissionStatistics, getSubmissionStatisticsItems } from "oj/api"
|
||||
import { DURATION_OPTIONS, STORAGE_KEY } from "utils/constants"
|
||||
import storage from "utils/storage"
|
||||
import { useConfigStore } from "../store/config"
|
||||
@@ -202,6 +202,7 @@ import { NButton, NFlex, NText, type DataTableRowKey } from "naive-ui"
|
||||
import { JUDGE_STATUS } from "utils/constants"
|
||||
import type {
|
||||
AttemptedStudent,
|
||||
SubmissionStatisticsItems,
|
||||
SubmissionStatisticsUser,
|
||||
UnacceptedStudent,
|
||||
} from "@oj2/contract"
|
||||
@@ -232,24 +233,35 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
|
||||
{
|
||||
type: "expand",
|
||||
renderExpand: (row) => {
|
||||
return h(NFlex, { size: "small", wrap: true }, () =>
|
||||
row.submissionItems.map((item) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
tertiary: true,
|
||||
type: JUDGE_STATUS[item.result]?.type ?? "default",
|
||||
style: "width: 120px",
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
openSubmission(item.id)
|
||||
const loaded = items[row.username]
|
||||
if (!loaded) return h(NText, { depth: 3 }, () => "加载中…")
|
||||
return h(NFlex, { vertical: true, size: "small" }, () => [
|
||||
h(NFlex, { size: "small", wrap: true }, () =>
|
||||
loaded.items.map((item) =>
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: "small",
|
||||
tertiary: true,
|
||||
type: JUDGE_STATUS[item.result]?.type ?? "default",
|
||||
style: "width: 120px",
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
openSubmission(item.id)
|
||||
},
|
||||
},
|
||||
},
|
||||
() => item.id.toString().slice(0, 12),
|
||||
() => item.id.toString().slice(0, 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
loaded.truncated
|
||||
? h(
|
||||
NText,
|
||||
{ depth: 3 },
|
||||
() => `只显示最近 ${loaded.items.length} 条,上面「提交数」才是总数`,
|
||||
)
|
||||
: null,
|
||||
])
|
||||
},
|
||||
},
|
||||
{ title: "用户", key: "username" },
|
||||
@@ -325,6 +337,41 @@ const listUnaccepted = ref<UnacceptedStudent[]>([])
|
||||
const listAttempted = ref<AttemptedStudent[]>([])
|
||||
const expandedRowKeys = ref<DataTableRowKey[]>([])
|
||||
|
||||
/**
|
||||
* 展开行的明细,按人缓存。**每次重新统计都清空** —— 时间窗滚过之后旧明细就不对了;
|
||||
* 清完如果还有展开着的行,顺手把那一行重拉一遍,让它跟着自动刷新一起活着。
|
||||
*/
|
||||
const items = reactive<Record<string, SubmissionStatisticsItems>>({})
|
||||
|
||||
// 点一行会同时走 rowProps 的 onClick 和表格的 update:expanded-row-keys,
|
||||
// 两边都想拉一次;去重放在这里,调用方不用各自判
|
||||
const itemsLoading = new Set<string>()
|
||||
|
||||
async function loadItems(username: string) {
|
||||
if (items[username] || itemsLoading.has(username)) return
|
||||
itemsLoading.add(username)
|
||||
const current = Date.now()
|
||||
const duration =
|
||||
query.duration === "all"
|
||||
? { end: formatISO(current) }
|
||||
: {
|
||||
start: formatISO(sub(current, subOptions.value)),
|
||||
end: formatISO(current),
|
||||
}
|
||||
try {
|
||||
items[username] = await getSubmissionStatisticsItems(
|
||||
duration,
|
||||
username,
|
||||
query.problem,
|
||||
)
|
||||
} catch {
|
||||
// 拉不到就当空的:展开行显示不出东西,但不该把整个面板带崩
|
||||
items[username] = { items: [], truncated: false }
|
||||
} finally {
|
||||
itemsLoading.delete(username)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「查出东西了吗」。**不能只看提交数** —— 一节课刚开始时一条提交都没有,但后端
|
||||
* 已经把整份花名册当作「未完成」返回了,而那正是老师最想看名单的时刻。
|
||||
@@ -624,6 +671,10 @@ async function fetchStatistics() {
|
||||
personCount.value = res.personCount
|
||||
// 查过的班级记下来,下次打开直接带上
|
||||
if (query.username) storage.set(STORAGE_KEY.STATISTICS_CLASS, query.username)
|
||||
|
||||
const expanded = expandedRowKeys.value[0]
|
||||
for (const key of Object.keys(items)) delete items[key]
|
||||
if (typeof expanded === "string") loadItems(expanded)
|
||||
}
|
||||
|
||||
function rowKey(row: SubmissionStatisticsUser): DataTableRowKey {
|
||||
@@ -632,6 +683,8 @@ function rowKey(row: SubmissionStatisticsUser): DataTableRowKey {
|
||||
|
||||
function updateExpandedRowKeys(keys: DataTableRowKey[]) {
|
||||
expandedRowKeys.value = keys.slice(-1)
|
||||
const opened = expandedRowKeys.value[0]
|
||||
if (typeof opened === "string") loadItems(opened)
|
||||
}
|
||||
|
||||
function rowProps(row: SubmissionStatisticsUser) {
|
||||
@@ -641,6 +694,7 @@ function rowProps(row: SubmissionStatisticsUser) {
|
||||
const key = rowKey(row)
|
||||
const isExpanded = expandedRowKeys.value.includes(key)
|
||||
expandedRowKeys.value = isExpanded ? [] : [key]
|
||||
if (!isExpanded) loadItems(row.username)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user