refactor(契约): 出参不再 parse,后台老题详情和站内信页不再 500
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/submission.ts)。

## 文档

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 条路由,无遮蔽。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 05:45:15 -06:00
parent b9a80d62bc
commit 127718dc06
36 changed files with 562 additions and 524 deletions

View File

@@ -2,14 +2,14 @@ import { randomBytes } from "node:crypto"
import {
createSubmissionRequestSchema,
createSubmissionResponseSchema,
formatCodeRequestSchema,
formatCodeResponseSchema,
submissionDetailSchema,
submissionListItemSchema,
submissionListSchema,
submissionStatisticsItemsSchema,
submissionStatisticsSchema,
type CreateSubmissionResponse,
type FormatCodeResponse,
type SubmissionDetail,
type SubmissionList,
type SubmissionListItem,
type SubmissionStatistics,
type SubmissionStatisticsItems,
} from "@oj2/contract"
import { and, count, desc, eq, gt, ilike, inArray, isNull, or, sql, type SQL } from "drizzle-orm"
import { Hono } from "hono"
@@ -23,7 +23,7 @@ import {
import type { AuthUser } from "../auth/session"
import { db, schema } from "../db"
import { failure, success } from "../http"
import { JudgeStatus, UNJUDGED_RESULTS } from "../judge/status"
import { JudgeStatus, UNJUDGED_RESULTS, type JudgeStatusValue } from "../judge/status"
import { judgeQueue } from "../queue"
import {
canAccessContest,
@@ -36,22 +36,10 @@ import {
import { CodeFormatError, formatCode } from "../services/format-code"
import { getBooleanOption } from "../services/options"
import { consumeToken } from "../services/throttling"
import {
isAdminRole,
queryInteger,
rounded,
stripClassPrefix,
todayStart,
} from "./helpers"
import { asFilterValue, isAdminRole, queryInteger, rounded, stripClassPrefix, todayStart } from "./helpers"
export const submissionRoutes = new Hono<ContestEnv>()
function stringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: []
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -100,7 +88,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
.limit(1)
if (!problem) return failure(c, 404, "problem-not-found", "Problem does not exist")
if (!stringArray(problem.languages).includes(parsed.data.language)) {
if (!problem.languages.includes(parsed.data.language)) {
return failure(
c,
400,
@@ -162,7 +150,7 @@ submissionRoutes.post("/submissions", requireAuth, async (c) => {
return success(
c,
createSubmissionResponseSchema.parse({ submissionId }),
{ submissionId } satisfies CreateSubmissionResponse,
201,
)
})
@@ -258,9 +246,11 @@ const FAILURE_MESSAGE_LIMIT = 400
* statistic_info 的那一段,提交详情页读的也是它。
*/
async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
// result 手写成 JudgeStatusValue这条裸 SQL 读的就是 submission.result 那一列,
// 口径要和列上的 $type 一致
const byUser = new Map<
number,
{ id: string; problem: string; result: number; error: string | null }
{ id: string; problem: string; result: JudgeStatusValue; error: string | null }
>()
if (!userIds.length) return byUser
@@ -269,7 +259,7 @@ async function lastFailureByUser(where: SQL | undefined, userIds: number[]) {
user_id: number
id: string
problem: string
result: number
result: JudgeStatusValue
error: string | null
}>(sql`
select user_id, id, problem, result, error from (
@@ -606,7 +596,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
return success(
c,
submissionStatisticsSchema.parse({
{
submissionCount,
acceptedCount,
judgingCount,
@@ -615,7 +605,7 @@ submissionRoutes.get("/submissions/statistics", requireTeacher, async (c) => {
data,
dataUnaccepted,
dataAttempted,
}),
} satisfies SubmissionStatistics,
)
})
@@ -661,10 +651,10 @@ submissionRoutes.get("/submissions/statistics/items", requireTeacher, async (c)
const truncated = rows.length > STATISTICS_ITEMS_LIMIT
return success(
c,
submissionStatisticsItemsSchema.parse({
{
items: rows.slice(0, STATISTICS_ITEMS_LIMIT),
truncated,
}),
} satisfies SubmissionStatisticsItems,
)
})
@@ -696,7 +686,7 @@ submissionRoutes.post("/code/format", requireAuth, async (c) => {
if (!parsed.success) return failure(c, 400, "invalid-request", "Invalid format payload")
try {
const code = await formatCode(parsed.data.code, parsed.data.language)
return success(c, formatCodeResponseSchema.parse({ code }))
return success(c, { code } satisfies FormatCodeResponse)
} catch (error) {
if (error instanceof CodeFormatError) {
return failure(c, error.kind === "syntax" ? 400 : 500, error.kind === "syntax" ? "format-error" : "format-tool-error", error.message)
@@ -831,7 +821,7 @@ async function submissionDetail(id: string, user: AuthUser) {
// submission/views/oj.py 用 is_admin_role() 在 SubmissionModelSerializer 与
// SubmissionSafeModelSerializer 之间二选一,把关的是角色,不是「是不是自己的提交」。
const full = isAdminRole(user)
return submissionDetailSchema.parse({
return {
id: row.submission.id,
createTime: row.submission.createTime,
userId: row.submission.userId,
@@ -847,7 +837,7 @@ async function submissionDetail(id: string, user: AuthUser) {
// problem 表本来就 join 了,不额外查库
problemDisplayId: row.problem.displayId,
showLink: true,
})
} satisfies SubmissionDetail
}
/**
@@ -926,7 +916,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
// 「非管理员即受限」,不能写成「是普通用户才受限」——
// 后者对匿名用户user 为 null会短路匿名反而能看到全部提交权限大于登录学生。
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
return success(c, submissionListSchema.parse({ results: [], total: 0 }))
return success(c, { results: [], total: 0 } satisfies SubmissionList)
}
const filters = [isNull(schema.submission.contestId)]
const displayId = c.req.query("problemId")?.trim()
@@ -936,8 +926,8 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
else if (username) filters.push(usernameFilter(username))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, Number(result)))
if (language) filters.push(eq(schema.submission.language, language))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, asFilterValue(Number(result))))
if (language) filters.push(eq(schema.submission.language, asFilterValue(language)))
if (c.req.query("today") === "1") filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`)
const where = and(...filters)
// count 不 join problemproblem 只有按题号筛选时才出现在 where 里,无条件 join 会让
@@ -960,8 +950,8 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
// 来源题单的标题。一页里不同题单最多几个,按主键查一次就够
problemsetTitleMap(rows.map((row) => row.submission.problemsetId)),
])
return success(c, submissionListSchema.parse({
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({
return success(c, {
results: rows.map(({ submission, problem }) => ({
id: submission.id,
problem: problem.displayId,
problemTitle: problem.title,
@@ -976,9 +966,9 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
problemSet: submission.problemsetId !== null && problemsetTitles.has(submission.problemsetId)
? { id: submission.problemsetId, title: problemsetTitles.get(submission.problemsetId)! }
: null,
})),
} satisfies SubmissionListItem)),
total: totalRows[0]?.value ?? 0,
}))
} satisfies SubmissionList)
})
submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireContestAccess("submissions", "contestId"), async (c) => {
@@ -993,7 +983,7 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
else if (username) filters.push(usernameFilter(username))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, Number(result)))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, asFilterValue(Number(result))))
if (contestStatus(contest) !== "1") filters.push(sql`${schema.submission.createTime} >= ${contest.startTime}`)
const where = and(...filters)
// count 不 join problemproblem 只有按题号筛选时才出现在 where 里,无条件 join 会让
@@ -1013,8 +1003,8 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
// `isNull(problem.contestId)`admin/problemset.ts:232——而这条列表只出比赛提交
// 两边交集恒空,挂上去就是每页白跑一次查询,而比赛进行中这条列表是被刷得最狠的。
// 旧后端 ContestSubmissionListAPI 照抄了 bulk_fetch那边同样是死代码。
return success(c, submissionListSchema.parse({
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({
return success(c, {
results: rows.map(({ submission, problem }) => ({
id: submission.id,
problem: problem.displayId,
problemTitle: problem.title,
@@ -1028,9 +1018,9 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
// 比赛提交没有来源题单题单只收非比赛题admin/problemset.ts 加题时卡了
// isNull(problem.contestId)),提交接口那边也只在 contestId 为空时才认这个字段
problemSet: null,
})),
} satisfies SubmissionListItem)),
total: totalRows[0]?.value ?? 0,
}))
} satisfies SubmissionList)
})
submissionRoutes.get("/submissions/:id", requireAuth, async (c) => {