Files
OJ2/packages/contract/src/content.ts
T
xuyueandClaude Opus 5.5 11194543a3 feat(自学): 教程有了「所属项目」和课末的相关题,正文里的代码能直接运行
- 0031 迁移:tutorial 加 project 列(从正文「所属项目:」回填),新表 tutorial_problem
  挂每课的相关题(两头 CASCADE)
- 学完一课 = 练一练全对(契约 isLessonDone),不再按停留满 3 分钟算
- /tutorials/:id 带相关题和做对没有,/tutorials/:id/exercises 带每道做对没有
- /trial-runs 收 tutorialId:教程里的代码块走同一台判题机跑一组
- 后台:教程列表带练一练 / 相关题数 / 打开过的人,拖动排序、项目改名、挑题候选(必会题在前)
- 自学情况换成学生 × 课的方块格(/learn-analytics/board),默认班和排名页同一套猜法

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:40:34 +08:00

233 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod"
import { paginatedSchema, sampleUserSchema } from "./common"
import { embeddedSubmissionSchema } from "./submission"
export const announcementSchema = z.object({
id: z.number().int(),
title: z.string(),
tag: z.string(),
content: z.string(),
top: z.boolean(),
createdBy: sampleUserSchema,
createTime: z.string(),
lastUpdateTime: z.string(),
})
/**
* 列表不下发正文:公告是 8MB 上限的富文本,列表页只显示标题。
* 原来 content 写成 `.optional()` 让一个 schema 兼两种形态,结果详情页
* 拿到的 content 类型上也是 `string | undefined`,组件只能 ?? 兜底。
* 与后台侧 adminAnnouncementListItemSchema 同一个套路。
*/
export const announcementListItemSchema = announcementSchema.omit({
content: true,
})
export const announcementListSchema = paginatedSchema(announcementListItemSchema)
export const messageSchema = z.object({
id: z.number().int(),
sender: sampleUserSchema,
createTime: z.string(),
message: z.string(),
submission: embeddedSubmissionSchema,
})
export const messageListSchema = paginatedSchema(messageSchema)
export const createMessageRequestSchema = z.object({
recipientId: z.number().int().positive(),
submissionId: z.string().min(1),
message: z
.string()
.min(1)
.max(1024 * 1024),
})
export const reactionKeySchema = z.enum([
"too_easy",
"too_hard",
"confusing",
"buggy",
"learned",
"interesting",
"want_explain",
])
export const reactionCountsSchema = z.record(reactionKeySchema, z.number().int())
export const reactionStateSchema = z.object({
mine: reactionKeySchema.nullable(),
counts: reactionCountsSchema.nullable(),
})
export const setReactionRequestSchema = z.object({ type: reactionKeySchema })
/**
* 目录里的一课。`title` 是「情境——知识点」(「蜜橘园环境预警——条件语句基础」),
* 前端拆成知识点在前、情境在后显示;`project` 是所属项目,目录按它分组,可能为空。
*/
export const tutorialSummarySchema = z.object({
id: z.number().int(),
title: z.string(),
project: z.string(),
})
/**
* 一课结尾「去做几道题」里的一道。`solved` 是当前学生做对过没有(未登录恒为 false),
* `mustDo` 是带不带「必会题」标签。
*/
export const tutorialProblemSchema = z.object({
id: z.number().int(),
displayId: z.string(),
title: z.string(),
mustDo: z.boolean(),
solved: z.boolean(),
})
export const tutorialSchema = tutorialSummarySchema.extend({
content: z.string(),
problems: z.array(tutorialProblemSchema),
isPublic: z.boolean(),
order: z.number().int(),
type: z.enum(["python", "c"]),
createdBy: sampleUserSchema,
createdAt: z.string(),
updatedAt: z.string(),
})
/**
* 自学留痕的上报。前端每进一课发一次 `opened: true`(`seconds` 为 0),
* 之后按心跳补时长发 `opened: false`。
*
* `seconds` 卡在 1 小时以内:一次上报最多也就攒几分钟,超出这个量级只可能是
* 客户端算错或有人手造请求,直接拒掉比默默入库好——停留时长是要给老师看的。
*/
export const tutorialProgressPingSchema = z.object({
seconds: z.number().int().min(0).max(3600),
opened: z.boolean(),
})
/**
* 学习页目录要的整套进度:**每篇公开教程都有一行**,没读过的就是一行零。
* 让前端 `progress[id]` 永远取得到,省得目录里每处都判一次 undefined。
*/
export const tutorialProgressSchema = z.object({
tutorialId: z.number().int(),
viewCount: z.number().int(),
totalSeconds: z.number().int(),
// 没读过时是 null,不是假的零时间
firstViewedAt: z.string().nullable(),
lastViewedAt: z.string().nullable(),
exerciseTotal: z.number().int(),
exerciseSolved: z.number().int(),
})
/**
* 一次练一练的作答。
*
* `answer` 是前端拼好的一句人话(「选了 A、C」),只在做错时留下来给老师看;
* 做对了没什么好看的。长度卡在 200 字符:它是给人扫一眼的摘要,不是完整作答,
* 填空题写一整段进来只会把后台表格撑爆。
*/
export const exerciseAttemptRequestSchema = z.object({
correct: z.boolean(),
answer: z.string().max(200).optional(),
})
/**
* 题型 → 内容形状。**只在写入侧校验**(`apps/api/src/services/exercise.ts` 的
* `exerciseDataError`),读出侧不校验。
*
* 这张表曾经挂在 `exerciseSchema` 的 superRefine 上,于是学生端那条
* `GET /tutorials/:id/exercises`(`routes/content.ts` 里硬 parse)变成了一道
* 读闸:一行 data 对不上,整条练习列表 500 —— 坏的不是那一道,是整页。而写入侧
* 当时并不检查 `question`,两边严紧度不一致,脏数据进得来、出不去。
*
* JSONB 的形状真相在写入侧,就在写入侧卡住:那里能给老师一句中文原因,
* 也不会让历史数据把学生端打不开。
*/
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()),
}),
}
/**
* 练一练。`data` 是 `Record<string, unknown>`,**读出侧刻意不按题型收紧** ——
* 这个 schema 后端也在 parse(`routes/content.ts`),收紧它等于给学生端加一道
* 会 500 的闸。七种题型各自的形状见上面的 `exerciseDataByType`,在写入时卡。
*/
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(),
/** 当前学生做对过没有。未登录恒为 false;一课的练一练全做对就算学完这课 */
solved: z.boolean(),
})
export type Message = z.infer<typeof messageSchema>
export type MessageList = z.infer<typeof messageListSchema>
export type Announcement = z.infer<typeof announcementSchema>
export type AnnouncementListItem = z.infer<typeof announcementListItemSchema>
export type TutorialSummary = z.infer<typeof tutorialSummarySchema>
export type AnnouncementList = z.infer<typeof announcementListSchema>
export type CreateMessageRequest = z.infer<typeof createMessageRequestSchema>
export type ReactionKey = z.infer<typeof reactionKeySchema>
export type ReactionCounts = z.infer<typeof reactionCountsSchema>
export type ReactionState = z.infer<typeof reactionStateSchema>
export type SetReactionRequest = z.infer<typeof setReactionRequestSchema>
export type Tutorial = z.infer<typeof tutorialSchema>
export type TutorialProblem = z.infer<typeof tutorialProblemSchema>
export type Exercise = z.infer<typeof exerciseSchema>
export type TutorialProgress = z.infer<typeof tutorialProgressSchema>
export type TutorialProgressPing = z.infer<typeof tutorialProgressPingSchema>
export type ExerciseAttemptRequest = z.infer<typeof exerciseAttemptRequestSchema>
/**
* 「学完一课」的口径:**这课的练一练全做对**。没有练一练的课,打开过就算。
*
* 原来按「累计停留满 3 分钟」算已读,学生看不见这条规则,只看见圆点变黄;而快照里
* 停留时长中位数是 0 秒 —— 一节课上十几分钟点开 17 课的大有人在。练一练是学生
* 自己做的、看得见的,「全对了」比「待够了」说得清。停留时长照样记,只给老师看。
*
* 前端目录、首页、后台「自学情况」都走这一个函数,别各写一份。
*/
export function isLessonDone(progress: {
viewCount: number
exerciseTotal: number
exerciseSolved: number
}) {
if (progress.exerciseTotal > 0) return progress.exerciseSolved >= progress.exerciseTotal
return progress.viewCount > 0
}