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:
@@ -1,20 +1,22 @@
|
||||
import {
|
||||
announcementListItemSchema,
|
||||
announcementListSchema,
|
||||
announcementSchema,
|
||||
createMessageRequestSchema,
|
||||
exerciseSchema,
|
||||
messageListSchema,
|
||||
messageSchema,
|
||||
reactionKeySchema,
|
||||
reactionStateSchema,
|
||||
setReactionRequestSchema,
|
||||
embeddedSubmissionSchema,
|
||||
exerciseAttemptRequestSchema,
|
||||
reactionKeySchema,
|
||||
setReactionRequestSchema,
|
||||
tutorialProgressPingSchema,
|
||||
tutorialProgressSchema,
|
||||
tutorialSchema,
|
||||
tutorialSummarySchema,
|
||||
type Announcement,
|
||||
type AnnouncementList,
|
||||
type AnnouncementListItem,
|
||||
type EmbeddedSubmission,
|
||||
type Exercise,
|
||||
type Message,
|
||||
type MessageList,
|
||||
type ReactionCounts,
|
||||
type ReactionState,
|
||||
type Tutorial,
|
||||
type TutorialProgress,
|
||||
type TutorialSummary,
|
||||
} from "@oj2/contract"
|
||||
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
@@ -38,8 +40,8 @@ contentRoutes.get("/announcements", async (c) => {
|
||||
.where(eq(schema.announcement.visible, true))
|
||||
.orderBy(desc(schema.announcement.top), desc(schema.announcement.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
return success(c, announcementListSchema.parse({
|
||||
results: rows.map(({ announcement, user, realName }) => announcementListItemSchema.parse({
|
||||
return success(c, {
|
||||
results: rows.map(({ announcement, user, realName }) => ({
|
||||
id: announcement.id,
|
||||
title: announcement.title,
|
||||
tag: announcement.tag,
|
||||
@@ -47,9 +49,9 @@ contentRoutes.get("/announcements", async (c) => {
|
||||
createdBy: sampleUser(user, realName),
|
||||
createTime: announcement.createTime,
|
||||
lastUpdateTime: announcement.lastUpdateTime,
|
||||
})),
|
||||
} satisfies AnnouncementListItem)),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
} satisfies AnnouncementList)
|
||||
})
|
||||
|
||||
contentRoutes.get("/announcements/:id", async (c) => {
|
||||
@@ -59,7 +61,7 @@ contentRoutes.get("/announcements/:id", async (c) => {
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(eq(schema.announcement.id, id), eq(schema.announcement.visible, true))).limit(1)
|
||||
if (!row) return failure(c, 404, "announcement-not-found", "Announcement does not exist")
|
||||
return success(c, announcementSchema.parse({
|
||||
return success(c, {
|
||||
id: row.announcement.id,
|
||||
title: row.announcement.title,
|
||||
tag: row.announcement.tag,
|
||||
@@ -68,7 +70,7 @@ contentRoutes.get("/announcements/:id", async (c) => {
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
createTime: row.announcement.createTime,
|
||||
lastUpdateTime: row.announcement.lastUpdateTime,
|
||||
}))
|
||||
} satisfies Announcement)
|
||||
})
|
||||
|
||||
contentRoutes.get("/messages", requireAuth, async (c) => {
|
||||
@@ -84,13 +86,13 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(eq(schema.message.recipientId, user.id)).orderBy(desc(schema.message.createTime)).limit(limit).offset(offset),
|
||||
])
|
||||
return success(c, messageListSchema.parse({
|
||||
results: rows.map(({ message, sender, realName, submission, displayId }) => messageSchema.parse({
|
||||
return success(c, {
|
||||
results: rows.map(({ message, sender, realName, submission, displayId }) => ({
|
||||
id: message.id,
|
||||
sender: sampleUser(sender, realName),
|
||||
createTime: message.createTime,
|
||||
message: message.message,
|
||||
submission: embeddedSubmissionSchema.parse({
|
||||
submission: {
|
||||
id: submission.id,
|
||||
createTime: submission.createTime,
|
||||
userId: submission.userId,
|
||||
@@ -104,10 +106,10 @@ contentRoutes.get("/messages", requireAuth, async (c) => {
|
||||
// 展示用题号而非数字主键,站内信页面拿它拼 /problem/<题号>
|
||||
problem: displayId,
|
||||
showLink: true,
|
||||
}),
|
||||
})),
|
||||
} satisfies EmbeddedSubmission,
|
||||
} satisfies Message)),
|
||||
total: totalRows[0]?.value ?? 0,
|
||||
}))
|
||||
} satisfies MessageList)
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -140,15 +142,16 @@ contentRoutes.post("/messages", requireSuperAdmin, async (c) => {
|
||||
async function reactionState(problemId: number, userId: number) {
|
||||
const [mine] = await db.select({ type: schema.reaction.type }).from(schema.reaction)
|
||||
.where(and(eq(schema.reaction.problemId, problemId), eq(schema.reaction.userId, userId))).limit(1)
|
||||
if (!mine) return reactionStateSchema.parse({ mine: null, counts: null })
|
||||
if (!mine) return { mine: null, counts: null } satisfies ReactionState
|
||||
const rows = await db.select({ type: schema.reaction.type, value: count() }).from(schema.reaction)
|
||||
.where(eq(schema.reaction.problemId, problemId)).groupBy(schema.reaction.type)
|
||||
const counts = Object.fromEntries(reactionKeySchema.options.map((key) => [key, 0]))
|
||||
for (const row of rows) {
|
||||
const key = reactionKeySchema.safeParse(row.type)
|
||||
if (key.success) counts[key.data] = row.value
|
||||
}
|
||||
return reactionStateSchema.parse({ mine: mine.type, counts })
|
||||
// fromEntries 推不出这个键集,但 options 就是 ReactionKey 的全集,断言是成立的。
|
||||
// row.type 不必再 safeParse:reaction.type 列上挂着 $type<ReactionKey>()
|
||||
const counts = Object.fromEntries(
|
||||
reactionKeySchema.options.map((key) => [key, 0]),
|
||||
) as ReactionCounts
|
||||
for (const row of rows) counts[row.type] = row.value
|
||||
return { mine: mine.type, counts } satisfies ReactionState
|
||||
}
|
||||
|
||||
contentRoutes.get("/problems/:id/reaction", requireAuth, async (c) => {
|
||||
@@ -183,7 +186,7 @@ contentRoutes.get("/tutorials", async (c) => {
|
||||
const type = c.req.query("type") === "c" ? "c" : "python"
|
||||
const rows = await db.select({ id: schema.tutorial.id, title: schema.tutorial.title }).from(schema.tutorial)
|
||||
.where(and(eq(schema.tutorial.isPublic, true), eq(schema.tutorial.type, type))).orderBy(asc(schema.tutorial.order))
|
||||
return success(c, rows.map((row) => tutorialSummarySchema.parse(row)))
|
||||
return success(c, rows satisfies TutorialSummary[])
|
||||
})
|
||||
|
||||
contentRoutes.get("/tutorials/:id", async (c) => {
|
||||
@@ -193,7 +196,7 @@ contentRoutes.get("/tutorials/:id", async (c) => {
|
||||
.leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!row) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
return success(c, tutorialSchema.parse({
|
||||
return success(c, {
|
||||
id: row.tutorial.id,
|
||||
title: row.tutorial.title,
|
||||
content: row.tutorial.content,
|
||||
@@ -204,7 +207,7 @@ contentRoutes.get("/tutorials/:id", async (c) => {
|
||||
createdBy: sampleUser(row.user, row.realName),
|
||||
createdAt: row.tutorial.createdAt,
|
||||
updatedAt: row.tutorial.updatedAt,
|
||||
}))
|
||||
} satisfies Tutorial)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- 自学留痕
|
||||
@@ -252,7 +255,7 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
|
||||
])
|
||||
const exercises = new Map(exerciseRows.map((row) => [row.tutorialId, row]))
|
||||
|
||||
return success(c, rows.map((row) => tutorialProgressSchema.parse({
|
||||
return success(c, rows.map((row) => ({
|
||||
tutorialId: row.tutorialId,
|
||||
viewCount: row.viewCount ?? 0,
|
||||
totalSeconds: row.totalSeconds ?? 0,
|
||||
@@ -260,7 +263,7 @@ contentRoutes.get("/learn/progress", requireAuth, async (c) => {
|
||||
lastViewedAt: row.lastViewedAt,
|
||||
exerciseTotal: exercises.get(row.tutorialId)?.total ?? 0,
|
||||
exerciseSolved: exercises.get(row.tutorialId)?.solved ?? 0,
|
||||
})))
|
||||
} satisfies TutorialProgress)))
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -369,5 +372,5 @@ contentRoutes.get("/tutorials/:id/exercises", async (c) => {
|
||||
.where(and(eq(schema.tutorial.id, id), eq(schema.tutorial.isPublic, true))).limit(1)
|
||||
if (!tutorial) return failure(c, 404, "tutorial-not-found", "Tutorial does not exist")
|
||||
const rows = await db.select().from(schema.exercise).where(eq(schema.exercise.tutorialId, id)).orderBy(asc(schema.exercise.order))
|
||||
return success(c, rows.map((row) => exerciseSchema.parse({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order })))
|
||||
return success(c, rows.map((row) => ({ id: row.id, type: row.type, data: objectValue(row.data), order: row.order } satisfies Exercise)))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user