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:
18
apps/api/src/db/0016_add_submission_trace.sql
Normal file
18
apps/api/src/db/0016_add_submission_trace.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 提交的编辑过程信号(AI 时代 OJ 设计的第 1 步:过程信号采集),字段含义见 schema.ts 的
|
||||
-- submissionTrace 与契约的 submissionTraceSchema。纯建表,历史提交没有对应行,这是预期的。
|
||||
CREATE TABLE "submission_trace" (
|
||||
"submission_id" text PRIMARY KEY NOT NULL,
|
||||
"active_ms" integer NOT NULL,
|
||||
"since_open_ms" integer NOT NULL,
|
||||
"typed_chars" integer NOT NULL,
|
||||
"pasted_chars" integer NOT NULL,
|
||||
"paste_count" integer NOT NULL,
|
||||
"max_paste" integer NOT NULL,
|
||||
"deleted_chars" integer NOT NULL,
|
||||
"blur_count" integer NOT NULL,
|
||||
"initial_len" integer NOT NULL,
|
||||
"collab" boolean NOT NULL,
|
||||
"since_prev_ms" bigint
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "submission_trace" ADD CONSTRAINT "submission_trace_submission_id_fk_submission_id" FOREIGN KEY ("submission_id") REFERENCES "public"."submission"("id") ON DELETE cascade ON UPDATE no action;
|
||||
3837
apps/api/src/db/meta/0016_snapshot.json
Normal file
3837
apps/api/src/db/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,13 @@
|
||||
"when": 1789364546358,
|
||||
"tag": "0015_submission_filter_indexes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1789817209482,
|
||||
"tag": "0016_add_submission_trace",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -953,6 +953,44 @@ export const submission = pgTable(
|
||||
],
|
||||
)
|
||||
|
||||
/**
|
||||
* 提交时附带的编辑过程信号,和 submission 一对一。字段含义见契约的
|
||||
* `submissionTraceSchema`,这里只记表本身的取舍:
|
||||
*
|
||||
* - **没有行 ≠ 可疑。** 2026-09 之前的全部历史提交、刷新过页面的、老版本前端交的
|
||||
* 都没有 trace,用它的地方一律把「缺失」当「无数据」。
|
||||
* - 类型化的列而不是一个 jsonb:「可信 AC」和学情热力图要在 SQL 里按这些值筛、聚合。
|
||||
* - `since_prev_ms` 是唯一由**服务端**算的一列(距同一用户同一道题上一次提交),
|
||||
* 客户端伪造不了;这道题的第一次提交为 null。
|
||||
* - CASCADE 挂在 submission 上、不挂 user:它是提交的附属,提交没了它没有意义,
|
||||
* 人是谁顺着 submission 就能查到。同表的 message / problemset_submission 也是这一档。
|
||||
*/
|
||||
export const submissionTrace = pgTable(
|
||||
"submission_trace",
|
||||
{
|
||||
submissionId: text("submission_id").primaryKey().notNull(),
|
||||
activeMs: integer("active_ms").notNull(),
|
||||
sinceOpenMs: integer("since_open_ms").notNull(),
|
||||
typedChars: integer("typed_chars").notNull(),
|
||||
pastedChars: integer("pasted_chars").notNull(),
|
||||
pasteCount: integer("paste_count").notNull(),
|
||||
maxPaste: integer("max_paste").notNull(),
|
||||
deletedChars: integer("deleted_chars").notNull(),
|
||||
blurCount: integer("blur_count").notNull(),
|
||||
initialLen: integer("initial_len").notNull(),
|
||||
collab: boolean().notNull(),
|
||||
// bigint:int4 的毫秒数只够 24.8 天,隔一个假期回来重交就溢出了
|
||||
sincePrevMs: bigint("since_prev_ms", { mode: "number" }),
|
||||
},
|
||||
(table) => [
|
||||
foreignKey({
|
||||
columns: [table.submissionId],
|
||||
foreignColumns: [submission.id],
|
||||
name: "submission_trace_submission_id_fk_submission_id",
|
||||
}).onDelete("cascade"),
|
||||
],
|
||||
)
|
||||
|
||||
export const tutorial = pgTable(
|
||||
"tutorial",
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type SubmissionDetail,
|
||||
type SubmissionList,
|
||||
type SubmissionListItem,
|
||||
type SubmissionTrace,
|
||||
} from "@oj2/contract"
|
||||
import {
|
||||
and,
|
||||
@@ -59,6 +60,38 @@ function objectValue(value: unknown): Record<string, unknown> {
|
||||
: {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 落编辑过程信号。**失败只记日志、不影响提交** —— 这是附带的统计数据,
|
||||
* 提交已经进库了,不能因为它回一个 500 让学生以为没交上。
|
||||
*
|
||||
* `since_prev_ms` 在同一条 INSERT 里用子查询算,排掉刚插进去的这条自己;
|
||||
* 两次提交并发到达时也各自取到的是对方之外的最近一条。这道题第一次提交时
|
||||
* `max()` 为 null,列就是 null。
|
||||
*/
|
||||
async function saveTrace(
|
||||
submissionId: string,
|
||||
userId: number,
|
||||
problemId: number,
|
||||
createTime: string,
|
||||
trace: SubmissionTrace,
|
||||
) {
|
||||
try {
|
||||
await db.insert(schema.submissionTrace).values({
|
||||
submissionId,
|
||||
...trace,
|
||||
sincePrevMs: sql`(
|
||||
select (extract(epoch from ${createTime}::timestamptz - max(${schema.submission.createTime})) * 1000)::bigint
|
||||
from ${schema.submission}
|
||||
where ${schema.submission.userId} = ${userId}
|
||||
and ${schema.submission.problemId} = ${problemId}
|
||||
and ${schema.submission.id} <> ${submissionId}
|
||||
)`,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to record submission trace", error)
|
||||
}
|
||||
}
|
||||
|
||||
submissionRoutes.post("/submissions", requireAuth, async (c) => {
|
||||
const parsed = createSubmissionRequestSchema.safeParse(
|
||||
await c.req.json().catch(() => null),
|
||||
@@ -168,6 +201,15 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
|
||||
contestId,
|
||||
})
|
||||
|
||||
if (parsed.data.trace)
|
||||
await saveTrace(
|
||||
submissionId,
|
||||
user.id,
|
||||
problem.id,
|
||||
createTime,
|
||||
parsed.data.trace,
|
||||
)
|
||||
|
||||
try {
|
||||
await judgeQueue.add(
|
||||
"judge",
|
||||
|
||||
Reference in New Issue
Block a user