Files
OJ2/apps/api/src/db/schema.ts
T
xuyueandClaude Opus 5.5 8427475337
Deploy / deploy (push) Canceled after 0s
chore(清理): 删掉并行试跑、搬库、Python3 别名、旧链接这些过渡期的兼容写法
- 部署:JUDGE_STATE_DIR 不再强制设;删掉搬库脚本和只为搬库存在的自检、提醒
- 语言:Python3 / Python2 别名表删掉,normalizeLanguage 改名 parseLanguage
- 旧链接:后台题单详情的跳转、用户列表 -last_login 的转换
- 旧页面:布置作业必须带 language(null = 没选);不再清旧版出题页草稿

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:15:04 +08:00

1405 lines
60 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 张业务表。
//
// 2026-09-02:又清了一批只有 Django 时代写过、OJ2 一次都没读过的列 ——
// 0008 删比赛 IP 白名单与 submission.ip(判题机的 judge_server.ip 保留,那是运维数据),
// 0009 删 user 的 auth_token / open_api / open_api_appkey / session_keys 和
// user_profile 的 blog / github / school / major / language。**删的判据是「全仓零读取」**,
// 不是「看着没用」:raw_password 同样刺眼却是在用的(老师要能查学生密码),别一起清掉。
//
// 手工修正(都是 `pull` 自己没法无损 round-trip 的地方,改回去会让 generate 产生假 diff,
// 详见 CLAUDE.md「改 schema 走 drizzle migration」):
// * bigint identity 的 maxValue 用字符串,不能写成 JS number 字面量(会丢精度)。
// * 索引不写 `.desc()`,生成 SQL 时方向会被丢掉。
// * `.$type<...>()` 的收窄(见下)—— `pull` 只会吐出 text/integer/jsonb,重新 pull
// 会把这些断言全抹掉,之后出参的 `satisfies` 会当场编译不过(这是好事,别拿
// `as` 糊过去,把断言补回来)。
//
// 2026-09-10:出参不再 `xxxSchema.parse()` 而是 `satisfies`(原来 136 处),
// 收窄的责任因此挪到了列上:枚举型的列和几个形状确定的 JSONB 都挂了 `.$type<>()`。
// `$type` 只是 TS 层的断言、不产生任何 SQL,所以它成立与否得靠数据说话 ——
// 下面每一处都拿根目录那份生产备份逐列核过(12.4 万条提交的 result 全在 -2..6,10、
// 961 道题的 languages 全是合法数组、10050 条榜单条目形状全对,无一例外)。
// **再给别的列加 $type 之前,照样先核一遍全量数据。** 兑现这些断言的是写入侧的
// `safeParse`,不是读出侧。前因后果见 CLAUDE.md「出参不 `parse`,用 `satisfies`」。
//
// 关于 10 张表的 bigint id(problemset*、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 type {
AchievementOperator,
AchievementRarity,
AdminType,
AstRules,
BadgeConditionType,
ContestSubmissionInfo,
ExerciseType,
FlowchartStatus,
HintDiagnosis,
JudgeStatus,
LessonLanguage,
ProblemChallenges,
ProblemDifficulty,
ProblemLanguage,
ProblemPermission,
ReactionKey,
SqlConfig,
SqlDisplay,
TagCategory,
TutorialType,
} from '@oj2/contract'
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 出来的是全 ASC,schema.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().$type<AchievementRarity>(),
hidden: boolean().default(false).notNull(),
metric: text().notNull(),
operator: text().notNull().$type<AchievementOperator>(),
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(),
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().$type<FlowchartStatus>(),
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')),
// 流程图列表分页。原来是 hash join 全表再 top-N 排序(4.5ms / 551 buffers),
// 走这条之后 0.19ms / 47。绝对值不大,但索引只要 64kB,而这张表每行带一大坨
// jsonb,行数涨上去是线性恶化的。ASC 反向扫,理由同 submission 那几条。
index('flowchart_create_time_idx').using('btree', table.createTime.asc().nullsLast()),
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',
}).onDelete('cascade'),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'flowchart_submission_user_id_225c83e8_fk_user_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().$type<ExerciseType>(),
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',
}).onDelete('cascade'),
],
)
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 / status 两列 0027 起代码不再读写(线上全是 Easy / active,「状态 + 可见」
// 合成了「公开」),等两个站点都换上新代码再删列
createdById: integer('created_by_id').notNull(),
/**
* 布置期:assignedAt 是这一轮布置开始的时刻,assignedUntil 是布置到哪天(东八区当天结束)。
* 布置期内,题单里这几道题学生在 assignedAt 之前交的代码先藏起来(防抄),见
* services/problemset.ts 的 problemSetLockCutoffs。不拦做题,过期照样做、照样记进度。
* 两个都为空 = 没布置。
*
* 列名还是 end_time(以前叫「截止时间」,名不副实):机房那套和服务器共用这个库、
* 不一定同时升级,改列名会让没升级的那边一查题单就报错,所以只改代码里的名字
*/
assignedUntil: timestamp('end_time', { withTimezone: true, mode: 'string' }),
assignedAt: timestamp('assigned_at', { 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 / hint 两列 0027 起不再读写(分数拿掉了,提示一条都没人填过),待删
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')),
foreignKey({
columns: [table.problemId],
foreignColumns: [problem.id],
name: 'problemset_problem_problem_id_fff2d686_fk_problem_id',
}).onDelete('cascade'),
foreignKey({
columns: [table.problemsetId],
foreignColumns: [problemset.id],
name: 'problemset_problem_problemset_id_350d17fb_fk_problemset_id',
}).onDelete('cascade'),
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(),
// progress_percentage / total_score 两列 0027 起不再读写(百分比随用随算,分数拿掉了),待删
completedProblemsCount: integer('completed_problems_count').notNull(),
totalProblemsCount: integer('total_problems_count').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_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',
}).onDelete('cascade'),
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),
],
)
// problemset_submission 表 0027 起不再读写(每次做对都往里写一行,从来没有地方读它),待删
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().$type<ReactionKey>(),
createTime: timestamp('create_time', {
withTimezone: true,
mode: 'string',
}).notNull(),
problemId: integer('problem_id').notNull(),
userId: integer('user_id').notNull(),
},
(table) => [
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',
}).onDelete('cascade'),
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().$type<ProblemLanguage[]>(),
template: jsonb().notNull().$type<Record<string, string>>(),
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().$type<ProblemDifficulty>(),
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(),
/**
* 已停用。「提交互相可见」的两个开关(这个是题目级,submission.shared 是单条级)
* 连同判定分支一起删掉了:生产库 956 道题里只有 2 道打开过,还都是比赛题
* (比赛未结束时那条分支根本走不到),出题页也从来没给过开关。
* 列保留不删:删列是破坏性迁移,而留着不写不读没有任何代价。
*/
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').$type<AstRules>(),
/** 做对之后的挑战,按语言分组。写入侧在 admin/problem.ts 校验,见契约 challengeSchema */
challenges: jsonb().$type<ProblemChallenges>(),
sqlConfig: jsonb('sql_config').$type<SqlConfig>(),
sqlDisplay: jsonb('sql_display').$type<SqlDisplay>(),
},
(table) => [
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_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',
}).onDelete('cascade'),
foreignKey({
columns: [table.problemtagId],
foreignColumns: [problemTag.id],
name: 'problem_tags_problemtag_id_72d20571_fk_problem_tag_id',
}).onDelete('cascade'),
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(),
category: text().notNull().default('knowledge').$type<TagCategory>(),
// 分类有没有人确认过。出题时顺手新建的标签一律先落在「知识点」,这一列为 false,
// 后台标签管理把它们排在最前面等老师归类;存量标签由 0022 统一标成 true
categoryConfirmed: boolean('category_confirmed').notNull().default(false),
},
() => [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().$type<JudgeStatus>(),
info: jsonb().default({}).notNull(),
language: text().notNull().$type<ProblemLanguage>(),
/**
* 已停用,见 problem.share_submission 的说明。历史上 12.3 万条提交里有 40 条
* 为真(2022 年 39 条、2023 年 1 条),入口在更早的那版前端上,ojnext 和 OJ2
* 都没有把它搬过来。现在没有任何代码读写它,行里的历史值原样留着。
*/
shared: boolean().default(false).notNull(),
statisticInfo: jsonb('statistic_info').default({}).notNull(),
username: text().notNull(),
// 来源题单:学生从题单入口(/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 sort(5.2MB 落盘),比不建还糟。
// 两列同为 ASC 时整条索引反着扫就是精确的反序,所以反而是能用的那一种。
// 这两列都 NOT NULL,nulls 位置在语义上无所谓,纯粹是规划器的匹配规则。
index('submission_public_create_time_id_idx')
.using('btree', table.createTime.asc().nullsLast(), table.id.asc().nullsLast())
.where(sql`${table.contestId} is null`),
/**
* Django 给每个外键都自动建了一个单列索引,`db_index=True` 的还会多一个
* `_like`(text_pattern_ops)。0012 把其中 21 个删了 —— 它们的列都是某个
* 复合索引的**最左前缀**,规划器本来就走那一个,多出来的只是每次写入要多维护
* 一棵树。这张表上删的三个是 contest_id / problem_id / user_id,分别被下面
* 的 contest_create_time_idx、problem_user_idx、user_create_time_idx 覆盖。
*
* 加新索引时先看一眼有没有现成的复合索引已经以它打头,别把这批又建回来。
*/
index('problem_user_idx').using('btree', table.problemId.asc().nullsLast().op('int4_ops'), table.userId.asc().nullsLast().op('int4_ops')),
/**
* `submission_result_37e2f67a` 是 Django 建的单列索引,**别当成被下面
* submission_result_time_idx 覆盖了就删**:那个是 `WHERE contest_id IS NULL`
* 的部分索引,管不了「全库含比赛按 result 统计」那类查询(实测删掉之后
* `count(*) where result in (6,7)` 从走索引掉回 75ms 全表扫)。856kB,留着。
*/
index('submission_result_37e2f67a').using('btree', table.result.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')),
/**
* 提交列表的「语言」和「结果」两个下拉筛选。原来这两列上要么没索引、要么只有
* 不带 `contest_id IS NULL` 的单列索引,翻页那条靠 submission_public_create_time_id_idx
* 边扫边滤还能对付,**count 那条只能全表扫**(快照实测固定 75~82ms / 18448 buffers,
* 筛什么值都一样)。加完:语言 count 80ms → 11ms(Python,占 8 成)/ 1.6ms(C),
* 结果 count 75ms → 2.0ms。
*
* 更要命的是冷门语言的**翻页**:JavaScript 只有 3 条、全是很早以前的,分页索引得从
* 最新一路倒扫到底才凑够一页,43ms 全表扫;走这条索引是 0.02ms。(这个实测当年用的
* 是 Python2,那 3 条 2026-09 已被 0019 迁移并进 Python,换了个同样冷门的值举例。)
*
* 两列都 ASC NULLS LAST,理由同上面 submission_public_create_time_id_idx ——
* 靠 Index Scan **Backward** 出 `ORDER BY create_time DESC`。这里再实测了一遍:
* 写成 DESC NULLS LAST 规划器直接不认这条索引,回落到分页索引带 Filter。
*/
index('submission_language_time_idx')
.using('btree', table.language.asc().nullsLast(), table.createTime.asc().nullsLast())
.where(sql`${table.contestId} is null`),
index('submission_result_time_idx')
.using('btree', table.result.asc().nullsLast(), table.createTime.asc().nullsLast())
.where(sql`${table.contestId} is null`),
/**
* 提交列表的「题号」筛选。路由先把题号解析成 problem.id(见 routes/submission.ts 的
* problemFilter),这条索引才用得上:等值定位到一道题,剩下两列正好是翻页的全序,
* 深翻页的游标也照走。列方向同上面几条,全 ASC 靠 Backward 扫。
*
* problem_user_idx 以 problem_id 打头,但不带时间:一道题最近几个月没人交的话,
* 规划器照样选分页索引倒扫、边扫边滤。快照实测 1047(6 月之后没人交)17ms 倒扫
* 2 万行、3017(最后一次在 2024 年)59ms 倒扫 8.2 万行、不存在的题号扫完全表。
*/
index('submission_public_problem_time_idx')
.using('btree', table.problemId.asc().nullsLast(), table.createTime.asc().nullsLast(), table.id.asc().nullsLast())
.where(sql`${table.contestId} is null`),
/**
* 提交列表的「用户名」筛选是 `ilike '%x%'`,btree 帮不上,只有 trigram 能索引中缀匹配。
* 扩展在迁移 0015 里装(官方 postgres 镜像自带 contrib,pg_trgm 是 trusted 扩展)。
* 3MB。模式不足 3 个字符时 trigram 抽不出东西,照旧全表扫——那种前缀匹配大半张表,
* 扫表本来就是对的计划。
*/
index('submission_public_username_trgm_idx')
.using('gin', table.username.op('gin_trgm_ops'))
.where(sql`${table.contestId} is null`),
/**
* 覆盖索引,专门给「在全部公开提交上做聚合」那几个接口用:教师统计不填班级、
* 活跃榜、题目 AC 趋势。它们慢的**不是聚合本身,是为了读这四个小列把 145MB 的堆
* 翻一遍** —— `code` 和 `info` 占了这张表的绝大部分体积,聚合一列都用不上。
*
* 有了它这些查询走 Index Only Scan,只读 6MB。快照实测:
* 教师统计全站 186ms → 49ms(还消掉了 4.2MB 的落盘排序)、活跃榜 108ms → 18ms、
* AC 趋势 120ms → 41ms,buffers 一律从 18000+ 掉到 2000 以内。
*
* 列序按 user_id 打头:三个查询里两个按人分组,能省掉排序。加列要谨慎 ——
* 多一列就多一份 10 万行的拷贝,而它的价值全在「窄」上。
*/
index('submission_public_metrics_idx')
.using('btree', table.userId.asc().nullsLast(), table.problemId.asc().nullsLast(), table.result.asc().nullsLast(), table.createTime.asc().nullsLast())
.where(sql`${table.contestId} is null`),
/**
* 题目列表每一页的「做对 / 做过人数」(`services/problem-progress.ts` 的
* problemUserCounts):按 problem_id 取一页 30 道题、数 distinct user_id。
* 上面那条以 user_id 打头,按题号查只能把整棵索引 11 万行扫一遍再滤,12–18ms 而且
* 随提交数线性涨;这条以 problem_id 打头,快照实测 12ms → 3.6ms,「最多人做对」排序
* 的全表聚合 85ms → 16ms。
*
* problem_user_idx 也以 problem_id 打头,但它不是部分索引、也不带 result,规划器
* 不选它(还得回表看 contest_id 和 result)。
*/
index('submission_public_problem_user_idx')
.using('btree', table.problemId.asc().nullsLast(), table.userId.asc().nullsLast(), table.result.asc().nullsLast())
.where(sql`${table.contestId} is null`),
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'),
],
)
/**
* 提交时附带的编辑过程信号,和 submission 一对一。字段含义见契约的
* `submissionTraceSchema`,这里只记表本身的取舍:
*
* - **没有行 ≠ 可疑。** 2026-09 之前的全部历史提交、刷新过页面的、老版本前端交的
* 都没有 trace,用它的地方一律把「缺失」当「无数据」。
* - 类型化的列而不是一个 jsonb:「可信 AC」和学情热力图要在 SQL 里按这些值筛、聚合。
* - `since_prev_ms` 是唯一由**服务端**算的一列(距同一用户同一道题上一次提交),
* 客户端伪造不了;这道题的第一次提交为 null。
* - CASCADE 挂在 submission 上、不挂 user:它是提交的附属,提交没了它没有意义,
* 人是谁顺着 submission 就能查到。同表的 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'),
],
)
/**
* 每一次「让 AI 分析我的代码」(POST /ai/hint)的留痕,连同学生的评价。
*
* 在此之前提示一条都没落库,用了多少次、在哪些题上用、用完有没有做出来都无从知道;
* 之后要做的提示分级、错误归因都得拿这张表做对照。**生成失败的也记**(content 为空、
* error 有值),失败率是 AI 功能悄悄变差时最先动的那个数。
*
* - 不存 prompt 原文:学生代码在 submission 里、题面在 problem 里,重复存一遍没有意义。
* 存的是 `prompt_version` —— 改 system / prompt 的拼法时在 routes/ai.ts 里加一,
* 事后才分得清哪批提示是按哪版生成的。
* - 人和题顺着 submission 查(submission_id 上有索引)。和 submission_trace 一样
* CASCADE 挂在提交上:提交没了,这条提示也就没有上下文了。
* - 同一条提交可以有多条:刷新页面之后按钮会重新出现。
*/
export const aiHint = pgTable(
'ai_hint',
{
id: bigint({ mode: 'number' }).primaryKey().generatedByDefaultAsIdentity({
name: 'ai_hint_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
maxValue: '9223372036854775807',
cache: 1,
}),
submissionId: text('submission_id').notNull(),
model: text().notNull(),
promptVersion: integer('prompt_version').notNull(),
// 生成失败时为空串,失败原因在 error
content: text().notNull(),
error: text(),
// 从收到请求到生成结束(或失败)的毫秒数,两段式时含诊断那一段
durationMs: integer('duration_ms').notNull(),
/**
* 两段式第一段的诊断结果(见 services/hint-diagnosis.ts)。**只存 safeParse 过的**,
* 所以 `$type` 成立 —— 闸在写入侧。没开两段式、编译失败(不诊断)、诊断失败时为 null。
* 同一条提交再要提示时复用这里的结果,不再调一次模型。
*/
diagnosis: jsonb().$type<HintDiagnosis>(),
// 诊断失败的原因(超时、回的不是 JSON、校验不过)。这时第二段退回单段式的 prompt
diagnosisError: text('diagnosis_error'),
/**
* 这条提示给的是哪一级(契约的 `HINT_LEVELS`,AI 时代 OJ 设计 2c)。
* -1 = 编译失败那一档,**不在阶梯上**;null = 2c 上线前那批不分级的提示。
* 查阶梯的 SQL 一律 `level >= 0`,两者都被摘掉。
*/
level: integer(),
/**
* 一共生成了几次(1 = 一次就过,2 = 重生成过)。**整段生成、过滤后才推**(边流式边
* 过滤做不到:发现违规时内容已经在学生屏幕上了),违规就重生成一次,只重一次。
*
* **不是「发出去的是第几次生成」** —— `filter_blocked` 为真时两次都作废了,发的是
* 兜底话术。7.5 的「输出过滤触发率」= `filter_attempt = 2` ÷ `filter_attempt` 非空。
*/
filterAttempt: integer('filter_attempt'),
/**
* 两次都违规,发的是写死的兜底话术(content 是那句话,不是模型的输出)。
*
* **这种行的 `error` 仍是 null** —— 生成本身没失败,是内容被拦了。所以算「生成失败率」
* 看 `error`,算「学生有没有真拿到提示」要另外扣掉 `filter_blocked` 为真的这批。
*/
filterBlocked: boolean('filter_blocked'),
// 被拦下的原因,两次都被拦时是两条(用 ; 连)。null = 一次都没触发过过滤
filterReason: text('filter_reason'),
// 学生的评价:null = 没评
helpful: boolean(),
feedbackTime: timestamp('feedback_time', {
withTimezone: true,
mode: 'string',
}),
createTime: timestamp('create_time', {
withTimezone: true,
mode: 'string',
}).notNull(),
},
(table) => [
index('ai_hint_submission_id_idx').on(table.submissionId),
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: 'ai_hint_submission_id_fk_submission_id',
}).onDelete('cascade'),
],
)
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().$type<TutorialType>(),
// 所属项目(「项目三 · 智慧农业·蜜橘园」),自学目录按它分组。0031 从正文第一段的
// 「**所属项目:…**」回填;发布脚本不传它,后端照样从正文里抽,见 admin/tutorial.ts
project: varchar({ length: 64 }).default('').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',
}),
],
)
/**
* 一课挂的「去做几道题」:老师在后台手挑的题目,按 `order` 排。
*
* 两头都 CASCADE:教程删了这串题就没意义了;题目删了,教程少挂一道题也不是学生的
* 做题证据(证据在 submission 那边,它对 problem 是拦住的)。
*/
export const tutorialProblem = pgTable(
'tutorial_problem',
{
tutorialId: integer('tutorial_id').notNull(),
problemId: integer('problem_id').notNull(),
order: integer().notNull(),
},
(table) => [
primaryKey({
columns: [table.tutorialId, table.problemId],
name: 'tutorial_problem_pkey',
}),
foreignKey({
columns: [table.tutorialId],
foreignColumns: [tutorial.id],
name: 'tutorial_problem_tutorial_id_fk_tutorial_id',
}).onDelete('cascade'),
foreignKey({
columns: [table.problemId],
foreignColumns: [problem.id],
name: 'tutorial_problem_problem_id_fk_problem_id',
}).onDelete('cascade'),
],
)
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',
}).onDelete('cascade'),
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_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',
}).onDelete('cascade'),
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')),
foreignKey({
columns: [table.badgeId],
foreignColumns: [problemsetBadge.id],
name: 'user_badge_badge_id_92a983e9_fk_problemset_badge_id',
}).onDelete('cascade'),
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().$type<Record<string, unknown>>(),
avatar: text().notNull(),
mood: text(),
acceptedNumber: integer('accepted_number').default(0).notNull(),
submissionNumber: integer('submission_number').default(0).notNull(),
userId: integer('user_id').notNull(),
realName: text('real_name'),
},
(table) => [
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'user_profile_user_id_8fdce8e2_fk_user_id',
}).onDelete('cascade'),
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().$type<Record<string, ContestSubmissionInfo>>(),
contestId: integer('contest_id').notNull(),
userId: integer('user_id').notNull(),
},
(table) => [
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',
}),
// $type 只是 TS 层的收窄,不产生任何 SQL —— 让 eq(schema.user.adminType, "...")
// 里的角色名也受类型检查。运行时的兜底仍在 auth/session.ts 的 toAdminType。
adminType: text('admin_type').notNull().$type<AdminType>(),
isDisabled: boolean('is_disabled').default(false).notNull(),
problemPermission: text('problem_permission').notNull().$type<ProblemPermission>(),
rawPassword: varchar('raw_password', { length: 20 }),
className: text('class_name'),
/**
* 老师把这个学生「不计入排名」的时刻,null = 正常上榜。怀疑抄代码时用,随时能恢复
* (见 routes/ranking.ts)。只管排名页和首页周榜,比赛排名不受它影响
*/
rankHiddenAt: timestamp('rank_hidden_at', { withTimezone: true, mode: 'string' }),
/**
* 签进 access token 的版本号。改密码、重置密码、禁用账号时加一,这个人所有设备上
* 签出去的 token 立刻作废(见 auth/session.ts 的 revokeUserSessions)。
*
* 放在库里而不是 Redis:机房和服务器共用这个库、各有各的 Redis,
* 在一边改了密码,另一边登着的也得一起下线。
*/
tokenVersion: integer('token_version').default(0).notNull(),
},
(table) => [
unique('user_username_key').on(table.username),
// 「近两年登录过的活跃人数」—— problems/:id/beat-count 每次打开题目详情都要算一遍,
// 而这张表原来只有主键和 username 两个索引,那句统计是全表扫。
index('user_active_idx').using('btree', table.isDisabled.asc().nullsLast(), table.lastLogin.desc().nullsFirst()),
// 按班级 / 按年级(class_name like '241%')取学生:班级榜、班级对比、AI 学情的
// 排名 scope 都走它,见 routes/classroom.ts 的 loadClassUsers。
index('user_class_name_idx').using('btree', table.className.asc().nullsLast()),
],
)
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().$type<BadgeConditionType>(),
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',
}).onDelete('cascade'),
],
)
/**
* 自学模块的留痕:一个学生 × 一课一行。
*
* OJ2 自己建的表,不是 Django 遗留,所以没有代理主键 —— 写入路径只有一条 upsert,
* 冲突目标就是 (user_id, tutorial_id),再挂个 id 序列没有任何用处。
*
* **只记「读到哪一课 + 停留多久」。** 练一练的作答另有一张表(下面的 `exerciseAttempt`),
* 一个学生一道练习一行;「学完」由两张表合起来判(契约的 `isLessonDone`)。
*
* 外键这里**用了库级 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'),
],
)
/**
* 老师给某个班「这节课」布置的题(课堂看板里输入的题号),一个班一天一行。
*
* 学生首页的「班里在做」优先读它,没有才退回从提交记录推断(routes/classroom.ts)。
* 推断在上课头几分钟是空的 —— 还没人交 —— 而这正是没听清题号的学生最需要它的时候。
*
* - `day` 是**东八区日历日**文本(`calendarDay()` 写的 `YYYY-MM-DD`),不用 date 列:
* 「哪一天」的口径只在 time.ts 一处定,不交给驱动和会话时区去解释。
* - `problem_ids` 按老师输入的顺序存 problem.id。**不挂外键**(jsonb 挂不了),
* 读的时候按 visible 过滤,题目被删、被藏的自然不出现;写的时候校验过都存在。
* - `created_by` CASCADE:备课记录不是「学生做过什么」的证据,老师账号没了它也没有意义
* (外键分档见 CLAUDE.md「数据库」一节)。
*/
export const classLesson = pgTable(
'class_lesson',
{
id: integer().primaryKey().generatedByDefaultAsIdentity({
name: 'class_lesson_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 2147483647,
cache: 1,
}),
className: text('class_name').notNull(),
day: text().notNull(),
problemIds: jsonb('problem_ids').notNull().$type<number[]>(),
/**
* 这份作业用什么语言做(Python / C / C++ / SQL),老师布置时选。可空:加这一列之前布置的
* 没有它。写入侧在 PUT /classroom/lesson 按 lessonLanguageSchema 校验过
*/
language: text().$type<LessonLanguage>(),
/** 这天第一次布置的老师,别的老师「改」也不换(和 createdAt 一样)。看板上写「HH:mm 谁 布置」 */
createdBy: integer('created_by').notNull(),
/**
* 这天第一次布置的时刻,之后「改」只动 updatedAt。清掉(删行)再布置算重新开始。
* 加这一列之前布置的用 updated_at 回填(0034)
*/
createdAt: timestamp('created_at', {
withTimezone: true,
mode: 'string',
})
.notNull()
.defaultNow(),
updatedAt: timestamp('updated_at', {
withTimezone: true,
mode: 'string',
}).notNull(),
},
(table) => [
unique('class_lesson_class_day_unique').on(table.className, table.day),
index('class_lesson_created_by_idx').on(table.createdBy),
foreignKey({
columns: [table.createdBy],
foreignColumns: [user.id],
name: 'class_lesson_created_by_fk_user_id',
}).onDelete('cascade'),
],
)
/**
* 谁做到了哪条挑战(problem.challenges 里的 id)。一人一条挑战一行,记第一次做到的那份提交。
*
* 两条路写进来:判题时做对的提交顺手判一遍(judge/run.ts),以及新加一条挑战时拿历史上
* 做对的代码补一遍(services/challenge.ts)—— 后者的 created_at 也是那份提交的时刻,
* 所以「本班第几个、全站第几个做到」按它排,补的和新做的一个口径。
*
* 外键:提交、题目没了,从它算出来的这一行也就没意义了,CASCADE;
* user 跟着 submission 走(submission.user_id 是 NO ACTION,删不掉有提交的用户)。
*/
export const challengeSuccess = pgTable(
'challenge_success',
{
id: integer().primaryKey().generatedByDefaultAsIdentity({
name: 'challenge_success_id_seq',
startWith: 1,
increment: 1,
minValue: 1,
maxValue: 2147483647,
cache: 1,
}),
userId: integer('user_id').notNull(),
problemId: integer('problem_id').notNull(),
challengeId: text('challenge_id').notNull(),
submissionId: text('submission_id').notNull(),
/** 做到的那份提交的时刻(不是写这一行的时刻) */
createdAt: timestamp('created_at', {
withTimezone: true,
mode: 'string',
}).notNull(),
},
(table) => [
unique('challenge_success_user_challenge_unique').on(table.userId, table.problemId, table.challengeId),
index('challenge_success_problem_idx').on(table.problemId, table.challengeId, table.createdAt),
foreignKey({
columns: [table.userId],
foreignColumns: [user.id],
name: 'challenge_success_user_id_fk_user_id',
}),
foreignKey({
columns: [table.problemId],
foreignColumns: [problem.id],
name: 'challenge_success_problem_id_fk_problem_id',
}).onDelete('cascade'),
foreignKey({
columns: [table.submissionId],
foreignColumns: [submission.id],
name: 'challenge_success_submission_id_fk_submission_id',
}).onDelete('cascade'),
],
)