Files
OJ2/packages/contract/src/submission.ts
yuetsh 9c5a1d551d
Some checks failed
Deploy / deploy (push) Has been cancelled
perf(教师统计): 展开行的明细改成按需拉,统计响应不再背着 4.9 万行没人看的数据
上一版把明细收成「只取有 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
2026-09-08 05:43:32 -06:00

239 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod"
import { paginatedSchema } from "./common"
export const judgeStatusSchema = z.union([
z.literal(-2),
z.literal(-1),
z.literal(0),
z.literal(1),
z.literal(2),
z.literal(3),
z.literal(4),
z.literal(5),
z.literal(6),
z.literal(7),
z.literal(8),
z.literal(10),
])
export const createSubmissionRequestSchema = z.object({
problemId: z.number().int().positive(),
language: z.string().min(1).max(32),
code: z.string().min(1).max(1024 * 1024),
contestId: z.number().int().positive().optional(),
/**
* 来源题单。学生从 `/problemset/:id/problem/:pid` 那个入口提交时前端带上,
* 后端落进 `submission.problemset_id`,提交列表据此标出「这条是刷题单刷出来的」。
*
* 只是**来源标记**,不参与判题、也不参与题单进度记账 —— 进度由判完之后的
* `recordSolvedProblem` 记进所有已加入且含这道题的题单,和从哪个入口进来无关。
* 所以这里带错了顶多是标记不准,不会影响成绩。
*/
problemSetId: z.number().int().positive().optional(),
})
export const createSubmissionResponseSchema = z.object({
submissionId: z.string(),
})
export const submissionDetailSchema = z.object({
id: z.string(),
createTime: z.string(),
userId: z.number().int(),
username: z.string(),
code: z.string(),
result: judgeStatusSchema,
info: z.unknown(),
language: z.string(),
statisticInfo: z.record(z.string(), z.unknown()),
contestId: z.number().int().nullable(),
problemId: z.number().int(),
/**
* 题目的展示编号problem._id。**独立的 /submission/:id 页面要靠它** ——
* 那条路由只喂 submissionID组件拿不到 display id而「复制回到题目」要用它
* 拼路由。原来只给内部数字 id于是那个按钮在这条路由上一点就抛
* `Missing required param "problemID"`。
*/
problemDisplayId: z.string(),
showLink: z.boolean(),
})
/**
* 内嵌在别处(目前只有站内信)的提交对象。对齐旧后端的
* `SubmissionSafeModelSerializer(exclude=("info", "contest", "ip"))` ——
* 这些键**根本不出现**,而不是出现但值为空。(`ip` 已随 IP 功能整体删除。)
*
* 独立成一个 schema 而不是复用 submissionDetailSchema 传空值:形状一致了,
* 将来有人「顺手」把空值改成真值就不会变成泄露,因为这里压根没有这些字段。
*/
export const embeddedSubmissionSchema = submissionDetailSchema
.omit({ info: true, contestId: true, problemId: true })
// 旧 SubmissionSafeModelSerializer 里 problem 是
// `SlugRelatedField(slug_field="_id")`,即**展示用题号**而非数字主键。
// 站内信页面拿它拼 `/problem/<题号>` 链接,给数字 id 会拼出打不开的地址。
.extend({ problem: z.string() })
/**
* 判题进度推送。**只带前端真正要用的东西**:靠 submissionId 认领、靠 result /
* status 决定是继续等还是去拉详情。
*
* 这里曾经还带着 time_cost / memory_cost / err_info —— 从 statistic_info 原样
* 抄一份出来,前端一处都没读过。耗时和错误信息在提交详情里本来就有,判完了去
* 拉一次就是了,不必让推送顺带背一份 JSONB 的形状。
*/
export const submissionUpdateSchema = z.object({
type: z.literal("submission_update"),
submissionId: z.string(),
result: judgeStatusSchema,
status: z.enum(["pending", "judging", "finished", "error"]),
score: z.number().optional(),
})
export const submissionListItemSchema = z.object({
id: z.string(),
problem: z.string(),
problemTitle: z.string(),
showLink: z.boolean(),
createTime: z.string(),
userId: z.number().int(),
username: z.string(),
result: judgeStatusSchema,
language: z.string(),
statisticInfo: z.record(z.string(), z.unknown()),
/**
* 来源题单,非题单入口提交的为 null。比赛提交恒为 null比赛题不会进题单
* 历史提交里只有「当年首次 AC 那一条」有值 —— 迁移 0007 从 problemset_submission
* 回填的就是这些,其余老提交无从判断入口,一律留空。
*/
problemSet: z.object({ id: z.number().int(), title: z.string() }).nullable(),
})
export const submissionListSchema = paginatedSchema(submissionListItemSchema)
/**
* **一条都没交**的学生。`realName` 是从用户名里剥掉 `ks<班级号>` 前缀后剩下的那一段,
* 不是 user.real_name 列 —— 与 F2「真名默认不下发」不冲突这里只有教师能看到
* 且教师面板的用途正是点名谁没做。
*
* 注意它不是「未完成」的全部:交了但一次没对的学生在 `dataAttempted` 里。
*/
export const unacceptedStudentSchema = z.object({
username: z.string(),
realName: z.string(),
})
/**
* **交了但一次没对**的学生。这批人原来两栏都不在 —— 不在「完成人数」(没 AC
* 也不在「未完成」名单(那一栏只收一条没交的),于是课堂上最该去看一眼的人
* 反而从屏幕上消失了。`submissionCount` 是窗口内的提交次数,教师据此判断
* 「卡了多久」。
*/
export const attemptedStudentSchema = unacceptedStudentSchema.extend({
submissionCount: z.number().int(),
/**
* 已经解决的题数。查多道题时这一栏里混着「一道没对」和「三道做出两道」两种人,
* 差几道决定了老师先管谁 —— 所以名字后面要缀 `2/3`。
*/
solvedCount: z.number().int(),
/**
* 最近一条提交错在哪。教师点名字就能看到「是编译错了还是答案错了」,
* 不必再切去提交列表翻这个人。`error` 是判题机写进 statistic_info 的 err_info
* 已截断;没有错误文本(比如答案错误那种)时为 null。
*/
lastFailure: z
.object({
id: z.string(),
/** 题目的展示编号,用来告诉老师错在哪道题 */
problem: z.string(),
result: judgeStatusSchema,
error: z.string().nullable(),
})
.nullable(),
})
export const submissionStatisticsUserSchema = z.object({
username: z.string(),
className: z.string().nullable(),
submissionCount: z.number().int(),
/** 通过的**提交条数**。correctRate 的分子就是它 */
acceptedCount: z.number().int(),
/**
* 解决的**题数**(同一道题重复 AC 只算一道)。表格「已解决」那一列显示的是它 ——
* 不指定题号查「这节课全班」时,条数和题数能差出好几倍。
*/
solvedCount: z.number().int(),
/**
* 「答案对了但语法没按要求写」且**最后也没改对**的题数。这些题算在 solvedCount 里
* AST_CHECK_FAILED 全站都算通过),单列出来只是让教师看得见教学上没达标的那几个。
*/
astOnlyCount: z.number().int(),
/** 这个人还在判题队列里的条数。`submissionCount` 含它,`correctRate` 的分母不含 */
judgingCount: z.number().int(),
// 百分比数值,不带 %。旧后端返回 "85.5%" 字符串,展示格式化交给前端。
correctRate: z.number(),
})
/**
* 展开行的明细,**按需拉**GET /submissions/statistics/items
*
* 原来是随统计一起给每个人各带一份,可表格一次只展开一行 —— 生产快照上那是
* 4.9 万行没人看的数据。`truncated` 为真时前端要说明「只显示最近 N 条」,
* 免得老师以为这人就交了这么多。
*/
export const submissionStatisticsItemsSchema = z.object({
items: z.array(z.object({ id: z.string(), result: judgeStatusSchema })),
truncated: z.boolean(),
})
export const submissionStatisticsSchema = z.object({
submissionCount: z.number().int(),
acceptedCount: z.number().int(),
/**
* 还没判完的条数PENDING / JUDGING。`submissionCount` 把它算在内,
* `correctRate` 的分母不算 —— 全班同时交卷的那几秒,分母涨了分子没涨,
* 正确率会凭空掉一截。下发它是为了让教师看得出「那几条还在判」。
*/
judgingCount: z.number().int(),
correctRate: z.number(),
// 花名册人数(未禁用的普通用户)。**只有这一个分母下发**:完成度由前端算,
// 因为「请假隐藏」会把请假的人从分母里减掉,那是后端不知道的浏览器本地状态。
personCount: z.number().int(),
data: z.array(submissionStatisticsUserSchema),
/** 一条都没交的(花名册里的人减去有提交的人) */
dataUnaccepted: z.array(unacceptedStudentSchema),
/** 交了但一次没对的。和 dataUnaccepted 一样只在传了用户名(有花名册)时才有内容 */
dataAttempted: z.array(attemptedStudentSchema),
})
export const formatCodeRequestSchema = z.object({
code: z.string().max(1024 * 1024),
language: z.enum(["python", "c", "cpp", "sql"]),
})
export const formatCodeResponseSchema = z.object({ code: z.string() })
export type JudgeStatus = z.infer<typeof judgeStatusSchema>
export type CreateSubmissionRequest = z.infer<
typeof createSubmissionRequestSchema
>
export type SubmissionDetail = z.infer<typeof submissionDetailSchema>
export type SubmissionUpdate = z.infer<typeof submissionUpdateSchema>
export type SubmissionStatistics = z.infer<typeof submissionStatisticsSchema>
export type SubmissionStatisticsUser = z.infer<
typeof submissionStatisticsUserSchema
>
export type SubmissionStatisticsItems = z.infer<
typeof submissionStatisticsItemsSchema
>
export type UnacceptedStudent = z.infer<typeof unacceptedStudentSchema>
export type AttemptedStudent = z.infer<typeof attemptedStudentSchema>
export type SubmissionListItem = z.infer<typeof submissionListItemSchema>
export type SubmissionList = z.infer<typeof submissionListSchema>
export type EmbeddedSubmission = z.infer<typeof embeddedSubmissionSchema>
export type CreateSubmissionResponse = z.infer<typeof createSubmissionResponseSchema>
export type FormatCodeResponse = z.infer<typeof formatCodeResponseSchema>
export type FormatCodeRequest = z.infer<typeof formatCodeRequestSchema>