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/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:
@@ -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 problem:problem 只有按题号筛选时才出现在 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 problem:problem 只有按题号筛选时才出现在 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) => {
|
||||
|
||||
Reference in New Issue
Block a user