feat(提交): 采集编辑过程信号,落进 submission_trace
Some checks failed
Deploy / deploy (push) Has been cancelled

AI 时代 OJ 设计的第 1 步:给「可信 AC」和学情分析攒数据,本身不判任何事。
- 契约:提交请求加可选的 trace(活跃时长、键入/粘贴/删除字符数、切后台次数等,
  只有计数、不含按键内容);写成 .optional().catch(undefined),坏了就当没带,
  不让附带数据把提交挡成 400
- 迁移 0016 建 submission_trace,与 submission 一对一、CASCADE;since_prev_ms
  由服务端在同一条 INSERT 里算(排掉自身),bigint —— 实测已有 44 天的间隔,int4 装不下
- 后端写 trace 失败只记日志,不影响提交
- 前端 oj/problem/utils/editTrace.ts 是模块单例(扩展对象不能过 Pinia 的响应式代理),
  只数带 userEvent 的事务:格式化回写 / 载入草稿 / 协作对方的改动天然排除;
  closeBrackets 越过右括号时是原样替换,按 no-op 跳过。比赛编辑器同样挂上
- 实跑:后端四种请求、前端浏览器里键入/粘贴/删除/setCode 回写/切后台/提交后清零,
  计数与预期逐项一致

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 05:46:55 -06:00
parent 20a6ddc79c
commit a0ef204bd2
12 changed files with 4152 additions and 0 deletions

View File

@@ -79,6 +79,36 @@ export const statisticInfoSchema = z.looseObject({
.optional(),
})
/**
* 编辑过程的聚合信号,落进 `submission_trace`。**只有计数,不含任何按键内容。**
*
* 口径是「自上次提交以来」的增量(前端每次提交成功后清零,切题也清零),
* 所以同一道题连交几次,每条提交各记各的那一段。
*
* 全部来自客户端,**可以伪造** —— 这是接受了的:它只用来给「可信 AC」加权、
* 给老师提示「建议关注」,不单独判任何事。服务端自己算的间隔另见 `since_prev_ms`。
*/
export const submissionTraceSchema = z.object({
/** 活跃编辑时长:相邻两次编辑间隔不超过 60 秒才累加,页面不可见时不计 */
activeMs: z.number().int().min(0).max(1e8),
/** 打开这道题(或上次提交)到这次提交的墙钟时长 */
sinceOpenMs: z.number().int().min(0).max(1e9),
/** 键入、输入法上屏、补全插入的字符数 */
typedChars: z.number().int().min(0).max(1e7),
/** 粘贴、从外部拖入的字符数 */
pastedChars: z.number().int().min(0).max(1e7),
pasteCount: z.number().int().min(0).max(1e5),
/** 单次最大粘贴的字符数 */
maxPaste: z.number().int().min(0).max(1e7),
deletedChars: z.number().int().min(0).max(1e7),
/** 页面切到后台的次数(切标签页、切窗口、最小化)。只作辅助,别单独拿来说事 */
blurCount: z.number().int().min(0).max(1e5),
/** 这一段开始时编辑器里已有的字符数(本地草稿 / 模板 / 上次提交后的代码) */
initialLen: z.number().int().min(0).max(1e7),
/** 提交时这道题正在课堂协作中。老师替学生交的那条也会是 true统计时要排掉 */
collab: z.boolean(),
})
export const createSubmissionRequestSchema = z.object({
problemId: z.number().int().positive(),
/**
@@ -102,6 +132,13 @@ export const createSubmissionRequestSchema = z.object({
* 所以这里带错了顶多是标记不准,不会影响成绩。
*/
problemSetId: z.number().int().positive().optional(),
/**
* 编辑过程信号,见 submissionTraceSchema。**坏了就当没带**`.catch`
* 整个请求体是一把 safeParse这里要是能 400一份附带的统计数据就能挡住
* 学生交作业。刷新过页面、老版本前端、脚本提交都会没有它,那是「无数据」,
* 不是「可疑」。
*/
trace: submissionTraceSchema.optional().catch(undefined),
})
export const createSubmissionResponseSchema = z.object({
@@ -384,6 +421,7 @@ export const formatCodeRequestSchema = z.object({
export const formatCodeResponseSchema = z.object({ code: z.string() })
export type StatisticInfo = z.infer<typeof statisticInfoSchema>
export type SubmissionTrace = z.infer<typeof submissionTraceSchema>
export type CreateSubmissionRequest = z.infer<
typeof createSubmissionRequestSchema
>