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 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/exercisestype=${row.type} 的 data`,
shape,
row.data,
)
}
// 类型上仍要收窄一次:契约推断出的 data 是宽松 record组件要的是判别联合
// 两者不重叠,所以只能经过 unknown。**这个断言是有意的,不是假校验** ——
// 上面那个循环已经在运行时按题型逐支验过;它只是把「运行时已确认」告诉 TS。
return rows as unknown as Exercise[]
}
/**