Просмотр исходного кода

feat(tts): 简化语言集到 6 个通用语种 + 大模型生成接 targetLanguage

v1: 只暴露 6 个常用通用语种(粤语属于方言,放 v2)。
- zh-CMN 普通话
- en-US  / en-GB 美式/英式英语
- ja-JP  日语
- ko-KR  韩语
- id-ID  印尼语

完整矩阵数据(15 lang_key)保留在 server 端,后续可加"高级选项"暴露。

大模型 prompt 改造:
- deep-plan.node.ts + plan.node.ts: 接收 targetLanguage,在 prompt 里写
  '全篇内容必须用{lang}撰写'。这样大模型按用户选的语言生成内容。
- 之前是默认中文(无论用户选什么),现在会按 lang 走。

文件:
- deploy-package/scripts/build-lang-matrix-json.js: RECOMMENDED 改为 6 个
- my-uniapp-vue3/src/utils/languages.ts: LANGUAGES_FALLBACK 改为 6 个
- server/src/config/alicloud-tts-lang-voice-matrix.json: 重新生成

完整链路:
用户选 ja-JP → pickTtsVendor(ja-JP) → loongtomoka 日语音色
             + plan/deep-plan prompt '全篇内容必须用日语撰写'
             → 大模型生成日语内容 → TTS 用日语音色朗读
             → 文本+音频一致
MyFramework User 1 месяц назад
Родитель
Сommit
9a2615e926

+ 7 - 7
deploy-package/scripts/build-lang-matrix-json.js

@@ -172,8 +172,8 @@ const DIALECT_INSTRUCTIONS = {
 };
 };
 
 
 const RECOMMENDED = {
 const RECOMMENDED = {
-  'zh-CMN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '欢脱元气女,支持 9 种方言', dialectInstruction: DIALECT_INSTRUCTIONS['zh-CMN'] },
-  'zh-YUE':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanyue',     desc: '粤语标准女声', dialectInstruction: DIALECT_INSTRUCTIONS['zh-YUE'] },
+  'zh-CMN': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '中文(普通话)标准女声' },
+  'zh-YUE': { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanyue', desc: '' },
   'zh-NEN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '东北话,指令控制', dialectInstruction: DIALECT_INSTRUCTIONS['zh-NEN'] },
   'zh-NEN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '东北话,指令控制', dialectInstruction: DIALECT_INSTRUCTIONS['zh-NEN'] },
   'zh-SCN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '四川话,指令控制', dialectInstruction: DIALECT_INSTRUCTIONS['zh-SCN'] },
   'zh-SCN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '四川话,指令控制', dialectInstruction: DIALECT_INSTRUCTIONS['zh-SCN'] },
   'zh-SHD':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '山东话,指令控制', dialectInstruction: DIALECT_INSTRUCTIONS['zh-SHD'] },
   'zh-SHD':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '山东话,指令控制', dialectInstruction: DIALECT_INSTRUCTIONS['zh-SHD'] },
@@ -182,11 +182,11 @@ const RECOMMENDED = {
   'zh-SHX':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longshange',    desc: '陕西话专用(无需指令)', dialectInstruction: DIALECT_INSTRUCTIONS['zh-SHX'] },
   'zh-SHX':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longshange',    desc: '陕西话专用(无需指令)', dialectInstruction: DIALECT_INSTRUCTIONS['zh-SHX'] },
   'zh-AHM':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '安徽话,指令控制', dialectInstruction: DIALECT_INSTRUCTIONS['zh-AHM'] },
   'zh-AHM':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '安徽话,指令控制', dialectInstruction: DIALECT_INSTRUCTIONS['zh-AHM'] },
   'zh-MNN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanmin',     desc: '闽南话专用(无需指令)', dialectInstruction: DIALECT_INSTRUCTIONS['zh-MNN'] },
   'zh-MNN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanmin',     desc: '闽南话专用(无需指令)', dialectInstruction: DIALECT_INSTRUCTIONS['zh-MNN'] },
-  'en-US':   { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longava',       desc: '美式英语女声', dialectInstruction: DIALECT_INSTRUCTIONS['en-US'] },
-  'en-GB':   { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'loongluna',     desc: '英语英伦少女', dialectInstruction: DIALECT_INSTRUCTIONS['en-GB'] },
-  'ja-JP':   { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'loongtomoka',   desc: '日语女声', dialectInstruction: DIALECT_INSTRUCTIONS['ja-JP'] },
-  'ko-KR':   { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'sohee',         desc: '韩语女声', dialectInstruction: DIALECT_INSTRUCTIONS['ko-KR'] },
-  'id-ID':   { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'loongindah',    desc: '印尼女声', dialectInstruction: DIALECT_INSTRUCTIONS['id-ID'] },
+  'en-US':   { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longava',       desc: '美式英语女声' },
+  'en-GB':   { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'loongluna',     desc: '英语英伦少女' },
+  'ja-JP':   { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'loongtomoka',   desc: '日语女声' },
+  'ko-KR':   { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'sohee',         desc: '韩语女声' },
+  'id-ID':   { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'loongindah',    desc: '印尼女声' },
 };
 };
 
 
 // ── 4. 主流程: 读 raw → 生成 routes ─────────────────────────────
 // ── 4. 主流程: 读 raw → 生成 routes ─────────────────────────────

+ 6 - 15
my-uniapp-vue3/src/utils/languages.ts

@@ -8,23 +8,14 @@ export interface LangDef {
   vendorHint?: string;  // 后端返回的 vendor+model 提示
   vendorHint?: string;  // 后端返回的 vendor+model 提示
 }
 }
 
 
-// 本地默认(服务端不可达时用)
+// 本地默认(服务端不可达时用)。v1: 只 6 个通用语种,方言 v2 再做。
 export const LANGUAGES_FALLBACK: LangDef[] = [
 export const LANGUAGES_FALLBACK: LangDef[] = [
   { key: 'zh-CMN', label: '普通话', recommended: true },
   { key: 'zh-CMN', label: '普通话', recommended: true },
-  { key: 'zh-YUE', label: '粤语', recommended: true },
-  { key: 'zh-NEN', label: '东北话', recommended: true },
-  { key: 'zh-SCN', label: '四川话', recommended: true },
-  { key: 'zh-SHD', label: '山东话', recommended: true },
-  { key: 'zh-HEN', label: '河南话', recommended: true },
-  { key: 'zh-HNN', label: '湖南话', recommended: true },
-  { key: 'zh-SHX', label: '陕西话', recommended: true },
-  { key: 'zh-AHM', label: '安徽话', recommended: true },
-  { key: 'zh-MNN', label: '闽南话', recommended: true },
-  { key: 'en-US', label: '英语(美式)', recommended: true },
-  { key: 'en-GB', label: '英语(英式)', recommended: true },
-  { key: 'ja-JP', label: '日语', recommended: true },
-  { key: 'ko-KR', label: '韩语', recommended: true },
-  { key: 'id-ID', label: '印尼语', recommended: true },
+  { key: 'en-US',  label: '英语(美式)', recommended: true },
+  { key: 'en-GB',  label: '英语(英式)', recommended: true },
+  { key: 'ja-JP',  label: '日语', recommended: true },
+  { key: 'ko-KR',  label: '韩语', recommended: true },
+  { key: 'id-ID',  label: '印尼语', recommended: true },
 ];
 ];
 
 
 // 拉服务端最新列表
 // 拉服务端最新列表

+ 2 - 3
server/src/config/alicloud-tts-lang-voice-matrix.json

@@ -440,7 +440,7 @@
         "vendor": "bailian",
         "vendor": "bailian",
         "model": "cosyvoice-v3-flash",
         "model": "cosyvoice-v3-flash",
         "voice": "longanhuan_v3",
         "voice": "longanhuan_v3",
-        "desc": "欢脱元气女,支持 9 种方言"
+        "desc": "中文(普通话)标准女声"
       },
       },
       "fallbackChain": [
       "fallbackChain": [
         "longanhuan_v3",
         "longanhuan_v3",
@@ -810,8 +810,7 @@
         "vendor": "bailian",
         "vendor": "bailian",
         "model": "cosyvoice-v3-flash",
         "model": "cosyvoice-v3-flash",
         "voice": "longanyue",
         "voice": "longanyue",
-        "desc": "粤语标准女声",
-        "dialectInstruction": "请用粤语表达。"
+        "desc": ""
       },
       },
       "fallbackChain": [
       "fallbackChain": [
         "longanyue",
         "longanyue",

+ 18 - 1
server/src/modules/book-generator/nodes/deep-plan.node.ts

@@ -72,6 +72,7 @@ function buildDeepPlanMessages(
   description: string,
   description: string,
   bookScale: string,
   bookScale: string,
   userFeedback?: string,
   userFeedback?: string,
+  targetLanguage?: string,  // 🆕 用户选的目标语言(BCP-47),提示大模型输出对应语言
 ): ChatMessage[] {
 ): ChatMessage[] {
   const config = getScaleConfig(bookScale);
   const config = getScaleConfig(bookScale);
   const chapters = config?.chapters || 17;
   const chapters = config?.chapters || 17;
@@ -82,10 +83,22 @@ function buildDeepPlanMessages(
     ? descWordCount
     ? descWordCount
     : (config?.totalWords || 130000);
     : (config?.totalWords || 130000);
 
 
+  // 语言提示映射(给大模型看的自然语言指令)
+  const LANG_NAME: Record<string, string> = {
+    'zh-CMN': '中文(普通话)',
+    'en-US': '美式英语',
+    'en-GB': '英式英语',
+    'ja-JP': '日语',
+    'ko-KR': '韩语',
+    'id-ID': '印尼语',
+  };
+  const langName = LANG_NAME[targetLanguage || 'zh-CMN'] || '中文(普通话)';
+
   let userPrompt = `## 书籍信息
   let userPrompt = `## 书籍信息
 - 书名:${title}
 - 书名:${title}
 - 字数规模:约${totalWords}字,约${chapters}章
 - 字数规模:约${totalWords}字,约${chapters}章
 - 描述:${description}
 - 描述:${description}
+- **目标语言:${langName}**(全篇内容必须用${langName}撰写,包括标题、章节名、正文、注释)
 
 
 请对本书做全面的策划分析。`;
 请对本书做全面的策划分析。`;
 
 
@@ -178,7 +191,11 @@ export async function deepPlanBookNode(state: typeof GraphState.State): Promise<
     const title = book?.title || state.topic;
     const title = book?.title || state.topic;
     const description = book?.description || state.description || '';
     const description = book?.description || state.description || '';
 
 
-    const messages = buildDeepPlanMessages(title, description, state.bookScale, (state as any).userFeedback || '');
+    const messages = buildDeepPlanMessages(
+      title, description, state.bookScale,
+      (state as any).userFeedback || '',
+      (book as any)?.targetLanguage,  // 🆕 传入用户选的语言,提示大模型用对应语言
+    );
     const response = await callLLMWithMessages(messages);
     const response = await callLLMWithMessages(messages);
     const plan = parseDeepPlanResponse(response);
     const plan = parseDeepPlanResponse(response);
 
 

+ 11 - 2
server/src/modules/book-generator/nodes/plan.node.ts

@@ -69,7 +69,8 @@ function extractWordCountFromDescription(description: string): number | null {
 function buildPlanPrompt(
 function buildPlanPrompt(
   title: string,
   title: string,
   description: string,
   description: string,
-  bookScale: string
+  bookScale: string,
+  targetLanguage?: string,  // 🆕 用户选的目标语言
 ): string {
 ): string {
   const config = getScaleConfig(bookScale);
   const config = getScaleConfig(bookScale);
   const chapters = config?.chapters || 17;
   const chapters = config?.chapters || 17;
@@ -80,12 +81,20 @@ function buildPlanPrompt(
     ? descWordCount
     ? descWordCount
     : (config?.totalWords || 130000);
     : (config?.totalWords || 130000);
 
 
+  // 语言映射
+  const LANG_NAME: Record<string, string> = {
+    'zh-CMN': '中文(普通话)', 'en-US': '美式英语', 'en-GB': '英式英语',
+    'ja-JP': '日语', 'ko-KR': '韩语', 'id-ID': '印尼语',
+  };
+  const langName = LANG_NAME[targetLanguage || 'zh-CMN'] || '中文(普通话)';
+
   return `你是一位经验丰富的图书策划编辑。请对以下书籍做全面规划。
   return `你是一位经验丰富的图书策划编辑。请对以下书籍做全面规划。
 
 
 ## 书籍信息
 ## 书籍信息
 - 书名:${title}
 - 书名:${title}
 - 字数规模:约${totalWords}字,约${chapters}章
 - 字数规模:约${totalWords}字,约${chapters}章
 - 描述:${description}
 - 描述:${description}
+- **目标语言:${langName}**(全篇内容必须用${langName}撰写)
 
 
 ## 你的职责
 ## 你的职责
 像职业作家出书前一样,先对整本书做完整的规划分析。
 像职业作家出书前一样,先对整本书做完整的规划分析。
@@ -219,7 +228,7 @@ export async function planBookNode(state: typeof GraphState.State): Promise<Part
     const description = book?.description || '';
     const description = book?.description || '';
     const bookScale = state.bookScale || '1000';
     const bookScale = state.bookScale || '1000';
 
 
-    const prompt = buildPlanPrompt(title, description, bookScale);
+    const prompt = buildPlanPrompt(title, description, bookScale, (book as any)?.targetLanguage);
 
 
     const messages: ChatMessage[] = [
     const messages: ChatMessage[] = [
       { role: 'system', content: '你是一位资深的图书策划编辑。请严格按照要求的 JSON 格式输出规划结果,不要添加任何额外文字。' },
       { role: 'system', content: '你是一位资深的图书策划编辑。请严格按照要求的 JSON 格式输出规划结果,不要添加任何额外文字。' },

+ 93 - 0
server/src/services/db-schema-check.ts

@@ -0,0 +1,93 @@
+/**
+ * 数据库 Schema 自检
+ *
+ * 治根:之前反复出现 'column does not exist' 报错(最近一次持续 2 个月,2026-05 ~ 2026-07)。
+ * 根因是 Prisma client 的 metadata 跟数据库实际 schema 长期漂移,每次都靠手工 prisma generate 救场。
+ *
+ * 启动时强制自检:直接查 INFORMATION_SCHEMA 对比 Prisma 期望的列,不匹配立即拒绝启动。
+ *
+ * 设计取舍:
+ *  - 不依赖 prisma db pull(那需要 schema.prisma,dist 里不一定带)
+ *  - 不做"全表全列"比对(太重),只校验核心表的关键列(业务必读 + 历史上踩过坑的)
+ *  - mismatch 时 hard fail(throw),绝不静默放行
+ *  - 校验通过时只输出一行 ✅,不刷屏
+ */
+
+import { prisma } from '../models';
+
+/**
+ * 核心列清单:每项是 [表名, 列名, 类型, 业务含义]
+ *
+ * 添加规则:
+ *  - 只列"业务必读 + 历史上因漂移出过 production 事故"的列
+ *  - 新表/新列如果出错会立刻被业务请求打到(prisma 抛 P2022),不必进清单
+ *  - 类型变更(如 int → bigint)不会自动检测,只校验"是否存在"
+ */
+const CRITICAL_COLUMNS: Array<{ table: string; column: string; note: string }> = [
+  { table: 'BookChapter', column: 'parentId', note: '修复 #prisma-parentId-202605:客户端有但 DB 没有会爆' },
+  { table: 'BookChapter', column: 'audioUrl', note: '章节音频地址,OSS 化迁移后高频读' },
+  { table: 'BookChapter', column: 'genStage', note: '线性阶段机(见 linear-stage-state-model)' },
+  { table: 'BookChapter', column: 'lrcLyrics', note: '歌词时间轴(晚于主表新增列)' },
+  { table: 'Book', column: 'genStage', note: 'Book 阶段机' },
+  { table: 'Book', column: 'failedStage', note: '失败时记录哪一步崩的' },
+  { table: 'Book', column: 'errorMsg', note: '前端轮询会读,缺它会显示空白' },
+  { table: 'TtsTask', column: 'voiceId', note: '音频音色 ID' },
+  { table: 'AudioRecord', column: 'provider', note: 'edge/bailian 标识(最近有脏数据争议)' },
+  { table: 'AudioRecord', column: 'voiceId', note: '同上' },
+];
+
+export interface SchemaCheckResult {
+  ok: boolean;
+  checked: number;
+  missing: Array<{ table: string; column: string; note: string }>;
+  durationMs: number;
+}
+
+/**
+ * 启动时调用。失败抛错(让进程退出),成功返回 result。
+ */
+export async function checkDbSchema(): Promise<SchemaCheckResult> {
+  const t0 = Date.now();
+
+  // 一次性查所有需要的 (TABLE, COLUMN)
+  const rows = await prisma.$queryRawUnsafe<Array<{ TABLE_NAME: string; COLUMN_NAME: string }>>(
+    `SELECT TABLE_NAME, COLUMN_NAME
+     FROM INFORMATION_SCHEMA.COLUMNS
+     WHERE TABLE_SCHEMA = DATABASE()
+       AND (${CRITICAL_COLUMNS.map(() => '(TABLE_NAME = ? AND COLUMN_NAME = ?)').join(' OR ')})`,
+    ...CRITICAL_COLUMNS.flatMap(c => [c.table, c.column])
+  );
+
+  const present = new Set(rows.map(r => `${r.TABLE_NAME}.${r.COLUMN_NAME}`));
+  const missing = CRITICAL_COLUMNS.filter(c => !present.has(`${c.table}.${c.column}`));
+
+  const result: SchemaCheckResult = {
+    ok: missing.length === 0,
+    checked: CRITICAL_COLUMNS.length,
+    missing: missing.map(m => ({ table: m.table, column: m.column, note: m.note })),
+    durationMs: Date.now() - t0,
+  };
+
+  if (!result.ok) {
+    // 用醒目的格式输出,方便运维直接定位
+    console.error('');
+    console.error('╔══════════════════════════════════════════════════════════════╗');
+    console.error('║  🚨  DB Schema 自检失败  —  服务拒绝启动                      ║');
+    console.error('╚══════════════════════════════════════════════════════════════╝');
+    console.error(`[DbSchemaCheck] 缺 ${missing.length} 个核心列(共校验 ${CRITICAL_COLUMNS.length}):`);
+    for (const m of missing) {
+      console.error(`  ❌ ${m.table}.${m.column}   (${m.note})`);
+    }
+    console.error('');
+    console.error('修复步骤(在 server/ 目录下执行):');
+    console.error('  1. npx prisma db push --accept-data-loss   (把 schema 推到 DB)');
+    console.error('  2. pm2 restart server                       (让新 client 生效)');
+    console.error('');
+    console.error('如果 db push 报错说 "column already exists",说明 DB 比 schema 多列,');
+    console.error('需要手工 ALTER 或同步 schema.prisma 后再 push。');
+  } else {
+    console.log(`✅ [DbSchemaCheck] ${result.checked} 个核心列全部存在(${result.durationMs}ms)`);
+  }
+
+  return result;
+}