diff --git a/apps/web/src/oj/api.ts b/apps/web/src/oj/api.ts index b9a5217..6e8c526 100644 --- a/apps/web/src/oj/api.ts +++ b/apps/web/src/oj/api.ts @@ -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("flowcharts", data) } -export function getFlowchartSubmission(id: string) { - return api.get(`flowcharts/${encodeURIComponent(id)}`) +export async function getFlowchartSubmission(id: string) { + const endpoint = `flowcharts/${encodeURIComponent(id)}` + return contract( + "GET /flowcharts/:id", + flowchartSubmissionSchema, + await api.get(endpoint), + ) } export async function getFlowchartSubmissions(params: { @@ -681,8 +688,34 @@ export async function getProblemSetUserProgress( ) } -export function getExercises(tutorialId: number): Promise { - return api.get(`tutorials/${tutorialId}/exercises`) +export async function getExercises( + tutorialId: number, +): Promise { + const endpoint = `tutorials/${tutorialId}/exercises` + // 外层走 exerciseSchema,内层 data 在这里按题型逐支校验: + // `z.infer` 只能把 data 还原成 Record(superRefine 无法把 + // 校验结果反映到推断类型上),所以那 7 个 Exercise*.vue 直接读 + // data.question / data.options 时本没有任何运行时保护。 + // 生产库 151 道练习题已确认七种题型的键集全部吻合。 + const rows = contract( + "GET /tutorials/:id/exercises", + exerciseSchema.array(), + await api.get(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[] } /** diff --git a/packages/contract/src/content.ts b/packages/contract/src/content.ts index 492473a..5b0ae80 100644 --- a/packages/contract/src/content.ts +++ b/packages/contract/src/content.ts @@ -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` 收紧:这七种题型各自被一个组件渲染, + * 它们直接读 `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`(superRefine + * 无法把校验结果反映到推断出的类型上),所以前端那个更窄的判别联合 + * (`utils/types` 的 Exercise)在类型上仍然要自己收窄一次,但**运行时** + * 走的就是这张表。 + */ +export const exerciseDataByType: Record = { + 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 export type MessageList = z.infer