Procházet zdrojové kódy

feat(book-generator): 新增 chapter-nav / chapter-read / chapter-tts 模块

- chapter-nav.ts          (1709 bytes)
- chapter-read.controller.ts (5892 bytes)
- chapter-tts.ts          (3208 bytes)

Co-Authored-By: Claude <noreply@anthropic.com>
MyFramework User před 1 měsícem
rodič
revize
04aaec998d

+ 50 - 0
server/src/modules/book-generator/chapter-nav.ts

@@ -0,0 +1,50 @@
+/**
+ * 章节导航信息(小智音箱播完当前章节后用来找下一章)
+ *
+ * 服务端实现,对应 mcp-for-xiaozhi/audio_server.py:_chapter_nav
+ * 区别:这里走 Prisma 直查,不走 HTTP(避免 MCP 自调)
+ */
+import { prisma } from '../../models';
+
+export interface ChapterNav {
+  chapter_index: number;
+  total_chapters: number;
+  prev_chapter_id: number | null;
+  next_chapter_id: number | null;
+}
+
+/**
+ * 取章节在书中的索引、上下章 ID。
+ *
+ * @param bookId 书籍 ID
+ * @param chapterId 当前章节 ID(0 时只返回 total_chapters)
+ */
+export async function getChapterNav(bookId: number, chapterId: number): Promise<ChapterNav> {
+  if (!bookId || !chapterId) {
+    return { chapter_index: 0, total_chapters: 0, prev_chapter_id: null, next_chapter_id: null };
+  }
+
+  try {
+    // 取同级章节(level=1 通常为章;如未来有 level=2 小节需求,按 number 排序即可)
+    const ids = await prisma.bookChapter.findMany({
+      where: { bookId, parentId: 0 },
+      select: { id: true },
+      orderBy: [{ number: 'asc' }, { id: 'asc' }],
+    });
+    const total = ids.length;
+    const idList = ids.map((c) => c.id);
+    const idx = idList.indexOf(chapterId);
+    if (idx < 0) {
+      return { chapter_index: 0, total_chapters: total, prev_chapter_id: null, next_chapter_id: null };
+    }
+    return {
+      chapter_index: idx + 1,
+      total_chapters: total,
+      prev_chapter_id: idx > 0 ? idList[idx - 1] : null,
+      next_chapter_id: idx < total - 1 ? idList[idx + 1] : null,
+    };
+  } catch (e) {
+    // nav 失败不影响主流程
+    return { chapter_index: 0, total_chapters: 0, prev_chapter_id: null, next_chapter_id: null };
+  }
+}

+ 170 - 0
server/src/modules/book-generator/chapter-read.controller.ts

@@ -0,0 +1,170 @@
+/**
+ * 章节统一朗读接口 — 给小智音箱/外部设备用
+ *
+ *   GET /api/book-generator/books/chapters/:id/read?format=audio|text|auto[&voice_id=&speed=&offset=&max_chars=]
+ *
+ * 用途:把"返回音频 URL 让音箱直接播"和"返回文本让音箱自己 TTS 读"统一为一个端点,
+ * 由调用方通过 format 参数显式选择。
+ *
+ * 设计要点:
+ *   - 独立端点,不动 album-controller.ts 的 getChapterDetail(避免破坏现有 caller)。
+ *   - format=audio:无合法音频时触发 on-demand TTS;失败返 502 TTS_FALLBACK_FAILED。
+ *   - format=text:stripMarkdown 后按 offset/max_chars 分页,纯本地处理。
+ *   - format=auto(默认):有音频 → audio;无音频 → text(**不**走 on-demand,避免误合成)。
+ *
+ * 鉴权:optionalAuth + assertBookAccess('read'),与现有章节接口一致。
+ */
+import Router from '@koa/router';
+import type { Context } from 'koa';
+import { optionalAuth } from '../../middleware/auth';
+import { prisma } from '../../models';
+import { assertBookAccess } from './access-control';
+import { stripMarkdown } from '../tts/tts.service';
+import { onDemandChapterTts } from './chapter-tts';
+import { getChapterNav } from './chapter-nav';
+
+// 开发环境测试用户ID(与 album-controller 对齐)
+const TEST_USER_ID = '1';
+
+const DEFAULT_TEXT_MAX_CHARS = 800;
+const HARD_TEXT_MAX_CHARS = 4000;
+const VALID_FORMATS = ['audio', 'text', 'auto'] as const;
+type ReadFormat = typeof VALID_FORMATS[number];
+
+const router = new Router({ prefix: '/books/chapters' });
+
+/**
+ * GET /books/chapters/:id/read
+ *
+ * Query:
+ *   format      - audio | text | auto (default auto)
+ *   voice_id    - optional, on-demand TTS 音色
+ *   speed       - optional, 语速 0.5~2.0
+ *   offset      - optional, text 模式分页起点
+ *   max_chars   - optional, text 模式分页大小(默认 800,上限 4000)
+ */
+async function readChapter(ctx: Context): Promise<void> {
+  const chapterId = parseInt(ctx.params.id);
+  const userId = ctx.state.user?.userId || TEST_USER_ID;
+  const format = ((ctx.query.format as string) || 'auto') as ReadFormat;
+
+  if (!VALID_FORMATS.includes(format)) {
+    ctx.status = 400;
+    ctx.body = { code: 2004, message: `INVALID_FORMAT (must be one of ${VALID_FORMATS.join(', ')})`, data: null };
+    return;
+  }
+  if (Number.isNaN(chapterId)) {
+    ctx.status = 400;
+    ctx.body = { code: 2004, message: 'INVALID_CHAPTER_ID', data: null };
+    return;
+  }
+
+  const chapter = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
+  if (!chapter || !(await assertBookAccess(chapter.bookId, userId, 'read'))) {
+    ctx.status = 404;
+    ctx.body = { code: 2001, message: 'CHAPTER_NOT_FOUND', data: null };
+    return;
+  }
+
+  const nav = await getChapterNav(chapter.bookId, chapter.id);
+  const meta = {
+    chapter_id: chapter.id,
+    book_id: chapter.bookId,
+    title: chapter.title,
+    word_count: chapter.wordCount || 0,
+    nav,
+  };
+
+  const hasValidAudio = !!chapter.audioUrl && !chapter.audioUrl.startsWith('/uploads/');
+
+  // -------- format=text:纯文本分支(不调 TTS) --------
+  if (format === 'text') {
+    const maxChars = Math.min(parseInt(ctx.query.max_chars as string) || DEFAULT_TEXT_MAX_CHARS, HARD_TEXT_MAX_CHARS);
+    const offset = Math.max(parseInt(ctx.query.offset as string) || 0, 0);
+    const clean = stripMarkdown(chapter.content || '');
+    const text = clean.slice(offset, offset + maxChars);
+    const hasMore = offset + maxChars < clean.length;
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        mode: 'text',
+        ...meta,
+        text,
+        has_more: hasMore,
+        next_offset: hasMore ? offset + maxChars : null,
+        total_chars: clean.length,
+      },
+    };
+    return;
+  }
+
+  // -------- format=audio | auto:有合法音频直接返 --------
+  if (hasValidAudio) {
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        mode: 'audio',
+        ...meta,
+        audio_url: chapter.audioUrl,
+        audio_duration: chapter.audioDuration || 0,
+        audio_source: chapter.audioSource || 'full',
+      },
+    };
+    return;
+  }
+
+  // -------- 没音频:auto 走 text;audio 走 on-demand TTS --------
+  if (format === 'auto') {
+    const clean = stripMarkdown(chapter.content || '');
+    const maxChars = Math.min(parseInt(ctx.query.max_chars as string) || DEFAULT_TEXT_MAX_CHARS, HARD_TEXT_MAX_CHARS);
+    const offset = Math.max(parseInt(ctx.query.offset as string) || 0, 0);
+    const text = clean.slice(offset, offset + maxChars);
+    const hasMore = offset + maxChars < clean.length;
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        mode: 'text',
+        ...meta,
+        text,
+        has_more: hasMore,
+        next_offset: hasMore ? offset + maxChars : null,
+        total_chars: clean.length,
+      },
+    };
+    return;
+  }
+
+  // format === 'audio' 显式要音频 → on-demand TTS 兜底
+  try {
+    const speed = ctx.query.speed ? parseFloat(ctx.query.speed as string) : undefined;
+    const voiceId = ctx.query.voice_id as string | undefined;
+    const result = await onDemandChapterTts(chapter, {
+      voiceId,
+      speed: speed !== undefined && !Number.isNaN(speed) ? speed : undefined,
+    });
+    ctx.body = {
+      code: 0,
+      message: 'success',
+      data: {
+        mode: 'audio',
+        ...meta,
+        audio_url: result.audioUrl,
+        audio_duration: result.duration,
+        audio_source: 'on_demand',
+      },
+    };
+  } catch (e: any) {
+    console.error(`[readChapter] on-demand TTS failed for chapter ${chapter.id}:`, e?.message);
+    ctx.status = 502;
+    ctx.body = { code: 2002, message: `TTS_FALLBACK_FAILED: ${e?.message || 'unknown'}`, data: null };
+  }
+}
+
+router.get('/:id/read', optionalAuth, readChapter);
+
+export { readChapter };
+
+export default router;

+ 84 - 0
server/src/modules/book-generator/chapter-tts.ts

@@ -0,0 +1,84 @@
+/**
+ * 章节级别 on-demand 同步 TTS 兜底
+ *
+ * 场景:小智音箱要播一个还没生成完整音频的章节。返回 content 第一段(≤500 字)
+ * 的合成音频 OSS URL,并把结果乐观写回 BookChapter.audioSource='on_demand'。
+ *
+ * 不修改:
+ * - TtsService.synthesizeSync 内部实现
+ * - 异步 TTS 写回逻辑(tts.service.ts:1378)
+ *
+ * 并发控制:用 Prisma updateMany 条件更新(where audioSource=null)做应用层乐观锁,
+ * 避免引入 Redis 依赖。并发时第二个请求拿到锁后 fresh.audioSource 可能是 'on_demand'
+ * —— 此时跳过重合成(参考二次校验逻辑)。
+ */
+import { synthesizeSync, stripMarkdown } from '../tts/tts.service';
+import { prisma } from '../../models';
+
+const MAX_TTS_CHARS = 500;       // 与 /api/tts/synthesize controller 硬限制对齐
+const FIRST_PARA_MIN = 50;       // 第一段字符数阈值(小于此阈值回退到整段取前 500)
+const EMPTY_CONTENT_MIN = 5;     // 内容过短判定
+
+export interface OnDemandChapterTtsOptions {
+  voiceId?: string;
+  speed?: number;
+}
+
+export interface OnDemandChapterTtsResult {
+  audioUrl: string;
+  duration: number;
+}
+
+/**
+ * 章节级别同步 TTS 兜底(取 content 第一段 ≤500 字 → stripMarkdown → 标题前缀 → synthesizeSync)
+ *
+ * @throws Error('CHAPTER_CONTENT_EMPTY') 内容过短
+ * @throws synthesizeSync 内部异常(Provider 全失败 / OSS 上传失败等)
+ */
+export async function onDemandChapterTts(
+  chapter: { id: number; content: string | null; title: string },
+  opts?: OnDemandChapterTtsOptions,
+): Promise<OnDemandChapterTtsResult> {
+  // 二次校验:可能并发请求时已有完整音频(异步 TTS 刚完成)
+  const fresh = await prisma.bookChapter.findUnique({ where: { id: chapter.id } });
+  if (
+    fresh?.audioUrl &&
+    !fresh.audioUrl.startsWith('/uploads/') &&
+    fresh.audioSource === 'full'
+  ) {
+    return { audioUrl: fresh.audioUrl, duration: fresh.audioDuration || 0 };
+  }
+
+  const clean = stripMarkdown(chapter.content || '');
+  if (clean.length < EMPTY_CONTENT_MIN) {
+    throw new Error('CHAPTER_CONTENT_EMPTY');
+  }
+
+  // 选段:优先第一段(双换行分隔),过短则用前 500 字
+  const firstPara = clean.split(/\n{2,}/)[0].trim();
+  const ttsBody = (firstPara.length >= FIRST_PARA_MIN ? firstPara : clean).slice(0, MAX_TTS_CHARS);
+
+  // 标题前缀(让听感更自然,"标题。第一段…")
+  const prefix = chapter.title ? `${chapter.title}。` : '';
+  const finalText = (prefix + ttsBody).slice(0, MAX_TTS_CHARS);
+
+  const result = await synthesizeSync(
+    finalText,
+    opts?.voiceId,
+    { speed: opts?.speed ?? 1.0 },
+  );
+
+  // 乐观锁写回:仅当 audioSource 为 null 才写,避免覆盖 full(异步 TTS 已完成的情况)
+  prisma.bookChapter
+    .updateMany({
+      where: { id: chapter.id, audioSource: null },
+      data: {
+        audioUrl: result.audioUrl,
+        audioDuration: result.duration,
+        audioSource: 'on_demand',
+      },
+    })
+    .catch((e: any) => console.warn('[onDemandChapterTts] writeback skipped:', e?.message));
+
+  return { audioUrl: result.audioUrl, duration: result.duration };
+}