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:
@@ -6,7 +6,6 @@ import {
|
||||
type CreateSubmissionResponse,
|
||||
type ProblemAuthor,
|
||||
type CreateFlowchartResponse,
|
||||
type FlowchartSubmission,
|
||||
problemDetailSchema,
|
||||
problemListSchema,
|
||||
problemListItemSchema,
|
||||
@@ -35,6 +34,9 @@ import {
|
||||
flowchartDetailSchema,
|
||||
flowchartCurrentSchema,
|
||||
flowchartStatisticsSchema,
|
||||
flowchartSubmissionSchema,
|
||||
exerciseSchema,
|
||||
exerciseDataByType,
|
||||
problemSetProgressListSchema,
|
||||
problemSetProblemSchema,
|
||||
tutorialSummarySchema,
|
||||
@@ -533,8 +535,13 @@ export function submitFlowchart(data: {
|
||||
return api.post<CreateFlowchartResponse>("flowcharts", data)
|
||||
}
|
||||
|
||||
export function getFlowchartSubmission(id: string) {
|
||||
return api.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
|
||||
export async function getFlowchartSubmission(id: string) {
|
||||
const endpoint = `flowcharts/${encodeURIComponent(id)}`
|
||||
return contract(
|
||||
"GET /flowcharts/:id",
|
||||
flowchartSubmissionSchema,
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
}
|
||||
|
||||
export async function getFlowchartSubmissions(params: {
|
||||
@@ -681,8 +688,34 @@ export async function getProblemSetUserProgress(
|
||||
)
|
||||
}
|
||||
|
||||
export function getExercises(tutorialId: number): Promise<Exercise[]> {
|
||||
return api.get<Exercise[]>(`tutorials/${tutorialId}/exercises`)
|
||||
export async function getExercises(
|
||||
tutorialId: number,
|
||||
): Promise<Exercise[]> {
|
||||
const endpoint = `tutorials/${tutorialId}/exercises`
|
||||
// 外层走 exerciseSchema,内层 data 在这里按题型逐支校验:
|
||||
// `z.infer` 只能把 data 还原成 Record<string, unknown>(superRefine 无法把
|
||||
// 校验结果反映到推断类型上),所以那 7 个 Exercise*.vue 直接读
|
||||
// data.question / data.options 时本没有任何运行时保护。
|
||||
// 生产库 151 道练习题已确认七种题型的键集全部吻合。
|
||||
const rows = contract(
|
||||
"GET /tutorials/:id/exercises",
|
||||
exerciseSchema.array(),
|
||||
await api.get<unknown>(endpoint),
|
||||
)
|
||||
for (const row of rows) {
|
||||
const shape = exerciseDataByType[row.type]
|
||||
if (!shape) continue
|
||||
// 故意用同一个 contract():形状不符时它负责记日志并放行,不抛错
|
||||
contract(
|
||||
`GET /tutorials/:id/exercises(type=${row.type} 的 data)`,
|
||||
shape,
|
||||
row.data,
|
||||
)
|
||||
}
|
||||
// 类型上仍要收窄一次:契约推断出的 data 是宽松 record,组件要的是判别联合,
|
||||
// 两者不重叠,所以只能经过 unknown。**这个断言是有意的,不是假校验** ——
|
||||
// 上面那个循环已经在运行时按题型逐支验过;它只是把「运行时已确认」告诉 TS。
|
||||
return rows as unknown as Exercise[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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