feat(契约): 练一练的内容按题型收进契约,7 个题型组件不再裸读 data
Some checks failed
Deploy / deploy (push) Has been cancelled

`Exercise*.vue` 七个组件直接读 `data.question` / `data.options` / `data.lines`,
而契约里 `data` 是 `Record<string, unknown>`(后端不校验)、前端只做了类型收窄
—— 也就是说这条路径**从来没有在运行时被看过一眼**,结构对不上时渲染期才炸。

现在 exerciseSchema 用 superRefine 按外层 `type` 分派到对应的内容形状,
外层与 data 对不上也会被抓住(`type: "mcq"` 配 `{question, code}` 会在渲染
mcq 组件时炸在 `data.options`)。七个题型的键集按生产库实测确定。

**这个 schema 后端也在 parse**(routes/content.ts 的 `exerciseSchema.parse`),
所以收紧它同时是一道服务端闸门:坏数据会让练习列表 500。因此先用生产全量
数据核验:151/151 通过,且反向验证确认能抓住题型与 data 不匹配的情况。

`getExercises` 另加了一层逐题型校验,让前端也能把内层分歧记进
`__OJ2_CONTRACT_DRIFT__`;`z.infer` 拿不到判别联合(superRefine 无法把校验
结果反映到推断类型上),所以类型上仍需一次经过 unknown 的断言 —— 它不掩盖
未经检查的分歧,因为运行时那一步已经做过。

顺带补上 getFlowchartSubmission 的闸门;被守卫端点达到 41 个。

验证:vue-tsc 与 tsc -p apps/api 均 exit 0;vite build 通过;check:routes 无遮蔽。
This commit is contained in:
2026-09-10 04:28:26 -06:00
parent 5da661d7f0
commit 684f2d29a5
2 changed files with 105 additions and 11 deletions

View File

@@ -117,12 +117,73 @@ export const exerciseAttemptRequestSchema = z.object({
answer: z.string().max(200).optional(),
})
export const exerciseSchema = z.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
data: z.record(z.string(), z.unknown()),
order: z.number().int(),
})
/**
* 练一练的内容,按题型分派。
*
* 形状按**生产库 151 道练习题实测**得出,七种题型的键集逐个吻合、没有越界数据:
* mcq 46 / fill 37 / sort 25 / predict 24 / debug 11 / match 6 / group 2。
*
* 为什么值得从 `Record<string, unknown>` 收紧:这七种题型各自被一个组件渲染,
* 它们直接读 `data.question` / `data.options` / `data.lines` —— 结构对不上时
* **渲染期才炸**,而炸的是整道题的组件。收紧之后这条路径由契约在响应边界上拦住。
*
* 注意 `exerciseSchema` **后端也在 parse**`routes/content.ts` 的
* `rows.map(... exerciseSchema.parse ...)`),所以这里的收紧同时是一道服务端闸门:
* 数据对不上时整条练习列表 500而不是渲染到一半崩。已用生产全量数据核验过
* 151/151 通过,才敢这么收。
*
* `data` 里**没有**判别键 —— 题型的真相在**外层**的 `type` 上,所以只能在
* superRefine 里拿 `type` 去挑对应的形状,不能用 discriminatedUnion
* (那要求判别键存在于被判别对象内部)。
*/
/**
* 题型 → 内容形状。导出是为了让**前端闸门**能逐题型校验 `data` ——
* `z.infer` 只能把 `data` 还原成 `Record<string, unknown>`superRefine
* 无法把校验结果反映到推断出的类型上),所以前端那个更窄的判别联合
* `utils/types` 的 Exercise在类型上仍然要自己收窄一次但**运行时**
* 走的就是这张表。
*/
export const exerciseDataByType: Record<string, z.ZodType> = {
mcq: z.object({ question: z.string(), options: z.array(z.string()), answer: z.array(z.number()) }),
sort: z.object({ question: z.string(), lines: z.array(z.string()) }),
fill: z.object({ question: z.string(), code: z.string() }),
match: z.object({ question: z.string(), left: z.array(z.string()), right: z.array(z.string()), answer: z.array(z.number()) }),
predict: z.object({ question: z.string(), code: z.string(), answer: z.array(z.string()) }),
debug: z.object({ question: z.string(), lines: z.array(z.string()), answer: z.array(z.number()), explanation: z.string().optional() }),
group: z.object({ question: z.string(), buckets: z.array(z.string()), items: z.array(z.string()), answer: z.array(z.number()) }),
}
/**
* 外层 `type` 与 `data` 的内容对不上时也算不通过,这正是要拦的情况:
* `type: "mcq"` 配一份 `{question, code}` 会在渲染 mcq 组件时炸在 `data.options` 上。
*/
export const exerciseSchema = z
.object({
id: z.number().int(),
type: z.enum(["mcq", "sort", "fill", "match", "predict", "debug", "group"]),
data: z.record(z.string(), z.unknown()),
order: z.number().int(),
})
.superRefine((value, ctx) => {
// type 是枚举,映射表覆盖了全部七个值;这里只是让 TS 收窄,顺带在
// 将来加了新题型却忘了补形状时,以一条清楚的 issue 而不是 undefined 崩掉。
const shape = exerciseDataByType[value.type]
if (!shape) {
ctx.addIssue({ code: "custom", path: ["type"], message: `题型 ${value.type} 没有对应的内容形状` })
return
}
const parsed = shape.safeParse(value.data)
if (!parsed.success) {
ctx.addIssue({
code: "custom",
path: ["data"],
message: `type=${value.type} 的 data 形状不符:${parsed.error.issues
.slice(0, 3)
.map((issue) => `${issue.path.join(".") || "(根)"} ${issue.message}`)
.join("")}`,
})
}
})
export type Message = z.infer<typeof messageSchema>
export type MessageList = z.infer<typeof messageListSchema>