build-course-content.mjs 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
  2. import { resolve } from 'node:path';
  3. import { verifyReview, reviewMarkdown } from './review-content.mjs';
  4. const root = process.cwd();
  5. const catalog = [
  6. ['api', 'API 服务开发', 16],
  7. ['database', '数据库开发', 12],
  8. ['api-database', 'API + 数据库', 12],
  9. ['tasks', '异步任务开发', 16],
  10. ['redis', 'Redis 缓存', 10],
  11. ['docker', 'Docker 容器化', 10],
  12. ['linux', 'Linux 部署', 10],
  13. ['troubleshooting', '线上问题排查', 12],
  14. ['distributed', '分布式系统设计', 12],
  15. ['review', '复盘与自测', 9],
  16. ];
  17. const errors = [];
  18. let total = 0;
  19. const report = [];
  20. const validatedModules = [];
  21. mkdirSync(resolve(root, 'public/downloads'), { recursive: true });
  22. const text = (value) => typeof value === 'string' && value.trim().length > 0;
  23. const list = (value, min = 1) => Array.isArray(value) && value.length >= min;
  24. function verify(l, slug, index) {
  25. if (slug === 'review') {
  26. errors.push(
  27. ...verifyReview(
  28. l,
  29. index,
  30. new Map(
  31. validatedModules.map((module) => [module.slug, module.lessons]),
  32. ),
  33. ),
  34. );
  35. return;
  36. }
  37. const prefix = `${slug}/${l?.id || index}`;
  38. const theory = slug === 'distributed';
  39. if (
  40. theory &&
  41. (l.kind !== 'theory' ||
  42. l.production ||
  43. l.steps?.some((s) => s.code || s.lang || s.filename))
  44. )
  45. errors.push(
  46. `${prefix}: theory lessons must not contain practical code or production drills`,
  47. );
  48. if (
  49. theory &&
  50. (!text(l.interview?.question) ||
  51. !text(l.interview?.answer) ||
  52. !list(l.interview?.followUps))
  53. )
  54. errors.push(`${prefix}: missing interview question, answer or follow-ups`);
  55. if (l?.id !== String(index + 1).padStart(2, '0'))
  56. errors.push(`${prefix}: invalid lesson ID`);
  57. for (const key of ['group', 'title', 'summary'])
  58. if (!text(l?.[key])) errors.push(`${prefix}: missing ${key}`);
  59. if (
  60. !text(l.intro?.scenario) ||
  61. !text(l.intro?.outcome) ||
  62. !list(l.intro?.startingPoint) ||
  63. l.intro.startingPoint.some((item) => !text(item))
  64. )
  65. errors.push(
  66. `${prefix}: missing self-study scenario, starting point or outcome`,
  67. );
  68. if ('minutes' in l)
  69. errors.push(`${prefix}: instructor duration metadata is not allowed`);
  70. for (const key of [
  71. 'objectives',
  72. 'concepts',
  73. 'steps',
  74. 'checks',
  75. 'pitfalls',
  76. 'refs',
  77. ])
  78. if (!list(l?.[key])) errors.push(`${prefix}: missing ${key}`);
  79. for (const c of l.concepts || [])
  80. if (!text(c.title) || !text(c.body))
  81. errors.push(`${prefix}: incomplete concept`);
  82. for (const [stepIndex, s] of (l.steps || []).entries()) {
  83. const stepPrefix = `${prefix}/step-${stepIndex + 1}`;
  84. for (const key of ['title', 'body', 'expected', 'explanation'])
  85. if (!text(s[key])) errors.push(`${stepPrefix}: missing ${key}`);
  86. if (
  87. s.expected === s.body ||
  88. s.explanation === s.body ||
  89. s.expected === s.explanation
  90. )
  91. errors.push(
  92. `${stepPrefix}: action, observation and explanation must be distinct`,
  93. );
  94. }
  95. for (const p of l.pitfalls || [])
  96. if (!text(p.symptom) || !text(p.cause) || !text(p.fix))
  97. errors.push(`${prefix}: incomplete troubleshooting`);
  98. for (const key of ['task', 'hint', 'answer'])
  99. if (!text(l.exercise?.[key]))
  100. errors.push(`${prefix}: missing exercise ${key}`);
  101. for (const ref of l.refs || []) {
  102. try {
  103. if (!text(ref.label) || new URL(ref.url).protocol !== 'https:')
  104. throw new Error();
  105. } catch {
  106. errors.push(`${prefix}: invalid reference ${ref.url}`);
  107. }
  108. }
  109. if (slug !== 'api') {
  110. if (
  111. !list(l.objectives, 3) ||
  112. !list(l.steps, 4) ||
  113. !list(l.concepts, 3) ||
  114. !list(l.checks, 3) ||
  115. !list(l.pitfalls, 2) ||
  116. !list(l.refs, 2)
  117. )
  118. errors.push(`${prefix}: insufficient lesson detail`);
  119. if (
  120. !theory &&
  121. (!text(l.production?.scenario) ||
  122. !list(l.production?.decisions) ||
  123. !text(l.production?.failureDrill) ||
  124. !list(l.production?.acceptance))
  125. )
  126. errors.push(`${prefix}: incomplete production exercise`);
  127. }
  128. }
  129. const bullets = (items) => items.map((s) => `- ${s}`).join('\n');
  130. function markdown(title, lessons) {
  131. return (
  132. `# ${title}\n\n术核 AI · 后端工程课件\n\n` +
  133. lessons
  134. .map((l) => {
  135. const theory = l.kind === 'theory';
  136. let out = `## ${l.id} ${l.title}\n\n${l.summary}\n\n### 从一个问题开始\n\n${l.intro.scenario}\n\n**这次的终点**\n\n${l.intro.outcome}\n\n#### 开始前,确认你在这里\n\n${bullets(l.intro.startingPoint)}\n\n`;
  137. out += theory
  138. ? '先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。\n'
  139. : '每次只做一个步骤,先核对结果,再读原因。结果不一致时,先排查当前步骤,再继续往下。\n';
  140. out += `\n### 这节课要完成什么\n\n${bullets(l.objectives)}\n`;
  141. out +=
  142. `\n### ${theory ? '一步步推演场景' : '一步步完成案例'}\n\n` +
  143. l.steps
  144. .map(
  145. (s, i) =>
  146. `#### ${i + 1}. ${s.title}\n\n${s.body}\n${s.code ? `\n文件:${s.filename || '终端 / 当前课时'}\n\n\`\`\`\`${s.lang || 'text'}\n${s.code}\n\`\`\`\`\n` : ''}\n**${theory ? '对照你的推演' : '你应该看到什么'}**\n\n${s.expected}\n\n**${theory ? '从这里理解原因' : '刚才发生了什么'}**\n\n${s.explanation}\n`,
  147. )
  148. .join('\n');
  149. out += `\n### ${theory ? '检查推演结果' : '检查学习结果'}\n\n${bullets(l.checks)}\n`;
  150. if (l.production) {
  151. const p = l.production;
  152. out += `\n### 进一步验证:约束与故障\n\n先通过上面的学习结果检查,再做这一组实验。\n\n${p.scenario}\n\n#### 实现决策\n\n${bullets(p.decisions)}\n\n#### 隔离环境演练\n\n${p.failureDrill}\n\n#### 恢复验收\n\n${bullets(p.acceptance)}\n`;
  153. }
  154. out +=
  155. `\n### ${theory ? '常见误区' : '遇到问题时查这里'}\n\n` +
  156. l.pitfalls
  157. .map(
  158. (p) =>
  159. `#### ${p.symptom}\n\n原因:${p.cause}\n\n处理:${p.fix}\n`,
  160. )
  161. .join('\n');
  162. if (l.interview)
  163. out += `\n### 面试问答\n\n${l.interview.question}\n\n${l.interview.answer}\n\n追问:\n\n${bullets(l.interview.followUps)}\n`;
  164. out += `\n### ${theory ? '自己设计一次' : '自己试一次'}\n\n${l.exercise.task}\n\n提示:${l.exercise.hint}\n\n参考答案:\n\n${l.exercise.answer}\n\n### 核心概念回顾\n\n${theory ? '回想刚才每一种选择造成的结果,再用下面的概念解释自己的判断。' : '回想刚才的操作、现象与选择,用下面的概念解释你已经完成的案例。'}\n\n`;
  165. out += l.concepts
  166. .map((c) => `#### ${c.title}\n\n${c.body}\n`)
  167. .join('\n');
  168. out += `\n### ${theory ? '延伸阅读' : '技术文档'}\n\n${l.refs.map((r) => `- [${r.label}](${r.url})`).join('\n')}\n`;
  169. return out;
  170. })
  171. .join('\n---\n\n')
  172. );
  173. }
  174. for (const [slug, title, count] of catalog) {
  175. const path = resolve(
  176. root,
  177. slug === 'api' ? 'app/lessons.json' : `public/content/${slug}.json`,
  178. );
  179. let lessons;
  180. try {
  181. lessons = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
  182. } catch (error) {
  183. errors.push(`${slug}: ${error.message}`);
  184. continue;
  185. }
  186. if (!Array.isArray(lessons) || lessons.length !== count) {
  187. errors.push(`${slug}: expected ${count} lessons`);
  188. continue;
  189. }
  190. lessons.forEach((l, i) => verify(l, slug, i));
  191. total += lessons.length;
  192. validatedModules.push({ slug, title, lessons });
  193. report.push({
  194. slug,
  195. lessons: lessons.length,
  196. steps: lessons.reduce((n, l) => n + (l.steps?.length || 0), 0),
  197. codeBlocks: lessons.reduce(
  198. (n, l) => n + (l.steps?.filter((s) => s.code).length || 0),
  199. 0,
  200. ),
  201. });
  202. }
  203. if (errors.length) {
  204. console.error(errors.join('\n'));
  205. process.exit(1);
  206. }
  207. for (const { slug, title, lessons } of validatedModules) {
  208. writeFileSync(
  209. resolve(root, `public/downloads/${slug}-lessons.md`),
  210. slug === 'review' ? reviewMarkdown(lessons) : markdown(title, lessons),
  211. );
  212. }
  213. console.log(
  214. JSON.stringify(
  215. { modules: report.length, lessons: total, details: report },
  216. null,
  217. 2,
  218. ),
  219. );