refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
## 出参改 satisfies
出参是后端自己刚拼出来的字面量,TS 编译期已经验过;再 xxxSchema.parse({...}) 一遍
拿不到任何新信息,唯一可能失败的输入是库里的历史数据,而失败的代价是 500。136 处
全部撤掉,撤的时候当场炸出两个一直存在的线上故障:
- 后台打开任何一道没编辑过的题都是 500 —— problem.last_update_time 是全库唯一可空
的列(961 道题里 470 道是 NULL),而 adminProblemSchema.lastUpdateTime 写的是
z.string();
- 收到过站内信的人打开消息页全是 500 —— embeddedSubmissionSchema 从
submissionDetailSchema 继承了 problemDisplayId 却没 omit,路由只填了同义的
problem;列表为空时才碰巧不炸,所以一直没人报。
两个都是读出侧校验自己造出来的故障,不是它拦住的故障。
## 校验责任挪回写入侧
- db/schema.ts:枚举型的列和几个形状确定的 JSONB 挂 .$type<>()(submission.result /
.language、problem.difficulty / .languages / .template / .astRules / .sqlConfig /
.sqlDisplay、achievement.rarity / .operator、exercise.type、reaction.type、
tutorial.type、problemset.difficulty / .status、flowchart_submission.status、
problemset_badge.condition_type、acm_contest_rank.submission_info)。只影响 TS、
不产生 SQL,断言逐列拿根目录那份生产备份核过全量数据。
- createProblemRequestSchema.languages 收窄成 problemLanguageSchema,兑现
problem.languages 列上的断言。
- 新增 routes/helpers.ts 的 asFilterValue():query 筛选值(result / language /
difficulty / status)要和收窄过的列比较时做纯类型交接,不加校验 —— 在这儿拦一道
会把「筛出空列表」变成「筛条件被忽略、返回全部」。
- 判题产物(submission.info / statistic_info / exercise.data)照旧放行,形状真相
在判题机那边;judge/sql、flowchart/run、events.ts 里对自家产物的 parse 一并撤掉。
- 仍然 parse 的只有 judge/events.ts 的 parseSubmissionEvent —— 从 Redis 收回来的
报文是真边界,失败返回 null 而不是 500。
顺带清掉两处重复的真相:stringArray 原本在 routes/helpers.ts、routes/problem.ts、
routes/submission.ts 各有一份拷贝,5 个调用点全部只作用于 problem.languages,列有类型后
三份一起删;routes/site.ts 里和契约同名同形的本地 interface Quote 也删了 —— loadSentences
读入时已经逐字段守过,那处 parse 同样是多余的。
## 文档
CLAUDE.md 那一节从「契约收紧要挑地方」改写成「出参不 parse,用 satisfies」,写明
三处写入侧闸门(入参 safeParse 58 处、列上 $type、语义校验函数);apps/web/CLAUDE.md
同步 —— 现在收紧字段的后果落在 tsc 编译期,但契约形状仍要对得上存量数据。
## 验证
- 生产备份全量:12.4 万条提交的 result 全在 -2..6,10、961 道题的 languages 均为合法
数组、10050 条榜单条目形状全对,无一例外;
- tsc -p apps/api 与 vue-tsc --noEmit 均 exit 0;check:routes 检查 177 条路由,无遮蔽;
前端 build、单二进制编译并在仓库目录之外启动均通过;
- 实跑 40+ 端点(学生端 / 后台 / AI / 榜单 / 题目回写往返),以及一次完整比赛 e2e:
建比赛 → 复制题目 → 错解 → 正解,把 judge/run.ts 榜单写入的三个分支全走到
(error_number 0→1、is_first_ac + ac_time 671、totalTime 1871 = 671 + 1×20×60),
后台核查页的勾选与 404 分支一并验过,测试数据已清理;
- 两个 500 用抓到的真实响应对着改动前的契约复验:lastUpdateTime 收到 null、
problemDisplayId 收到 undefined,改动后同样两个响应均通过。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012j1vgeDqay8wKCh8dPgPcH
This commit is contained in:
@@ -15,6 +15,17 @@
|
||||
// 详见 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 生成时
|
||||
@@ -22,7 +33,26 @@
|
||||
// problem、contest、submission)都是 int4。现存最大 id 一万出头,确实都用不上 bigint,
|
||||
// 但 2026-08-26 评估后决定**不改**:省 4 字节/行毫无意义,ALTER TYPE 要重写整表并拿
|
||||
// ACCESS EXCLUSIVE 锁,而且其中 6 处 id 被外键绑着得连坐。别再提这件事了。
|
||||
import type { AdminType, ProblemPermission } from "@oj2/contract"
|
||||
import type {
|
||||
AchievementOperator,
|
||||
AchievementRarity,
|
||||
AdminType,
|
||||
AstRules,
|
||||
BadgeConditionType,
|
||||
ContestSubmissionInfo,
|
||||
ExerciseType,
|
||||
FlowchartStatus,
|
||||
JudgeStatus,
|
||||
ProblemDifficulty,
|
||||
ProblemLanguage,
|
||||
ProblemPermission,
|
||||
ProblemSetDifficulty,
|
||||
ProblemSetStatus,
|
||||
ReactionKey,
|
||||
SqlConfig,
|
||||
SqlDisplay,
|
||||
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"
|
||||
|
||||
@@ -75,10 +105,10 @@ export const achievement = pgTable("achievement", {
|
||||
name: text().notNull(),
|
||||
description: text().notNull(),
|
||||
icon: text().notNull(),
|
||||
rarity: text().notNull(),
|
||||
rarity: text().notNull().$type<AchievementRarity>(),
|
||||
hidden: boolean().default(false).notNull(),
|
||||
metric: text().notNull(),
|
||||
operator: text().notNull(),
|
||||
operator: text().notNull().$type<AchievementOperator>(),
|
||||
threshold: integer().notNull(),
|
||||
visible: boolean().default(true).notNull(),
|
||||
unlockCount: integer("unlock_count").default(0).notNull(),
|
||||
@@ -111,7 +141,7 @@ export const flowchartSubmission = pgTable("flowchart_submission", {
|
||||
id: text().primaryKey().notNull(),
|
||||
mermaidCode: text("mermaid_code").notNull(),
|
||||
flowchartData: jsonb("flowchart_data").notNull(),
|
||||
status: integer().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 }),
|
||||
@@ -189,7 +219,7 @@ export const judgeServer = pgTable("judge_server", {
|
||||
|
||||
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: varchar({ length: 16 }).notNull().$type<ExerciseType>(),
|
||||
data: jsonb().notNull(),
|
||||
order: integer().notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
@@ -219,8 +249,8 @@ export const problemset = pgTable("problemset", {
|
||||
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(),
|
||||
difficulty: text().notNull().$type<ProblemSetDifficulty>(),
|
||||
status: text().notNull().$type<ProblemSetStatus>(),
|
||||
createdById: integer("created_by_id").notNull(),
|
||||
endTime: timestamp("end_time", { withTimezone: true, mode: 'string' }),
|
||||
}, (table) => [
|
||||
@@ -324,7 +354,7 @@ export const problemsetSubmission = pgTable("problemset_submission", {
|
||||
|
||||
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: 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(),
|
||||
@@ -354,14 +384,14 @@ export const problem = pgTable("problem", {
|
||||
testCaseId: text("test_case_id").notNull(),
|
||||
testCaseScore: jsonb("test_case_score").notNull(),
|
||||
hint: text(),
|
||||
languages: jsonb().notNull(),
|
||||
template: jsonb().notNull(),
|
||||
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(),
|
||||
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(),
|
||||
@@ -386,9 +416,9 @@ export const problem = pgTable("problem", {
|
||||
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"),
|
||||
astRules: jsonb("ast_rules").$type<AstRules>(),
|
||||
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")),
|
||||
@@ -439,9 +469,9 @@ export const submission = pgTable("submission", {
|
||||
createTime: timestamp("create_time", { withTimezone: true, mode: 'string' }).notNull(),
|
||||
userId: integer("user_id").notNull(),
|
||||
code: text().notNull(),
|
||||
result: integer().default(6).notNull(),
|
||||
result: integer().default(6).notNull().$type<JudgeStatus>(),
|
||||
info: jsonb().default({}).notNull(),
|
||||
language: text().notNull(),
|
||||
language: text().notNull().$type<ProblemLanguage>(),
|
||||
/**
|
||||
* 已停用,见 problem.share_submission 的说明。历史上 12.3 万条提交里有 40 条
|
||||
* 为真(2022 年 39 条、2023 年 1 条),入口在更早的那版前端上,ojnext 和 OJ2
|
||||
@@ -560,7 +590,7 @@ export const tutorial = pgTable("tutorial", {
|
||||
order: integer().notNull(),
|
||||
createdById: integer("created_by_id").notNull(),
|
||||
code: text(),
|
||||
type: varchar({ length: 10 }).notNull(),
|
||||
type: varchar({ length: 10 }).notNull().$type<TutorialType>(),
|
||||
}, (table) => [
|
||||
index("tutorial_created_by_id_07973cab").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
|
||||
foreignKey({
|
||||
@@ -635,7 +665,7 @@ export const userBadge = pgTable("user_badge", {
|
||||
|
||||
export const userProfile = pgTable("user_profile", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
acmProblemsStatus: jsonb("acm_problems_status").default({}).notNull(),
|
||||
acmProblemsStatus: jsonb("acm_problems_status").default({}).notNull().$type<Record<string, unknown>>(),
|
||||
avatar: text().notNull(),
|
||||
mood: text(),
|
||||
acceptedNumber: integer("accepted_number").default(0).notNull(),
|
||||
@@ -656,7 +686,7 @@ export const acmContestRank = pgTable("acm_contest_rank", {
|
||||
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(),
|
||||
submissionInfo: jsonb("submission_info").default({}).notNull().$type<Record<string, ContestSubmissionInfo>>(),
|
||||
contestId: integer("contest_id").notNull(),
|
||||
userId: integer("user_id").notNull(),
|
||||
}, (table) => [
|
||||
@@ -705,7 +735,7 @@ export const problemsetBadge = pgTable("problemset_badge", {
|
||||
name: text().notNull(),
|
||||
description: text().notNull(),
|
||||
icon: text().notNull(),
|
||||
conditionType: text("condition_type").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(),
|
||||
|
||||
Reference in New Issue
Block a user