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

@@ -6,7 +6,6 @@ import {
type CreateSubmissionResponse, type CreateSubmissionResponse,
type ProblemAuthor, type ProblemAuthor,
type CreateFlowchartResponse, type CreateFlowchartResponse,
type FlowchartSubmission,
problemDetailSchema, problemDetailSchema,
problemListSchema, problemListSchema,
problemListItemSchema, problemListItemSchema,
@@ -35,6 +34,9 @@ import {
flowchartDetailSchema, flowchartDetailSchema,
flowchartCurrentSchema, flowchartCurrentSchema,
flowchartStatisticsSchema, flowchartStatisticsSchema,
flowchartSubmissionSchema,
exerciseSchema,
exerciseDataByType,
problemSetProgressListSchema, problemSetProgressListSchema,
problemSetProblemSchema, problemSetProblemSchema,
tutorialSummarySchema, tutorialSummarySchema,
@@ -533,8 +535,13 @@ export function submitFlowchart(data: {
return api.post<CreateFlowchartResponse>("flowcharts", data) return api.post<CreateFlowchartResponse>("flowcharts", data)
} }
export function getFlowchartSubmission(id: string) { export async function getFlowchartSubmission(id: string) {
return api.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`) const endpoint = `flowcharts/${encodeURIComponent(id)}`
return contract(
"GET /flowcharts/:id",
flowchartSubmissionSchema,
await api.get<unknown>(endpoint),
)
} }
export async function getFlowchartSubmissions(params: { export async function getFlowchartSubmissions(params: {
@@ -681,8 +688,34 @@ export async function getProblemSetUserProgress(
) )
} }
export function getExercises(tutorialId: number): Promise<Exercise[]> { export async function getExercises(
return api.get<Exercise[]>(`tutorials/${tutorialId}/exercises`) 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/exercisestype=${row.type} 的 data`,
shape,
row.data,
)
}
// 类型上仍要收窄一次:契约推断出的 data 是宽松 record组件要的是判别联合
// 两者不重叠,所以只能经过 unknown。**这个断言是有意的,不是假校验** ——
// 上面那个循环已经在运行时按题型逐支验过;它只是把「运行时已确认」告诉 TS。
return rows as unknown as Exercise[]
} }
/** /**

View File

@@ -117,12 +117,73 @@ export const exerciseAttemptRequestSchema = z.object({
answer: z.string().max(200).optional(), 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()), * 形状按**生产库 151 道练习题实测**得出,七种题型的键集逐个吻合、没有越界数据:
order: z.number().int(), * 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 Message = z.infer<typeof messageSchema>
export type MessageList = z.infer<typeof messageListSchema> export type MessageList = z.infer<typeof messageListSchema>