| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219 |
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
- import { resolve } from 'node:path';
- import { verifyReview, reviewMarkdown } from './review-content.mjs';
- const root = process.cwd();
- const catalog = [
- ['api', 'API 服务开发', 16],
- ['database', '数据库开发', 12],
- ['api-database', 'API + 数据库', 12],
- ['tasks', '异步任务开发', 16],
- ['redis', 'Redis 缓存', 10],
- ['docker', 'Docker 容器化', 10],
- ['linux', 'Linux 部署', 10],
- ['troubleshooting', '线上问题排查', 12],
- ['distributed', '分布式系统设计', 12],
- ['review', '复盘与自测', 9],
- ];
- const errors = [];
- let total = 0;
- const report = [];
- const validatedModules = [];
- mkdirSync(resolve(root, 'public/downloads'), { recursive: true });
- const text = (value) => typeof value === 'string' && value.trim().length > 0;
- const list = (value, min = 1) => Array.isArray(value) && value.length >= min;
- function verify(l, slug, index) {
- if (slug === 'review') {
- errors.push(
- ...verifyReview(
- l,
- index,
- new Map(
- validatedModules.map((module) => [module.slug, module.lessons]),
- ),
- ),
- );
- return;
- }
- const prefix = `${slug}/${l?.id || index}`;
- const theory = slug === 'distributed';
- if (
- theory &&
- (l.kind !== 'theory' ||
- l.production ||
- l.steps?.some((s) => s.code || s.lang || s.filename))
- )
- errors.push(
- `${prefix}: theory lessons must not contain practical code or production drills`,
- );
- if (
- theory &&
- (!text(l.interview?.question) ||
- !text(l.interview?.answer) ||
- !list(l.interview?.followUps))
- )
- errors.push(`${prefix}: missing interview question, answer or follow-ups`);
- if (l?.id !== String(index + 1).padStart(2, '0'))
- errors.push(`${prefix}: invalid lesson ID`);
- for (const key of ['group', 'title', 'summary'])
- if (!text(l?.[key])) errors.push(`${prefix}: missing ${key}`);
- if (
- !text(l.intro?.scenario) ||
- !text(l.intro?.outcome) ||
- !list(l.intro?.startingPoint) ||
- l.intro.startingPoint.some((item) => !text(item))
- )
- errors.push(
- `${prefix}: missing self-study scenario, starting point or outcome`,
- );
- if ('minutes' in l)
- errors.push(`${prefix}: instructor duration metadata is not allowed`);
- for (const key of [
- 'objectives',
- 'concepts',
- 'steps',
- 'checks',
- 'pitfalls',
- 'refs',
- ])
- if (!list(l?.[key])) errors.push(`${prefix}: missing ${key}`);
- for (const c of l.concepts || [])
- if (!text(c.title) || !text(c.body))
- errors.push(`${prefix}: incomplete concept`);
- for (const [stepIndex, s] of (l.steps || []).entries()) {
- const stepPrefix = `${prefix}/step-${stepIndex + 1}`;
- for (const key of ['title', 'body', 'expected', 'explanation'])
- if (!text(s[key])) errors.push(`${stepPrefix}: missing ${key}`);
- if (
- s.expected === s.body ||
- s.explanation === s.body ||
- s.expected === s.explanation
- )
- errors.push(
- `${stepPrefix}: action, observation and explanation must be distinct`,
- );
- }
- for (const p of l.pitfalls || [])
- if (!text(p.symptom) || !text(p.cause) || !text(p.fix))
- errors.push(`${prefix}: incomplete troubleshooting`);
- for (const key of ['task', 'hint', 'answer'])
- if (!text(l.exercise?.[key]))
- errors.push(`${prefix}: missing exercise ${key}`);
- for (const ref of l.refs || []) {
- try {
- if (!text(ref.label) || new URL(ref.url).protocol !== 'https:')
- throw new Error();
- } catch {
- errors.push(`${prefix}: invalid reference ${ref.url}`);
- }
- }
- if (slug !== 'api') {
- if (
- !list(l.objectives, 3) ||
- !list(l.steps, 4) ||
- !list(l.concepts, 3) ||
- !list(l.checks, 3) ||
- !list(l.pitfalls, 2) ||
- !list(l.refs, 2)
- )
- errors.push(`${prefix}: insufficient lesson detail`);
- if (
- !theory &&
- (!text(l.production?.scenario) ||
- !list(l.production?.decisions) ||
- !text(l.production?.failureDrill) ||
- !list(l.production?.acceptance))
- )
- errors.push(`${prefix}: incomplete production exercise`);
- }
- }
- const bullets = (items) => items.map((s) => `- ${s}`).join('\n');
- function markdown(title, lessons) {
- return (
- `# ${title}\n\n术核 AI · 后端工程课件\n\n` +
- lessons
- .map((l) => {
- const theory = l.kind === 'theory';
- 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`;
- out += theory
- ? '先按每一步的问题作出判断,再对照推演结果和解释;完成后用课末回顾整理概念。\n'
- : '每次只做一个步骤,先核对结果,再读原因。结果不一致时,先排查当前步骤,再继续往下。\n';
- out += `\n### 这节课要完成什么\n\n${bullets(l.objectives)}\n`;
- out +=
- `\n### ${theory ? '一步步推演场景' : '一步步完成案例'}\n\n` +
- l.steps
- .map(
- (s, i) =>
- `#### ${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`,
- )
- .join('\n');
- out += `\n### ${theory ? '检查推演结果' : '检查学习结果'}\n\n${bullets(l.checks)}\n`;
- if (l.production) {
- const p = l.production;
- 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`;
- }
- out +=
- `\n### ${theory ? '常见误区' : '遇到问题时查这里'}\n\n` +
- l.pitfalls
- .map(
- (p) =>
- `#### ${p.symptom}\n\n原因:${p.cause}\n\n处理:${p.fix}\n`,
- )
- .join('\n');
- if (l.interview)
- out += `\n### 面试问答\n\n${l.interview.question}\n\n${l.interview.answer}\n\n追问:\n\n${bullets(l.interview.followUps)}\n`;
- 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`;
- out += l.concepts
- .map((c) => `#### ${c.title}\n\n${c.body}\n`)
- .join('\n');
- out += `\n### ${theory ? '延伸阅读' : '技术文档'}\n\n${l.refs.map((r) => `- [${r.label}](${r.url})`).join('\n')}\n`;
- return out;
- })
- .join('\n---\n\n')
- );
- }
- for (const [slug, title, count] of catalog) {
- const path = resolve(
- root,
- slug === 'api' ? 'app/lessons.json' : `public/content/${slug}.json`,
- );
- let lessons;
- try {
- lessons = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
- } catch (error) {
- errors.push(`${slug}: ${error.message}`);
- continue;
- }
- if (!Array.isArray(lessons) || lessons.length !== count) {
- errors.push(`${slug}: expected ${count} lessons`);
- continue;
- }
- lessons.forEach((l, i) => verify(l, slug, i));
- total += lessons.length;
- validatedModules.push({ slug, title, lessons });
- report.push({
- slug,
- lessons: lessons.length,
- steps: lessons.reduce((n, l) => n + (l.steps?.length || 0), 0),
- codeBlocks: lessons.reduce(
- (n, l) => n + (l.steps?.filter((s) => s.code).length || 0),
- 0,
- ),
- });
- }
- if (errors.length) {
- console.error(errors.join('\n'));
- process.exit(1);
- }
- for (const { slug, title, lessons } of validatedModules) {
- writeFileSync(
- resolve(root, `public/downloads/${slug}-lessons.md`),
- slug === 'review' ? reviewMarkdown(lessons) : markdown(title, lessons),
- );
- }
- console.log(
- JSON.stringify(
- { modules: report.length, lessons: total, details: report },
- null,
- 2,
- ),
- );
|