Files
OJ2/apps/api/src/db/schema.ts
yuetsh e624c62502
Some checks failed
Deploy / deploy (push) Has been cancelled
feat(提交): 列表标出来自题单的提交
submission 新增 problemset_id,学生从 /problemset/:id/problem/:pid 入口提交时
前端带上、后端落库,提交列表在题目后面挂一个「题单 xxx」的标签,点了进题单。

只是来源标记:题单进度和奖章仍由判完之后的 recordSolvedProblem 按「已加入且含
这道题的所有题单」记账,和从哪个入口进来无关,所以这个字段带错顶多标签不准,
不影响成绩。校验只确认这道题在那个题单里 —— 不查 visible / status、也不查有没有
加入,藏起来的题单里还困着已加入的学生;对不上就当没带,提交照收。

外键 ON DELETE SET NULL:删题单不该带走提交,清掉标记就行。索引建成部分索引
(WHERE problemset_id IS NOT NULL),绝大多数提交不来自题单,全列索引是给 12 万行
白建一遍;谓词能被 problemset_id = $1 蕴含,删题单时的外键检查也用得上它。

列表按页单独查一次题单标题,没有把 problemset join 进那条调过的深翻页查询。
比赛提交恒为 null:题单只收非比赛题。

历史回填从 problemset_submission 取「当年首次 AC 那条」,其余老提交无从判断入口,
一律留空。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y7AsqceaUfC81k7UCDcSk2
2026-09-02 22:06:29 -06:00

757 lines
39 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.
// 本文件由 `drizzle-kit pull` 从生产库自动生成,之后按下面几条手工维护。
//
// 2026-08-26旧 Django 后端下线7 张框架表auth_group、auth_group_permissions、
// auth_permission、django_content_type、django_dramatiq_task、django_migrations、
// django_session已由 0002_drop_django_leftovers 删除drizzle.config.ts 的
// tablesFilter 随之移除。库里现在就是这 27 张业务表。
//
// 手工修正(都是 `pull` 自己没法无损 round-trip 的地方,改回去会让 generate 产生假 diff
// 详见 CLAUDE.md「改 schema 走 drizzle migration」
// * bigint identity 的 maxValue 用字符串,不能写成 JS number 字面量(会丢精度)。
// * 索引不写 `.desc()`,生成 SQL 时方向会被丢掉。
//
// 关于 10 张表的 bigint idproblemset*、achievement、user_achievement、user_stat、
// user_badge、ai_analysis这是历史巧合不是设计——这些 app 的 0001_initial 生成时
// Django 还没设 DEFAULT_AUTO_FIELD用了 3.2+ 的默认 BigAutoField更早的表user、
// problem、contest、submission都是 int4。现存最大 id 一万出头,确实都用不上 bigint
// 但 2026-08-26 评估后决定**不改**:省 4 字节/行毫无意义ALTER TYPE 要重写整表并拿
// ACCESS EXCLUSIVE 锁,而且其中 6 处 id 被外键绑着得连坐。别再提这件事了。
import { pgTable, index, foreignKey, primaryKey, bigint, text, jsonb, timestamp, integer, boolean, serial, doublePrecision, varchar, unique, uniqueIndex } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
export const aiAnalysis = pgTable("ai_analysis", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "ai_analysis_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
provider: text().notNull(),
data: jsonb().notNull(),
systemPrompt: text("system_prompt").notNull(),
userPrompt: text("user_prompt").notNull(),
analysis: text().notNull(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
userId: integer("user_id").notNull(),
model: text().notNull(),
isPinned: boolean("is_pinned").notNull(),
}, (table) => [
index("ai_analysis_user_id_3aa23011").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "ai_analysis_user_id_3aa23011_fk_user_id"
}),
]);
export const announcement = pgTable("announcement", {
id: serial().primaryKey().notNull(),
title: text().notNull(),
content: text().notNull(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }).notNull(),
visible: boolean().notNull(),
createdById: integer("created_by_id").notNull(),
tag: text().notNull(),
top: boolean().notNull(),
}, (table) => [
index("announcement_created_by_id_359ccf50").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
// 不写 .op()opclass 会吞掉方向(见 CLAUDE.md。生产库是 (visible, top DESC, create_time DESC)
// 写了 .op() 的话 generate 出来的是全 ASCschema.ts 就和真实库对不上了。
index("announcement_list_idx").using("btree", table.visible.asc().nullsLast(), table.top.desc().nullsFirst(), table.createTime.desc().nullsFirst()),
foreignKey({
columns: [table.createdById],
foreignColumns: [user.id],
name: "announcement_created_by_id_359ccf50_fk_user_id"
}),
]);
export const achievement = pgTable("achievement", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "achievement_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
name: text().notNull(),
description: text().notNull(),
icon: text().notNull(),
rarity: text().notNull(),
hidden: boolean().default(false).notNull(),
metric: text().notNull(),
operator: text().notNull(),
threshold: integer().notNull(),
visible: boolean().default(true).notNull(),
unlockCount: integer("unlock_count").default(0).notNull(),
order: integer().default(0).notNull(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
});
export const contest = pgTable("contest", {
id: serial().primaryKey().notNull(),
title: text().notNull(),
description: text().notNull(),
password: text(),
startTime: timestamp("start_time", { withTimezone: true, mode: 'string' }).notNull(),
endTime: timestamp("end_time", { withTimezone: true, mode: 'string' }).notNull(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }).notNull(),
visible: boolean().notNull(),
createdById: integer("created_by_id").notNull(),
allowedIpRanges: jsonb("allowed_ip_ranges").notNull(),
tag: text().notNull(),
}, (table) => [
index("contest_created_by_id_a763ca7e").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
foreignKey({
columns: [table.createdById],
foreignColumns: [user.id],
name: "contest_created_by_id_a763ca7e_fk_user_id"
}),
]);
export const flowchartSubmission = pgTable("flowchart_submission", {
id: text().primaryKey().notNull(),
mermaidCode: text("mermaid_code").notNull(),
flowchartData: jsonb("flowchart_data").notNull(),
status: integer().notNull(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
aiScore: doublePrecision("ai_score"),
aiGrade: varchar("ai_grade", { length: 10 }),
aiFeedback: text("ai_feedback"),
aiSuggestions: text("ai_suggestions"),
aiCriteriaDetails: jsonb("ai_criteria_details").notNull(),
aiProvider: varchar("ai_provider", { length: 50 }).notNull(),
aiModel: varchar("ai_model", { length: 50 }).notNull(),
processingTime: doublePrecision("processing_time"),
evaluationTime: timestamp("evaluation_time", { withTimezone: true, mode: 'string' }),
problemId: integer("problem_id").notNull(),
userId: integer("user_id").notNull(),
}, (table) => [
index("flowchart_problem_time_idx").using("btree", table.problemId.asc().nullsLast().op("int4_ops"), table.createTime.asc().nullsLast().op("int4_ops")),
index("flowchart_status_idx").using("btree", table.status.asc().nullsLast().op("int4_ops")),
index("flowchart_submission_id_0dbfc4f9_like").using("btree", table.id.asc().nullsLast().op("text_pattern_ops")),
index("flowchart_submission_problem_id_8551edbf").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
index("flowchart_submission_user_id_225c83e8").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
index("flowchart_user_time_idx").using("btree", table.userId.asc().nullsLast().op("int4_ops"), table.createTime.asc().nullsLast().op("int4_ops")),
foreignKey({
columns: [table.problemId],
foreignColumns: [problem.id],
name: "flowchart_submission_problem_id_8551edbf_fk_problem_id"
}),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "flowchart_submission_user_id_225c83e8_fk_user_id"
}),
]);
export const message = pgTable("message", {
id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "message_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
message: text().notNull(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
recipientId: integer("recipient_id").notNull(),
senderId: integer("sender_id").notNull(),
submissionId: text("submission_id").notNull(),
}, (table) => [
index("message_recipient_id_2aa5dd76").using("btree", table.recipientId.asc().nullsLast().op("int4_ops")),
index("message_recipient_time_idx").using("btree", table.recipientId.asc().nullsLast().op("timestamptz_ops"), table.createTime.asc().nullsLast().op("int4_ops")),
index("message_sender_id_a2a2e825").using("btree", table.senderId.asc().nullsLast().op("int4_ops")),
index("message_submission_id_2fdf8a47").using("btree", table.submissionId.asc().nullsLast().op("text_ops")),
index("message_submission_id_2fdf8a47_like").using("btree", table.submissionId.asc().nullsLast().op("text_pattern_ops")),
foreignKey({
columns: [table.recipientId],
foreignColumns: [user.id],
name: "message_recipient_id_2aa5dd76_fk_user_id"
}),
foreignKey({
columns: [table.senderId],
foreignColumns: [user.id],
name: "message_sender_id_a2a2e825_fk_user_id"
}),
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: "message_submission_id_2fdf8a47_fk_submission_id"
}),
]);
export const judgeServer = pgTable("judge_server", {
id: serial().primaryKey().notNull(),
hostname: text().notNull(),
ip: text(),
judgerVersion: text("judger_version").notNull(),
cpuCore: integer("cpu_core").notNull(),
memoryUsage: doublePrecision("memory_usage").notNull(),
cpuUsage: doublePrecision("cpu_usage").notNull(),
lastHeartbeat: timestamp("last_heartbeat", { withTimezone: true, mode: 'string' }).notNull(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
taskNumber: integer("task_number").notNull(),
serviceUrl: text("service_url"),
isDisabled: boolean("is_disabled").notNull(),
});
export const exercise = pgTable("exercise", {
id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "exercise_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
type: varchar({ length: 16 }).notNull(),
data: jsonb().notNull(),
order: integer().notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).notNull(),
tutorialId: integer("tutorial_id").notNull(),
}, (table) => [
index("exercise_tutorial_id_6fd04055").using("btree", table.tutorialId.asc().nullsLast().op("int4_ops")),
foreignKey({
columns: [table.tutorialId],
foreignColumns: [tutorial.id],
name: "exercise_tutorial_id_6fd04055_fk_tutorial_id"
}),
]);
export const optionsSysoptions = pgTable("options_sysoptions", {
id: serial().primaryKey().notNull(),
key: text().notNull(),
value: jsonb().notNull(),
}, (table) => [
unique("options_sysoptions_key_key").on(table.key),
]);
export const problemset = pgTable("problemset", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
title: text().notNull(),
description: text().notNull(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }).notNull(),
visible: boolean().notNull(),
difficulty: text().notNull(),
status: text().notNull(),
createdById: integer("created_by_id").notNull(),
endTime: timestamp("end_time", { withTimezone: true, mode: 'string' }),
}, (table) => [
index("problemset_created_by_id_01b5197f").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
foreignKey({
columns: [table.createdById],
foreignColumns: [user.id],
name: "problemset_created_by_id_01b5197f_fk_user_id"
}),
]);
export const problemsetProblem = pgTable("problemset_problem", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_problem_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
order: integer().notNull(),
isRequired: boolean("is_required").notNull(),
score: integer().notNull(),
hint: text(),
problemId: integer("problem_id").notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
problemsetId: bigint("problemset_id", { mode: "number" }).notNull(),
}, (table) => [
index("problemset_problem_problem_id_fff2d686").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
index("problemset_problem_problemset_id_350d17fb").using("btree", table.problemsetId.asc().nullsLast().op("int8_ops")),
foreignKey({
columns: [table.problemId],
foreignColumns: [problem.id],
name: "problemset_problem_problem_id_fff2d686_fk_problem_id"
}),
foreignKey({
columns: [table.problemsetId],
foreignColumns: [problemset.id],
name: "problemset_problem_problemset_id_350d17fb_fk_problemset_id"
}),
unique("unique_problemset_problem").on(table.problemId, table.problemsetId),
]);
export const problemsetProgress = pgTable("problemset_progress", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_progress_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
joinTime: timestamp("join_time", { withTimezone: true, mode: 'string' }).notNull(),
completeTime: timestamp("complete_time", { withTimezone: true, mode: 'string' }),
isCompleted: boolean("is_completed").notNull(),
progressPercentage: doublePrecision("progress_percentage").notNull(),
completedProblemsCount: integer("completed_problems_count").notNull(),
totalProblemsCount: integer("total_problems_count").notNull(),
totalScore: integer("total_score").notNull(),
progressDetail: jsonb("progress_detail").notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
problemsetId: bigint("problemset_id", { mode: "number" }).notNull(),
userId: integer("user_id").notNull(),
}, (table) => [
index("problemset_progress_problemset_id_20a9632e").using("btree", table.problemsetId.asc().nullsLast().op("int8_ops")),
index("problemset_progress_user_id_c8041a80").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
foreignKey({
columns: [table.problemsetId],
foreignColumns: [problemset.id],
name: "problemset_progress_problemset_id_20a9632e_fk_problemset_id"
}),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "problemset_progress_user_id_c8041a80_fk_user_id"
}),
unique("unique_problemset_progress_user").on(table.problemsetId, table.userId),
]);
export const problemsetSubmission = pgTable("problemset_submission", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_submission_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
problemId: integer("problem_id").notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
problemsetId: bigint("problemset_id", { mode: "number" }).notNull(),
submissionId: text("submission_id").notNull(),
userId: integer("user_id").notNull(),
}, (table) => [
index("problemset__problem_1f39fa_idx").using("btree", table.problemsetId.asc().nullsLast().op("int4_ops"), table.userId.asc().nullsLast().op("int4_ops")),
index("problemset__problem_22f053_idx").using("btree", table.problemsetId.asc().nullsLast().op("int8_ops"), table.problemId.asc().nullsLast().op("int8_ops")),
index("problemset__user_id_2f1501_idx").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
index("problemset_submission_problem_id_5629b105").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
index("problemset_submission_problemset_id_85290e17").using("btree", table.problemsetId.asc().nullsLast().op("int8_ops")),
index("problemset_submission_submission_id_78e2b807").using("btree", table.submissionId.asc().nullsLast().op("text_ops")),
index("problemset_submission_submission_id_78e2b807_like").using("btree", table.submissionId.asc().nullsLast().op("text_pattern_ops")),
index("problemset_submission_user_id_915fc9c6").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
foreignKey({
columns: [table.problemId],
foreignColumns: [problem.id],
name: "problemset_submission_problem_id_5629b105_fk_problem_id"
}),
foreignKey({
columns: [table.problemsetId],
foreignColumns: [problemset.id],
name: "problemset_submission_problemset_id_85290e17_fk_problemset_id"
}),
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: "problemset_submission_submission_id_78e2b807_fk_submission_id"
}),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "problemset_submission_user_id_915fc9c6_fk_user_id"
}),
]);
export const reaction = pgTable("reaction", {
id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "reaction_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
type: varchar({ length: 20 }).notNull(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
problemId: integer("problem_id").notNull(),
userId: integer("user_id").notNull(),
}, (table) => [
index("reaction_problem_id_a7f3b9f3").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
index("reaction_problem_type_idx").using("btree", table.problemId.asc().nullsLast().op("int4_ops"), table.type.asc().nullsLast().op("int4_ops")),
index("reaction_user_id_cfa7f469").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
foreignKey({
columns: [table.problemId],
foreignColumns: [problem.id],
name: "reaction_problem_id_a7f3b9f3_fk_problem_id"
}),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "reaction_user_id_cfa7f469_fk_user_id"
}),
unique("reaction_problem_user_unique").on(table.problemId, table.userId),
]);
export const problem = pgTable("problem", {
id: serial().primaryKey().notNull(),
title: text().notNull(),
description: text().notNull(),
inputDescription: text("input_description").notNull(),
outputDescription: text("output_description").notNull(),
samples: jsonb().notNull(),
testCaseId: text("test_case_id").notNull(),
testCaseScore: jsonb("test_case_score").notNull(),
hint: text(),
languages: jsonb().notNull(),
template: jsonb().notNull(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
lastUpdateTime: timestamp("last_update_time", { withTimezone: true, mode: 'string' }),
timeLimit: integer("time_limit").notNull(),
memoryLimit: integer("memory_limit").notNull(),
visible: boolean().default(true).notNull(),
difficulty: text().notNull(),
source: text(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
submissionNumber: bigint("submission_number", { mode: "number" }).default(0).notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
acceptedNumber: bigint("accepted_number", { mode: "number" }).default(0).notNull(),
createdById: integer("created_by_id").notNull(),
displayId: text("_id").notNull(),
statisticInfo: jsonb("statistic_info").default({}).notNull(),
contestId: integer("contest_id"),
isPublic: boolean("is_public").default(false).notNull(),
shareSubmission: boolean("share_submission").default(false).notNull(),
prompt: text(),
answers: jsonb(),
allowFlowchart: boolean("allow_flowchart").default(false).notNull(),
flowchartData: jsonb("flowchart_data").default({}).notNull(),
flowchartHint: text("flowchart_hint"),
mermaidCode: text("mermaid_code"),
showFlowchart: boolean("show_flowchart").default(false).notNull(),
astRules: jsonb("ast_rules"),
sqlConfig: jsonb("sql_config"),
sqlDisplay: jsonb("sql_display"),
}, (table) => [
index("problem__id_919b1d80").using("btree", table.displayId.asc().nullsLast().op("text_ops")),
index("problem_contest_id_328e013a").using("btree", table.contestId.asc().nullsLast().op("int4_ops")),
index("problem_contest_visible_idx").using("btree", table.contestId.asc().nullsLast().op("bool_ops"), table.visible.asc().nullsLast().op("int4_ops")),
index("problem_created_by_id_cb362143").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
index("problem_visible_idx").using("btree", table.visible.asc().nullsLast().op("bool_ops")),
foreignKey({
columns: [table.contestId],
foreignColumns: [contest.id],
name: "problem_contest_id_328e013a_fk_contest_id"
}),
foreignKey({
columns: [table.createdById],
foreignColumns: [user.id],
name: "problem_created_by_id_cb362143_fk_user_id"
}),
unique("unique_problem_id_contest").on(table.displayId, table.contestId),
]);
export const problemTags = pgTable("problem_tags", {
id: serial().primaryKey().notNull(),
problemId: integer("problem_id").notNull(),
problemtagId: integer("problemtag_id").notNull(),
}, (table) => [
index("problem_tags_problem_id_866ecb8d").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
index("problem_tags_problemtag_id_72d20571").using("btree", table.problemtagId.asc().nullsLast().op("int4_ops")),
foreignKey({
columns: [table.problemId],
foreignColumns: [problem.id],
name: "problem_tags_problem_id_866ecb8d_fk_problem_id"
}),
foreignKey({
columns: [table.problemtagId],
foreignColumns: [problemTag.id],
name: "problem_tags_problemtag_id_72d20571_fk_problem_tag_id"
}),
unique("problem_tags_problem_id_problemtag_id_318459d1_uniq").on(table.problemId, table.problemtagId),
]);
export const problemTag = pgTable("problem_tag", {
id: serial().primaryKey().notNull(),
name: text().notNull(),
}, (table) => [
uniqueIndex("problem_tag_name_ci_unique").using("btree", sql`lower(name)`),
]);
export const submission = pgTable("submission", {
id: text().primaryKey().notNull(),
contestId: integer("contest_id"),
problemId: integer("problem_id").notNull(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
userId: integer("user_id").notNull(),
code: text().notNull(),
result: integer().default(6).notNull(),
info: jsonb().default({}).notNull(),
language: text().notNull(),
shared: boolean().default(false).notNull(),
statisticInfo: jsonb("statistic_info").default({}).notNull(),
username: text().notNull(),
ip: text(),
// 来源题单:学生从题单入口(/problemset/:id/problem/:pid提交时记下来
// 提交列表据此标出「这条来自题单」。**只是来源标记**,题单进度、奖章一概不看它,
// 那些由判完之后的 recordSolvedProblem 按「已加入且含这道题的所有题单」记账。
// 老数据里只有迁移 0007 从 problemset_submission 回填的首次 AC 有值。
problemsetId: bigint("problemset_id", { mode: "number" }),
}, (table) => [
// 同上,不写 .op()。原先 pull 出来的 opclass 还串了位contest_id 标成 timestamptz_ops、
// create_time 标成 int4_ops那条 SQL 真拿去执行 Postgres 会直接拒绝。
index("contest_create_time_idx").using("btree", table.contestId.asc().nullsLast(), table.createTime.desc().nullsFirst()),
// 提交列表默认视图WHERE contest_id IS NULL ORDER BY create_time DESC, id DESC专用。
// 上面的 contest_create_time_idx 看着能覆盖,但 Postgres 不把 `contest_id IS NULL`
// 当成能吃掉首列、从而继承第二列有序性的等值条件——把 seqscan/bitmapscan 全关掉逼它
// 也不肯用,只会走单列 contest_id 索引再全量排序。结果是每翻一页都 Parallel Seq Scan
// 扫完整张表 + top-N 排序。改用部分索引后谓词由索引本身保证,排序序就是索引序。
// 生产快照12.3 万条提交实测61.8ms / 18936 blocks → 0.22ms / 34 blocks。
// 这个索引不在 Django 的 migration 里,是 OJ2 单独加的,见 src/db/0001。
//
// 带上 id 是为了让排序成为**全序**深翻页的游标转换routes/submission.ts 的
// paginateSubmissionRows才精确。create_time 由 `new Date().toISOString()` 生成,
// 只有毫秒精度,同毫秒的两条提交靠 create_time 分不出先后:游标用 `<=` 回查时,
// 上一页的末行会重新出现在下一页页首。加上 id 之后两步用的是同一个全序,不会错位。
// 索引从 2.3MB 涨到 6.9MB,快照实测第一步 5.7ms → 8.9ms,换精确值得。
//
// 两列都建成默认的 ASC NULLS LAST靠 Index Only Scan **Backward** 服务
// `ORDER BY create_time DESC, id DESC`。别照着 ORDER BY 写成 .desc()Postgres 里
// `ORDER BY x DESC` 默认是 NULLS FIRST而 `CREATE INDEX ... (x DESC)` 默认是
// NULLS LAST两边 nulls 位置对不上,规划器就当这条索引出不了序——实测建成
// DESC NULLS LAST 之后深翻页退化成 external merge sort5.2MB 落盘),比不建还糟。
// 两列同为 ASC 时整条索引反着扫就是精确的反序,所以反而是能用的那一种。
// 这两列都 NOT NULLnulls 位置在语义上无所谓,纯粹是规划器的匹配规则。
index("submission_public_create_time_id_idx").using("btree", table.createTime.asc().nullsLast(), table.id.asc().nullsLast()).where(sql`${table.contestId} is null`),
index("problem_user_idx").using("btree", table.problemId.asc().nullsLast().op("int4_ops"), table.userId.asc().nullsLast().op("int4_ops")),
index("submission_contest_id_775716d5").using("btree", table.contestId.asc().nullsLast().op("int4_ops")),
index("submission_problem_id_76847b55").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
index("submission_result_37e2f67a").using("btree", table.result.asc().nullsLast().op("int4_ops")),
index("submission_user_id_3779a8c1").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
index("user_create_time_idx").using("btree", table.userId.asc().nullsLast().op("int4_ops"), table.createTime.asc().nullsLast().op("timestamptz_ops")),
foreignKey({
columns: [table.contestId],
foreignColumns: [contest.id],
name: "submission_contest_id_775716d5_fk_contest_id"
}),
foreignKey({
columns: [table.problemId],
foreignColumns: [problem.id],
name: "submission_problem_id_76847b55_fk_problem_id"
}),
// 部分索引:绝大多数提交不来自题单,全列索引等于给 12 万行白建一遍。
// 谓词是 IS NOT NULL`problemset_id = $1` 蕴含非空,所以删题单时的外键检查
// 也能用上它——不然那条检查要顺序扫全表。
index("submission_problemset_id_idx").using("btree", table.problemsetId.asc().nullsLast()).where(sql`${table.problemsetId} is not null`),
// 删掉题单不该带走提交:置空来源标记就行,提交本身照旧存在。
foreignKey({
columns: [table.problemsetId],
foreignColumns: [problemset.id],
name: "submission_problemset_id_fk_problemset_id"
}).onDelete("set null"),
]);
export const tutorial = pgTable("tutorial", {
id: integer().primaryKey().generatedByDefaultAsIdentity({ name: "tutorial_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: 2147483647, cache: 1 }),
title: varchar({ length: 128 }).notNull(),
content: text().notNull(),
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).notNull(),
isPublic: boolean("is_public").notNull(),
order: integer().notNull(),
createdById: integer("created_by_id").notNull(),
code: text(),
type: varchar({ length: 10 }).notNull(),
}, (table) => [
index("tutorial_created_by_id_07973cab").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
foreignKey({
columns: [table.createdById],
foreignColumns: [user.id],
name: "tutorial_created_by_id_07973cab_fk_user_id"
}),
]);
export const userStat = pgTable("user_stat", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_stat_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
metrics: jsonb().default({}).notNull(),
updateTime: timestamp("update_time", { withTimezone: true, mode: 'string' }).notNull(),
userId: integer("user_id").notNull(),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "user_stat_user_id_73337fc0_fk_user_id"
}),
unique("user_stat_user_id_key").on(table.userId),
]);
export const userAchievement = pgTable("user_achievement", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_achievement_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
unlockTime: timestamp("unlock_time", { withTimezone: true, mode: 'string' }).notNull(),
backfilled: boolean().default(false).notNull(),
notified: boolean().default(false).notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
achievementId: bigint("achievement_id", { mode: "number" }).notNull(),
userId: integer("user_id").notNull(),
}, (table) => [
index("user_achievement_achievement_id_29db600d").using("btree", table.achievementId.asc().nullsLast().op("int8_ops")),
index("user_achievement_user_id_b8ec7d6a").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
index("user_achv_notified_idx").using("btree", table.userId.asc().nullsLast().op("int4_ops"), table.notified.asc().nullsLast().op("bool_ops")),
index("user_achv_time_idx").using("btree", table.userId.asc().nullsLast().op("int4_ops"), table.unlockTime.desc().nullsFirst().op("timestamptz_ops")),
foreignKey({
columns: [table.achievementId],
foreignColumns: [achievement.id],
name: "user_achievement_achievement_id_29db600d_fk_achievement_id"
}),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "user_achievement_user_id_b8ec7d6a_fk_user_id"
}),
unique("unique_user_achievement").on(table.achievementId, table.userId),
]);
export const userBadge = pgTable("user_badge", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "user_badge_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
earnedTime: timestamp("earned_time", { withTimezone: true, mode: 'string' }).notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
badgeId: bigint("badge_id", { mode: "number" }).notNull(),
userId: integer("user_id").notNull(),
}, (table) => [
index("user_badge_badge_id_92a983e9").using("btree", table.badgeId.asc().nullsLast().op("int8_ops")),
index("user_badge_user_id_a286d718").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
foreignKey({
columns: [table.badgeId],
foreignColumns: [problemsetBadge.id],
name: "user_badge_badge_id_92a983e9_fk_problemset_badge_id"
}),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "user_badge_user_id_a286d718_fk_user_id"
}),
unique("unique_user_badge").on(table.badgeId, table.userId),
]);
export const userProfile = pgTable("user_profile", {
id: serial().primaryKey().notNull(),
acmProblemsStatus: jsonb("acm_problems_status").default({}).notNull(),
avatar: text().notNull(),
blog: varchar({ length: 200 }),
mood: text(),
acceptedNumber: integer("accepted_number").default(0).notNull(),
submissionNumber: integer("submission_number").default(0).notNull(),
github: text(),
school: text(),
major: text(),
userId: integer("user_id").notNull(),
realName: text("real_name"),
language: text(),
}, (table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "user_profile_user_id_8fdce8e2_fk_user_id"
}),
unique("user_profile_user_id_key").on(table.userId),
]);
export const acmContestRank = pgTable("acm_contest_rank", {
id: serial().primaryKey().notNull(),
submissionNumber: integer("submission_number").default(0).notNull(),
acceptedNumber: integer("accepted_number").default(0).notNull(),
totalTime: integer("total_time").default(0).notNull(),
submissionInfo: jsonb("submission_info").default({}).notNull(),
contestId: integer("contest_id").notNull(),
userId: integer("user_id").notNull(),
}, (table) => [
index("acm_contest_rank_contest_id_21030ccd").using("btree", table.contestId.asc().nullsLast().op("int4_ops")),
index("acm_contest_rank_user_id_40391ab2").using("btree", table.userId.asc().nullsLast().op("int4_ops")),
index("acm_rank_contest_user_idx").using("btree", table.contestId.asc().nullsLast().op("int4_ops"), table.userId.asc().nullsLast().op("int4_ops")),
index("acm_rank_order_idx").using("btree", table.contestId.asc().nullsLast().op("int4_ops"), table.acceptedNumber.asc().nullsLast().op("int4_ops"), table.totalTime.asc().nullsLast().op("int4_ops")),
foreignKey({
columns: [table.contestId],
foreignColumns: [contest.id],
name: "acm_contest_rank_contest_id_21030ccd_fk_contest_id"
}),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "acm_contest_rank_user_id_40391ab2_fk_user_id"
}),
unique("unique_acm_rank_user_contest").on(table.contestId, table.userId),
]);
export const user = pgTable("user", {
id: serial().primaryKey().notNull(),
password: varchar({ length: 128 }).notNull(),
lastLogin: timestamp("last_login", { withTimezone: true, mode: 'string' }),
username: text().notNull(),
email: text(),
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }),
adminType: text("admin_type").notNull(),
authToken: text("auth_token"),
openApi: boolean("open_api").default(false).notNull(),
openApiAppkey: text("open_api_appkey"),
isDisabled: boolean("is_disabled").default(false).notNull(),
problemPermission: text("problem_permission").notNull(),
sessionKeys: jsonb("session_keys").default([]).notNull(),
rawPassword: varchar("raw_password", { length: 20 }),
className: text("class_name"),
}, (table) => [
unique("user_username_key").on(table.username),
]);
export const problemsetBadge = pgTable("problemset_badge", {
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
id: bigint({ mode: "number" }).primaryKey().generatedByDefaultAsIdentity({ name: "problemset_badge_id_seq", startWith: 1, increment: 1, minValue: 1, maxValue: "9223372036854775807", cache: 1 }),
name: text().notNull(),
description: text().notNull(),
icon: text().notNull(),
conditionType: text("condition_type").notNull(),
conditionValue: integer("condition_value").notNull(),
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
problemsetId: bigint("problemset_id", { mode: "number" }).notNull(),
}, (table) => [
index("problemset_badge_problemset_id_6cb6c74f").using("btree", table.problemsetId.asc().nullsLast().op("int8_ops")),
foreignKey({
columns: [table.problemsetId],
foreignColumns: [problemset.id],
name: "problemset_badge_problemset_id_6cb6c74f_fk_problemset_id"
}),
]);
/**
* 自学模块的留痕:一个学生 × 一课一行。
*
* OJ2 自己建的表,不是 Django 遗留,所以没有代理主键 —— 写入路径只有一条 upsert
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
*
* **只记「读到哪一课 + 停留多久」,不记练一练的作答。** 练习的对错全在浏览器里判
* (见 apps/web/src/oj/learn/components/),学生可以随便重试到对为止,上报上来也只是
* 「他按了几次按钮」,不构成教学证据,还要为此多养一张按人按题膨胀的表。
*
* 外键这里**用了库级 CASCADE**,和 Django 建的那些 NO ACTION 外键不同:删教程、删用户
* 都不必再记得回来手工清一遍子表(后台删教程的事务里就没清它,靠的就是这里)。
*/
export const tutorialProgress = pgTable("tutorial_progress", {
userId: integer("user_id").notNull(),
tutorialId: integer("tutorial_id").notNull(),
// 打开次数。只有「进入这一课」才 +1后续补时长的心跳不动它
viewCount: integer("view_count").default(0).notNull(),
// 累计停留秒数。前端只在页面可见、且人没挂机时计时,见 useLearnTrace.ts
totalSeconds: integer("total_seconds").default(0).notNull(),
firstViewedAt: timestamp("first_viewed_at", { withTimezone: true, mode: 'string' }).notNull(),
lastViewedAt: timestamp("last_viewed_at", { withTimezone: true, mode: 'string' }).notNull(),
}, (table) => [
primaryKey({ columns: [table.userId, table.tutorialId], name: "tutorial_progress_pkey" }),
// 按课汇总(「这一课全班多少人读过」)要扫这一列,主键的前缀索引帮不上忙
index("tutorial_progress_tutorial_id_idx").on(table.tutorialId),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "tutorial_progress_user_id_fk_user_id"
}).onDelete("cascade"),
foreignKey({
columns: [table.tutorialId],
foreignColumns: [tutorial.id],
name: "tutorial_progress_tutorial_id_fk_tutorial_id"
}).onDelete("cascade"),
]);
/**
* 练一练的留痕:一个学生 × 一道练习一行。
*
* 存的是**聚合**,不是流水:试了几次、错了几次、做没做对、第几次做对的、
* 最后一次做错时填的什么。一道题一个学生一行,全校封顶就是「学生数 × 练习数」,
* 而流水会随着学生反复点「提交」无限长 —— 而且多存的那些行回答不了任何新问题:
* 「他第 3 次和第 5 次都选了 B」对老师没有意义「他试了 7 次才对」有。
*
* `lastWrongAnswer` 存的是**前端拼好的一句人话**(「选了 A、C」「第 2 空填了 xy」
* 不是原始作答结构:七种题型的作答形状各不相同,存结构就得在后台按题型各写一套
* 渲染,而老师要看的只是「他错在哪」。前端本来就知道怎么把自己的作答说成人话。
*/
export const exerciseAttempt = pgTable("exercise_attempt", {
userId: integer("user_id").notNull(),
exerciseId: integer("exercise_id").notNull(),
// 提交次数。同一份答案连点两次只算一次,见 ExerciseWidget.vue 的去重
attempts: integer().default(0).notNull(),
wrongAttempts: integer("wrong_attempts").default(0).notNull(),
solved: boolean().default(false).notNull(),
// 第一次做对时累计试了几次。做对之后就不再变 —— 后面再点提交不该把它改大
attemptsToSolve: integer("attempts_to_solve"),
lastWrongAnswer: text("last_wrong_answer"),
firstAttemptAt: timestamp("first_attempt_at", { withTimezone: true, mode: 'string' }).notNull(),
lastAttemptAt: timestamp("last_attempt_at", { withTimezone: true, mode: 'string' }).notNull(),
solvedAt: timestamp("solved_at", { withTimezone: true, mode: 'string' }),
}, (table) => [
primaryKey({ columns: [table.userId, table.exerciseId], name: "exercise_attempt_pkey" }),
// 按题汇总(「这道题全班多少人做对」)要扫这一列
index("exercise_attempt_exercise_id_idx").on(table.exerciseId),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: "exercise_attempt_user_id_fk_user_id"
}).onDelete("cascade"),
foreignKey({
columns: [table.exerciseId],
foreignColumns: [exercise.id],
name: "exercise_attempt_exercise_id_fk_exercise_id"
}).onDelete("cascade"),
]);