#!/usr/bin/env node /** * 彻底排查「段落间距」全链路: * 1. 该课程所有节点:outline.suggestedContent 按双换行拆段数 vs node_slides.content.paragraphs 数 * 2. 调用 lesson detail API,核对返回的 blocks 数量与内容 * 用法:node scripts/inspect-paragraphs-full.js * 例:node scripts/inspect-paragraphs-full.js 08253870-7499-4f39-9fa1-9cb76c280770 */ const { PrismaClient } = require('@prisma/client'); const http = require('http'); const prisma = new PrismaClient(); const API_BASE = process.env.API_BASE || 'http://127.0.0.1:3000'; function get(url) { return new Promise((resolve, reject) => { const u = new URL(url); const req = http.request( { hostname: u.hostname, port: u.port || 80, path: u.pathname + u.search, method: 'GET' }, (res) => { let body = ''; res.on('data', (ch) => { body += ch; }); res.on('end', () => { try { resolve(JSON.parse(body)); } catch (e) { reject(new Error('parse JSON: ' + body.slice(0, 200))); } }); } ); req.on('error', reject); req.setTimeout(8000, () => { req.destroy(); reject(new Error('timeout')); }); req.end(); }); } async function main() { const courseId = process.argv[2] ? process.argv[2].trim() : null; if (!courseId) { console.log('用法: node scripts/inspect-paragraphs-full.js '); process.exit(1); } const task = await prisma.courseGenerationTask.findFirst({ where: { courseId }, orderBy: { createdAt: 'desc' }, include: { course: { select: { id: true, title: true } } }, }); if (!task) { console.log('未找到 courseId 对应的任务:', courseId); return; } const outline = task.outline; if (!outline || !outline.chapters || !Array.isArray(outline.chapters)) { console.log('任务无 outline.chapters'); return; } const outlineNodes = []; for (const ch of outline.chapters) { if (ch.nodes && Array.isArray(ch.nodes)) { for (const node of ch.nodes) { outlineNodes.push({ title: node.title || '', suggestedContent: node.suggestedContent || '', }); } } } const dbNodes = await prisma.courseNode.findMany({ where: { courseId }, orderBy: { orderIndex: 'asc' }, include: { slides: { orderBy: { orderIndex: 'asc' } } }, }); console.log('=== 课程:', task.course?.title ?? courseId, '==='); console.log('courseId:', courseId); console.log('taskId:', task.id); console.log('outline 小节总数:', outlineNodes.length); console.log('DB 节点总数:', dbNodes.length); console.log(''); const rows = []; for (let i = 0; i < Math.max(outlineNodes.length, dbNodes.length); i++) { const oNode = outlineNodes[i]; const dNode = dbNodes[i]; let outlineParagraphs = 0; if (oNode && oNode.suggestedContent) { const split = oNode.suggestedContent.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0); outlineParagraphs = split.length; } let slideParagraphs = 0; let nodeId = ''; let nodeTitle = ''; if (dNode) { nodeId = dNode.id; nodeTitle = dNode.title || ''; const slide = dNode.slides && dNode.slides[0]; if (slide && slide.content && typeof slide.content === 'object' && Array.isArray(slide.content.paragraphs)) { slideParagraphs = slide.content.paragraphs.length; } } const match = outlineParagraphs === slideParagraphs ? '✓' : '✗'; rows.push({ index: i + 1, nodeId, nodeTitle: nodeTitle.slice(0, 36) + (nodeTitle.length > 36 ? '…' : ''), outlineParagraphs, slideParagraphs, match, }); } console.log('--- 全节点对比(outline 按双换行拆段数 vs node_slides.paragraphs 数)---'); console.log('序号\toutline段数\tslide段数\t一致\t节点标题'); rows.forEach((r) => { console.log(`${r.index}\t${r.outlineParagraphs}\t\t${r.slideParagraphs}\t\t${r.match}\t${r.nodeTitle}`); }); const mismatchCount = rows.filter((r) => r.match === '✗').length; console.log(''); if (mismatchCount > 0) { console.log('⚠️ 不一致节点数:', mismatchCount); } else { console.log('✓ 所有节点 outline 段数与 node_slides 段数一致'); } console.log(''); console.log('--- 调用 lesson detail API 核对(前 3 个节点)---'); for (let i = 0; i < Math.min(3, dbNodes.length); i++) { const node = dbNodes[i]; const nodeId = node.id; try { const res = await get(`${API_BASE}/api/lessons/nodes/${nodeId}/detail`); if (!res || !res.success || !res.data) { console.log(`节点 ${i + 1} (${nodeId}): API 返回异常`, res ? (res.error || res) : 'null'); continue; } const blocks = res.data.blocks || []; const blockTypes = blocks.map((b) => b.type).join(', '); console.log(`节点 ${i + 1}: ${node.title?.slice(0, 32)}…`); console.log(` nodeId: ${nodeId}`); console.log(` API blocks 数量: ${blocks.length}`); console.log(` block types: ${blockTypes}`); blocks.slice(0, 2).forEach((b, bi) => { console.log(` block[${bi}] type=${b.type} contentLen=${(b.content || '').length} contentPreview=${JSON.stringify((b.content || '').slice(0, 50))}`); }); const expectedBlocks = rows[i] ? rows[i].slideParagraphs : 0; if (blocks.length !== expectedBlocks) { console.log(` ⚠️ 与 node_slides.paragraphs 数(${expectedBlocks}) 不一致`); } console.log(''); } catch (e) { console.log(`节点 ${i + 1} (${nodeId}): 请求失败`, e.message); console.log(''); } } console.log('--- 小结 ---'); console.log('1. 上表:outline 段数 = suggestedContent 按 /\\n\\s*\\n/ 拆分后的段落数;slide 段数 = node_slides.content.paragraphs 长度'); console.log('2. getLessonDetail 会把每个 slide 的 content.paragraphs 逐条转成 paragraph block,故 blocks 数应等于该节点 slide 的 paragraphs 数'); console.log('3. iOS ContentBlockBuilder 在 index>0 的 block 前插入 paragraphSpacing(24pt),多段即有多段间距'); } main() .then(() => process.exit(0)) .catch((e) => { console.error(e); process.exit(1); }) .finally(() => prisma.$disconnect());