feat(契约): 练一练的内容按题型收进契约,7 个题型组件不再裸读 data
Some checks failed
Deploy / deploy (push) Has been cancelled
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user