ソースを参照

feat(tts): 语言选择器 — 全链路语言路由

15 种语言/方言对应不同的 TTS vendor+model+voice。

## 新增
- server/src/config/alicloud-tts-lang-voice-matrix.json (15 lang_key × model × 音色矩阵)
- server/src/services/tts-lang-router.service.ts (pickTtsVendor/getVoicesByLang/languages API)
- my-uniapp-vue3/src/utils/languages.ts (前端语言选择器数据源)
- deploy-package/scripts/build-lang-matrix-json.js (CosyVoice 音色 → lang_key 编译)

## 后端改动
- prisma/schema.prisma: Book.targetLanguage / TtsTask.{targetLanguage,preferredVendor,preferredModel}
- tts.controller.ts: GET /api/tts/languages + GET /api/tts/voices-by-lang + /generate 路由接 language
- tts.service.ts: requestTtsGeneration +options.language 透传
- book-generator.store.ts: create() + generateChapterAudioById() 写语言字段
- langgraph-controller.ts: POST /books 接 language,透传给 bookStore.create()
- book-recovery-scanner.ts: 补 select 进 totalChapters 字段
- package.json build 脚本加 matrix JSON 拷贝

## 前端改动
- create.vue: 新增语言 chip grid(15 项)+ languages.ts 加载
- interactive.vue: 同上,payload 传 language
- languages.ts: 本地 fallback + fetchLanguagesFromServer()

## 路由示例
- zh-CMN → bailian/cosyvoice-v3-flash/longanhuan_v3
- zh-YUE → bailian/cosyvoice-v3-flash/longanyue
- en-US  → bailian/cosyvoice-v3-flash/longava
- ja-JP  → bailian/cosyvoice-v3-flash/loongtomoka
MyFramework User 1 ヶ月 前
コミット
92e2a32d62

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

@@ -0,0 +1,239 @@
+#!/usr/bin/env node
+/**
+ * build-lang-matrix-json.js — 把 alicloud-voices-matrix.json 转成 LANG_ROUTE 路由表
+ */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+// ── 1. 规范化语言字符串 ────────────────────────────────────
+function parseSimpleLang(p) {
+  if (!p) return null;
+  p = p.trim();
+  if (!p) return null;
+  if (p === '中文' || p === '普通话') return { base: '中文', dialect: '普通话' };
+  if (p === '日语') return { base: '日语', dialect: '通用' };
+  if (p === '韩语') return { base: '韩语', dialect: '通用' };
+  if (p === '印尼语') return { base: '印尼语', dialect: '通用' };
+  if (p === '美语' || p === '美式英语' || p === '美式英文' || p === '英语(美式)') return { base: '英语', dialect: '美式' };
+  if (p === '英式英语' || p === '英式英文' || p === '英语(英式)') return { base: '英语', dialect: '英式' };
+  if (p === '英语') return { base: '英语', dialect: '通用' };
+  // 中文方言裸词
+  const DIALECT = {
+    '广东话': '粤语', '粤语': '粤语',
+    '闽南话': '闽南', '闽南': '闽南',
+    '东北话': '东北', '东北口音': '东北', '东北': '东北',
+    '四川话': '四川', '四川': '四川',
+    '山东话': '山东', '山东': '山东',
+    '河南话': '河南', '河南': '河南',
+    '湖南话': '湖南', '湖南': '湖南',
+    '陕西话': '陕西', '陕西': '陕西',
+    '安徽话': '安徽', '安徽': '安徽',
+  };
+  if (DIALECT[p]) return { base: '中文', dialect: DIALECT[p] };
+  return null;
+}
+
+// 用 regex 全局匹配 "{base}(dialect1、dialect2)" 或 "{base}" 这两种 base
+// 支持的全角/半角括号。注意 raw 里用的是 "中文" "英文" 不是 "英语"
+const LANGBASE_GROUP_RE = /(中文|英文|日语|韩语|印尼语)[\((]([^\))]+)[\))]/g;
+// 裸 base(不在 group 里的:纯 "英文"、"美式英语" 等)
+const LANGBASE_BARE_RE = /(中文|英文|日语|韩语|印尼语|普通话|中文东北口音|美式英语|英式英语|美式英文|英式英文)(?=[\s,,、]|$)/g;
+const DIALECT_SEP_GLOBAL = /[、,,]/g;
+
+function normalizeLangString(s) {
+  if (!s) return [];
+  const cleaned = s.replace(/\s+/g, ' ').trim();
+  const result = [];
+  const seen = new Set();
+  // 1. 先匹配 "base(dialect、dialect)" 这种 group
+  const groupMatches = [...cleaned.matchAll(LANGBASE_GROUP_RE)];
+  for (const m of groupMatches) {
+    const base = m[1];
+    const dialectText = m[2];
+    for (const d of dialectText.split(DIALECT_SEP_GLOBAL)) {
+      const trimmed = d.trim();
+      if (!trimmed) continue;
+      const lang = dialectToLang(base, trimmed);
+      const key = `${lang.base}-${lang.dialect}`;
+      if (!seen.has(key)) { seen.add(key); result.push(lang); }
+    }
+  }
+  // 2. 再匹配剩余的纯 base(可能紧跟 group)
+  let stripped = cleaned;
+  for (const m of groupMatches) stripped = stripped.replace(m[0], ' ');
+  const bareMatches = [...stripped.matchAll(LANGBASE_BARE_RE)];
+  for (const m of bareMatches) {
+    const base = m[1];
+    const lang = baseToLang(base);
+    const key = `${lang.base}-${lang.dialect}`;
+    if (!seen.has(key)) { seen.add(key); result.push(lang); }
+  }
+  return result;
+}
+
+// 方言标准名(支持 raw 里的所有写法)
+const DIALECT_ALIAS = {
+  '广东话': '粤语',
+  '广东': '粤语',
+  '东北话': '东北',
+  '东北': '东北',
+  '东北口音': '东北',
+  '四川话': '四川',
+  '四川': '四川',
+  '山东话': '山东',
+  '山东': '山东',
+  '河南话': '河南',
+  '河南': '河南',
+  '湖南话': '湖南',
+  '湖南': '湖南',
+  '陕西话': '陕西',
+  '陕西': '陕西',
+  '安徽话': '安徽',
+  '安徽': '安徽',
+  '闽南话': '闽南',
+  '闽南': '闽南',
+  '普通话': '普通话',
+};
+
+function dialectToLang(base, dialect) {
+  if (base === '中文') {
+    const d = DIALECT_ALIAS[dialect] || dialect;
+    return { base: '中文', dialect: d };
+  }
+  if (base === '英语') {
+    const d = DIALECT_ALIAS[dialect] || dialect;
+    return { base: '英语', dialect: d || '通用' };
+  }
+  return { base, dialect: dialect || '通用' };
+}
+
+function baseToLang(base) {
+  if (base === '普通话') return { base: '中文', dialect: '普通话' };
+  if (base === '中文东北口音') return { base: '中文', dialect: '东北' };
+  if (['美式英语', '美式英文'].includes(base)) return { base: '英语', dialect: '美式' };
+  if (['英式英语', '英式英文'].includes(base)) return { base: '英语', dialect: '英式' };
+  if (base === '中文') return { base: '中文', dialect: '普通话' };
+  if (base === '英文') return { base: '英语', dialect: '通用' };
+  return { base, dialect: '通用' };
+}
+
+// ── 2. 标准化 key: 中文(粤) → "zh-YUE" ────────────────────────
+const ZH_DIALECT_TO_ISO = {
+  '普通话': 'CMN', '粤语': 'YUE', '东北': 'NEN', '四川': 'SCN',
+  '山东': 'SHD', '河南': 'HEN', '湖南': 'HNN', '陕西': 'SHX',
+  '安徽': 'AHM', '闽南': 'MNN',
+};
+
+function langToKey(lang) {
+  const { base, dialect } = lang;
+  if (base === '中文') {
+    const k = ZH_DIALECT_TO_ISO[dialect];
+    return k ? `zh-${k}` : 'zh-MISC';
+  }
+  if (base === '英语') {
+    if (dialect === '美式') return 'en-US';
+    if (dialect === '英式') return 'en-GB';
+    return 'en-MISC';
+  }
+  if (base === '日语') return 'ja-JP';
+  if (base === '韩语') return 'ko-KR';
+  if (base === '印尼语') return 'id-ID';
+  // raw 字段作为兜底
+  return `${base.replace(/[^a-z0-9]/gi, '').toLowerCase() || 'misc'}-MISC`;
+}
+
+function langLabel(key, base, dialect) {
+  if (base === '中文') return '中文(' + (dialect || '普通话') + ')';
+  if (base === '英语') return '英语' + (dialect && dialect !== '通用' ? '(' + dialect + ')' : '');
+  return base || key;
+}
+
+// ── 3. 推荐音色(从 docs/alicloud-tts-lang-voice-matrix.md §三 手动权威) ───────
+const RECOMMENDED = {
+  'zh-CMN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '欢脱元气女,支持 9 种方言' },
+  'zh-YUE':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanyue',     desc: '粤语标准女声' },
+  'zh-NEN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '东北话 → longanhuan_v3 兜底' },
+  'zh-SCN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '四川话兜底' },
+  'zh-SHD':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '山东话兜底' },
+  'zh-HEN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '河南话兜底' },
+  'zh-HNN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '湖南话兜底' },
+  'zh-SHX':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longshange',    desc: '陕西话专用户(陕哥)' },
+  'zh-AHM':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanhuan_v3', desc: '安徽话 → longanhuan_v3 兜底' },
+  'zh-MNN':  { vendor: 'bailian', model: 'cosyvoice-v3-flash', voice: 'longanmin',     desc: '闽南话专用户' },
+  '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 ─────────────────────────────
+// 注: cosyvoice-v2 / v1 已淘汰,只保留 v3-flash / v3-plus / Qwen-TTS
+const DEPRECATED_MODELS = new Set(['cosyvoice-v2', 'cosyvoice-v1']);
+
+const rawPath = process.argv[2] || path.join(os.tmpdir(), 'alicloud-voices-matrix.json');
+const raw = JSON.parse(fs.readFileSync(rawPath, 'utf8'));
+
+const langSupported = new Map();
+const langVoices = new Map();
+
+for (const [model, voices] of Object.entries(raw)) {
+  if (DEPRECATED_MODELS.has(model)) continue;  // v2/v1 已淘汰,跳过
+  for (const v of voices) {
+    const langs = normalizeLangString(v.lang);
+    for (const lang of langs) {
+      const key = langToKey(lang);
+      if (!langSupported.has(key)) langSupported.set(key, new Set());
+      langSupported.get(key).add(model);
+      if (!langVoices.has(key)) langVoices.set(key, []);
+      langVoices.get(key).push({
+        model, voice_id: v.voice_id, name: v.name, desc: v.desc, lang,
+      });
+    }
+  }
+}
+
+// ── 5. 输出 JSON ─────────────────────────────────────────────
+const out = {
+  version: '2026-07-12',
+  note: '由 scripts/build-lang-matrix-json.js 从 alicloud 官方页面抓取数据后生成。不要手编。',
+  recommended: Object.keys(RECOMMENDED),
+  languages: {},
+};
+
+let totalKeys = 0;
+for (const [key, modelSet] of [...langSupported.entries()].sort()) {
+  totalKeys++;
+  // 取代表性 lang 用于 label
+  const sample = langVoices.get(key)[0];
+  const base = sample?.lang?.base || '中文';
+  const dialect = sample?.lang?.dialect || '普通话';
+  out.languages[key] = {
+    label: langLabel(key, base, dialect),
+    base,
+    dialect,
+    supportedModels: [...modelSet].sort(),
+    voiceCount: (langVoices.get(key) || []).length,
+    recommended: RECOMMENDED[key] || null,
+    fallbackChain: RECOMMENDED[key]
+      ? [RECOMMENDED[key].voice, 'longanhuan_v3']
+      : ['longanhuan_v3'],
+    sampleVoices: (langVoices.get(key) || []).slice(0, 30).map(v => ({
+      voice_id: v.voice_id, name: v.name, model: v.model,
+    })),
+  };
+}
+
+const outPath = process.argv[3] || path.join(__dirname, '..', '..', 'server', 'src', 'config', 'alicloud-tts-lang-voice-matrix.json');
+fs.mkdirSync(path.dirname(outPath), { recursive: true });
+fs.writeFileSync(outPath, JSON.stringify(out, null, 2), 'utf8');
+console.error(`写入 ${outPath}`);
+console.error(`共 ${totalKeys} 个 lang_key,${Object.keys(RECOMMENDED).length} 个推荐`);
+console.error();
+console.error('推荐列表 (UI 默认显示):');
+for (const k of out.recommended) {
+  const l = out.languages[k];
+  if (!l) { console.error(`  ${k}: (not in matrix, will fallback)`); continue; }
+  console.error(`  ${k}: ${l.label} (${l.voiceCount} voices, models: ${l.supportedModels.join(',')})`);
+}

+ 10 - 0
deploy-prod.sh

@@ -133,6 +133,16 @@ cp $REMOTE_PATH/server/.env.production $REMOTE_PATH/server/.env
 echo "生成 Prisma Client..."
 npx prisma generate
 
+echo "同步数据库 Schema (prisma db push)..."
+# 治根:每次部署把 schema 推到 DB,避免 client vs DB 长期漂移导致运行时 'column does not exist'
+# --accept-data-loss 允许破坏性变更(如删列);生产如有重要数据请先备份再改破坏性字段
+npx prisma db push --accept-data-loss --skip-generate
+DB_PUSH_EXIT=$?
+if [ $DB_PUSH_EXIT -ne 0 ]; then
+    echo "❌ prisma db push 失败 (exit=$DB_PUSH_EXIT),部署中止"
+    exit $DB_PUSH_EXIT
+fi
+
 echo "清理临时文件..."
 rm -rf $TEMP_DIR
 

+ 79 - 1
my-uniapp-vue3/src/pages/book-generator/create.vue

@@ -231,7 +231,29 @@
         </view>
       </view>
 
-      <!-- ===== 区域 5:声音速度 ===== -->
+      <!-- ===== 区域 5:目标语言(控制 TTS 音色选哪种方言/外语) ===== -->
+      <view class="card" id="section-language">
+        <view class="section-label">
+          <text class="label-text">🌐 目标语言</text>
+        </view>
+        <view class="lang-display">
+          <text class="lang-current">{{ currentLangLabel }}</text>
+          <text class="lang-hint">选这个决定 TTS 用什么音色</text>
+        </view>
+        <view class="lang-chip-grid">
+          <view
+            v-for="lang in languages"
+            :key="lang.key"
+            class="lang-chip"
+            :class="{ 'lang-chip-active': newBook.language === lang.key }"
+            @click="newBook.language = lang.key"
+          >
+            <text class="lang-chip-label">{{ lang.label }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- ===== 区域 6:声音速度 ===== -->
       <view class="card" id="section-speed">
         <view class="section-label">
           <text class="label-text">语速</text>
@@ -386,6 +408,12 @@ import { onLoad } from '@dcloudio/uni-app';
 import * as api from '../../utils/book-generator-api';
 import { getApiBaseUrl } from '../../utils/config';
 import CustomTabBar from '../../components/CustomTabBar.vue';
+import {
+  fetchLanguagesFromServer,
+  getLangLabel,
+  LANGUAGES_FALLBACK,
+  type LangDef,
+} from '../../utils/languages';
 
 const BASE_URL = getApiBaseUrl();
 
@@ -393,6 +421,13 @@ const BASE_URL = getApiBaseUrl();
 const creating = ref(false);
 const showAdvancedOptions = ref(false);
 const showAllScales = ref(false);
+
+// 🆕 语言选择器(15 个推荐语种 + 后端拉取覆盖)
+const languages = ref<LangDef[]>(LANGUAGES_FALLBACK);
+const currentLangLabel = computed(() => getLangLabel(newBook.value.language, languages.value));
+onMounted(async () => {
+  languages.value = await fetchLanguagesFromServer();
+});
 const isRecommending = ref(false);
 const attemptedCreate = ref(false);
 const isEditMode = ref(false);
@@ -417,6 +452,7 @@ const newBook = ref({
   bookType: 'auto',
   outlineLevel: 'auto',
   voiceSpeed: 1.0,
+  language: 'zh-CMN',  // 🆕 目标语言(BCP-47),默认普通话
 });
 
 // 示例提示词
@@ -838,6 +874,7 @@ async function createNewBook() {
       bookType,
       genLevel,
       voiceSpeed: newBook.value.voiceSpeed !== 1.0 ? newBook.value.voiceSpeed : undefined,
+      language: newBook.value.language,  // 🆕 目标语言(BCP-47),后端用 pickTtsVendor() 路由
       autoGenerateContent: autoGenerateContent.value,
       autoGenerateAudio: autoGenerateAudio.value,
     });
@@ -1144,6 +1181,47 @@ onLoad(async (query: any) => {
   margin-top: 4rpx;
 }
 
+/* ========== 语言选择器 ========== */
+.lang-display {
+  display: flex;
+  align-items: baseline;
+  gap: 12rpx;
+  margin-bottom: 16rpx;
+}
+.lang-current {
+  font-size: 32rpx;
+  font-weight: 700;
+  color: #4f46e5;
+}
+.lang-hint {
+  font-size: 22rpx;
+  color: #9ca3af;
+}
+.lang-chip-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12rpx;
+}
+.lang-chip {
+  padding: 14rpx 20rpx;
+  background: #f3f4f6;
+  border: 2rpx solid transparent;
+  border-radius: 24rpx;
+  font-size: 26rpx;
+  color: #374151;
+  transition: all 0.15s;
+}
+.lang-chip-active {
+  background: #eef2ff;
+  border-color: #4f46e5;
+  color: #4f46e5;
+  font-weight: 600;
+}
+.lang-chip-label {
+  font-size: 26rpx;
+  line-height: 1.2;
+}
+
 /* ========== 自动生成开关 ========== */
 .switch-row {
   display: flex; align-items: center; justify-content: space-between;

+ 46 - 0
my-uniapp-vue3/src/pages/book-generator/interactive.vue

@@ -112,6 +112,26 @@
           </view>
         </view>
 
+        <!-- 🆕 目标语言(控制 TTS 音色选哪种方言/外语) -->
+        <view class="lang-section">
+          <text class="lang-section-title">🌐 目标语言</text>
+          <view class="lang-display">
+            <text class="lang-current">{{ currentLangLabel }}</text>
+            <text class="lang-hint">选这个决定 TTS 用什么音色</text>
+          </view>
+          <view class="lang-chip-grid">
+            <view
+              v-for="lang in languages"
+              :key="lang.key"
+              class="lang-chip"
+              :class="{ 'lang-chip-active': form.language === lang.key }"
+              @click="form.language = lang.key"
+            >
+              <text class="lang-chip-label">{{ lang.label }}</text>
+            </view>
+          </view>
+        </view>
+
         <!-- 预估 -->
         <view v-if="bookEstimate" class="estimate-card">
           <view class="estimate-row">
@@ -400,6 +420,12 @@
 import { ref, computed, onMounted } from 'vue';
 import * as api from '../../utils/book-generator-api';
 import { getApiBaseUrl } from '../../utils/config';
+import {
+  fetchLanguagesFromServer,
+  getLangLabel,
+  LANGUAGES_FALLBACK,
+  type LangDef,
+} from '../../utils/languages';
 
 const BASE_URL = getApiBaseUrl();
 
@@ -421,6 +447,7 @@ const form = ref({
   bookType: 'auto',
   outlineLevel: 'auto',
   targetAudience: '',
+  language: 'zh-CMN',  // 🆕 目标语言(BCP-47),默认普通话
 });
 const bookId = ref('');
 const bookEstimate = ref<{ words: { min: number; max: number }; audioMinutes: { min: number; max: number }; estimatedChapters: number } | null>(null);
@@ -468,6 +495,12 @@ const canProceed = computed(() => form.value.title.trim() && form.value.descript
 
 // AI智能推荐
 const recommendLoading = ref(false);
+// 🆕 语言选择器(15 个推荐语种 + 后端拉取覆盖)
+const languages = ref<LangDef[]>(LANGUAGES_FALLBACK);
+const currentLangLabel = computed(() => getLangLabel(form.value.language, languages.value));
+onMounted(async () => {
+  languages.value = await fetchLanguagesFromServer();
+});
 
 async function doSmartRecommend() {
   if (!form.value.description.trim() || recommendLoading.value) return;
@@ -619,6 +652,7 @@ async function doCreateBook() {
       bookScale: form.value.bookScale,
       bookType,
       genLevel,
+      language: form.value.language,  // 🆕 目标语言
     });
     bookId.value = book.id;
     currentStep.value = 1;
@@ -954,6 +988,18 @@ onMounted(() => {
 .scale-chip:active { transform: scale(0.96); }
 .scale-chip.active { background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%); color: white; }
 .scale-chip.active .scale-desc { color: rgba(255,255,255,0.7); }
+
+/* 🆕 语言选择器 */
+.lang-section { margin-top: 24rpx; }
+.lang-section-title { font-size: 30rpx; font-weight: 600; color: #1f2937; margin-bottom: 16rpx; }
+.lang-display { display: flex; align-items: baseline; gap: 12rpx; margin-bottom: 16rpx; }
+.lang-current { font-size: 32rpx; font-weight: 700; color: #4f46e5; }
+.lang-hint { font-size: 22rpx; color: #9ca3af; }
+.lang-chip-grid { display: flex; flex-wrap: wrap; gap: 12rpx; }
+.lang-chip { padding: 14rpx 20rpx; background: #f3f4f6; border: 2rpx solid transparent; border-radius: 24rpx; }
+.lang-chip-active { background: #eef2ff; border-color: #4f46e5; }
+.lang-chip-label { font-size: 26rpx; color: #374151; line-height: 1.2; }
+.lang-chip-active .lang-chip-label { color: #4f46e5; font-weight: 600; }
 .scale-label { font-size: 24rpx; font-weight: 600; }
 .scale-desc { font-size: 20rpx; color: #9ca3af; }
 

+ 54 - 0
my-uniapp-vue3/src/utils/languages.ts

@@ -0,0 +1,54 @@
+// src/utils/languages.ts — 前端"语言选择器"数据源
+// 默认本地常量,启动时拉服务端 /api/tts/languages 覆盖(覆盖失败用本地)
+
+export interface LangDef {
+  key: string;          // BCP-47 风格,如 zh-CMN / en-US
+  label: string;        // UI 显示
+  recommended: boolean;  // 是否进推荐列表
+  vendorHint?: string;  // 后端返回的 vendor+model 提示
+}
+
+// 本地默认(服务端不可达时用)
+export const LANGUAGES_FALLBACK: LangDef[] = [
+  { 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 },
+];
+
+// 拉服务端最新列表
+export async function fetchLanguagesFromServer(): Promise<LangDef[]> {
+  // #ifdef MP-WEIXIN
+  //   // ...
+  // #endif
+  try {
+    // @ts-ignore
+    const api = (typeof uni !== 'undefined' && uni.request) ? uni : require('@dcloudio/uni-app').default;
+    const base = (typeof __GLOBAL__ !== 'undefined' && __GLOBAL__.API_BASE) || '';
+    const res: any = await new Promise((resolve, reject) => {
+      // @ts-ignore
+      api.request({ url: `${base}/api/tts/languages`, method: 'GET', success: resolve, fail: reject });
+    });
+    const langs = (res?.data?.data?.languages || []) as LangDef[];
+    return langs.length > 0 ? langs : LANGUAGES_FALLBACK;
+  } catch (e) {
+    console.warn('[languages] 拉服务端失败,使用本地 fallback:', e);
+    return LANGUAGES_FALLBACK;
+  }
+}
+
+// 简单按 key 找 label
+export function getLangLabel(key: string, list: LangDef[]): string {
+  return list.find(l => l.key === key)?.label || '普通话';
+}

+ 1 - 1
server/package.json

@@ -5,7 +5,7 @@
   "main": "dist/app.js",
   "scripts": {
     "dev": "tsx watch src/app.ts",
-    "build": "tsc && node -e \"require('fs').copyFileSync('src/config/models.json','dist/config/models.json')\"",
+    "build": "tsc && node -e \"const f=require('fs');f.copyFileSync('src/config/models.json','dist/config/models.json');f.copyFileSync('src/config/alicloud-tts-lang-voice-matrix.json','dist/config/alicloud-tts-lang-voice-matrix.json')\"",
     "start": "node dist/app.js",
     "test:unit": "vitest run --config vitest.config.mts",
     "test:unit:watch": "vitest --config vitest.config.mts",

+ 101 - 89
server/prisma/schema.prisma

@@ -36,8 +36,8 @@ model User {
   tokenUsages           TokenUsage[]
   preferences           UserPreference?
   shareRecords          ShareRecord[]
-  invitesSent           InviteRecord[]   @relation("InviterRelation")
-  invitesReceived       InviteRecord[]   @relation("InviteeRelation")
+  invitesSent           InviteRecord[]  @relation("InviterRelation")
+  invitesReceived       InviteRecord[]  @relation("InviteeRelation")
 
   @@index([phone])
   @@index([openid])
@@ -137,38 +137,41 @@ model Notification {
 }
 
 model Book {
-  id             Int            @id @default(autoincrement())
-  userId         Int?
-  title          String
-  subtitle       String?
-  description    String         @db.Text
-  coverUrl       String?
-  targetAudience String         @default("通用")
-  style          String         @default("专业严谨")
-  bookScale      String         @default("1000")
-  totalChapters  Int            @default(10)
-  estimatedWords Int            @default(0)
-  progress       Int            @default(0)
-  isPublished    Boolean        @default(false)
-  outlineJson    String?        @db.LongText
-  foreword       String?        @db.Text
-  afterword      String?        @db.Text
-  errorMsg       String?        @db.Text
-  voiceSpeed     Float          @default(1.0)
-  createdAt      DateTime       @default(now())
-  updatedAt      DateTime       @updatedAt
-  failedStage    String?        @db.VarChar(30)
-  genStage       String         @default("draft")
-  bookAnalysis   String?        @db.Text
-  autoGenerateContent Boolean   @default(true)
-  autoGenerateAudio   Boolean   @default(true)
+  id                  Int            @id @default(autoincrement())
+  userId              Int?
+  title               String
+  subtitle            String?
+  description         String         @db.Text
+  coverUrl            String?
+  targetAudience      String         @default("通用")
+  style               String         @default("专业严谨")
+  bookScale           String         @default("1000")
+  totalChapters       Int            @default(10)
+  estimatedWords      Int            @default(0)
+  progress            Int            @default(0)
+  isPublished         Boolean        @default(false)
+  outlineJson         String?        @db.LongText
+  foreword            String?        @db.Text
+  afterword           String?        @db.Text
+  errorMsg            String?        @db.Text
+  voiceSpeed          Float          @default(1.0)
+  // 目标语言: BCP-47 风格 key,如 zh-CMN / zh-YUE / en-US / ja-JP
+  // 老书(无此字段)按 zh-CMN 处理
+  targetLanguage      String         @default("zh-CMN") @db.VarChar(10)
+  createdAt           DateTime       @default(now())
+  updatedAt           DateTime       @updatedAt
+  failedStage         String?        @db.VarChar(30)
+  genStage            String         @default("draft")
+  bookAnalysis        String?        @db.Text
+  autoGenerateContent Boolean        @default(true)
+  autoGenerateAudio   Boolean        @default(true)
   // 非错误的报告字段(与 errorMsg 分离,避免前端误判为失败)
-  qualityReport   String?       @db.Text
-  continuityReport String?      @db.Text
-  rewriteReport   String?       @db.Text
-  chapters       BookChapter[]
-  favorites      Favorite[]
-  videoProjects  VideoProject[]
+  qualityReport       String?        @db.Text
+  continuityReport    String?        @db.Text
+  rewriteReport       String?        @db.Text
+  chapters            BookChapter[]
+  favorites           Favorite[]
+  videoProjects       VideoProject[]
 
   @@index([userId])
   @@index([createdAt])
@@ -335,16 +338,16 @@ model TokenUsage {
 }
 
 model TokenBalance {
-  id                Int       @id @default(autoincrement())
-  userId            Int       @unique
-  totalTokens       Int       @default(0)
-  usedTokens        Int       @default(0)    // 总消耗
-  packTokens        Int       @default(0)    // 积分包购买的Token总数
-  usedPackTokens    Int       @default(0)    // 积分包已消耗的Token数
-  resetDate         DateTime?
-  createdAt         DateTime  @default(now())
-  updatedAt         DateTime  @updatedAt
-  user              User      @relation(fields: [userId], references: [id])
+  id             Int       @id @default(autoincrement())
+  userId         Int       @unique
+  totalTokens    Int       @default(0)
+  usedTokens     Int       @default(0) // 总消耗
+  packTokens     Int       @default(0) // 积分包购买的Token总数
+  usedPackTokens Int       @default(0) // 积分包已消耗的Token数
+  resetDate      DateTime?
+  createdAt      DateTime  @default(now())
+  updatedAt      DateTime  @updatedAt
+  user           User      @relation(fields: [userId], references: [id])
 
   @@index([userId])
 }
@@ -370,23 +373,28 @@ model VideoMaterial {
 }
 
 model AudioRecord {
-  id            Int      @id @default(autoincrement())
-  userId        Int?
-  audioId       String   @unique
-  title         String   @default("未命名音频")
-  text          String?  @db.LongText
-  wordCount     Int      @default(0)
-  voiceId       String   @default("cherry")
-  voiceParams   String?
-  audioUrl      String?  @db.Text
-  audioDuration Int      @default(0)
-  audioSize     Int      @default(0)
-  status        String   @default("processing")
-  errorMsg      String?  @db.Text
-  createdAt     DateTime @default(now())
-  updatedAt     DateTime @updatedAt
-  bookId        Int?
-  provider      String?  // TTS 供应商: bailian / edge / mock
+  id              Int      @id @default(autoincrement())
+  userId          Int?
+  audioId         String   @unique
+  title           String   @default("未命名音频")
+  text            String?  @db.LongText
+  wordCount       Int      @default(0)
+  voiceId         String   @default("cherry")
+  voiceParams     String?
+  audioUrl        String?  @db.Text
+  audioDuration   Int      @default(0)
+  audioSize       Int      @default(0)
+  status          String   @default("processing")
+  errorMsg        String?  @db.Text
+  createdAt       DateTime @default(now())
+  updatedAt       DateTime @updatedAt
+  bookId          Int?
+  provider        String? // TTS 供应商: bailian / edge / mock
+  // 用户选的语言(B 系列"语言选择器"),诊断时一目了然
+  targetLanguage  String?  @db.VarChar(10)
+  // 入队时锁定的 vendor+model,避免 models.json 变更导致音色漂移
+  preferredVendor String?  @db.VarChar(20)
+  preferredModel  String?  @db.VarChar(50)
 
   @@index([userId])
   @@index([audioId])
@@ -394,26 +402,30 @@ model AudioRecord {
 }
 
 model TtsTask {
-  id            Int          @id @default(autoincrement())
-  taskType      String       @default("tts")    @db.VarChar(20)  // "tts" | "content"
-  chapterId     Int
-  bookId        Int?
-  userId        Int?
-  contentHash   String       @db.VarChar(64)
-  content       String?      @db.LongText
-  status        String       @default("pending")
-  audioUrl      String?      @db.Text
-  audioDuration Int          @default(0)
-  errorMsg      String?      @db.Text
-  retryCount    Int          @default(0)
-  maxRetries    Int          @default(3)
-  voiceId       String       @default("default")
-  voiceSpeed    Float        @default(1.0)
-  startedAt     DateTime?
-  completedAt   DateTime?
-  createdAt     DateTime     @default(now())
-  updatedAt     DateTime     @updatedAt
-  chapter       BookChapter  @relation(fields: [chapterId], references: [id], onDelete: Cascade)
+  id              Int         @id @default(autoincrement())
+  taskType        String      @default("tts") @db.VarChar(20) // "tts" | "content"
+  chapterId       Int
+  bookId          Int?
+  userId          Int?
+  contentHash     String      @db.VarChar(64)
+  content         String?     @db.LongText
+  status          String      @default("pending")
+  audioUrl        String?     @db.Text
+  audioDuration   Int         @default(0)
+  errorMsg        String?     @db.Text
+  retryCount      Int         @default(0)
+  maxRetries      Int         @default(3)
+  voiceId         String      @default("default")
+  voiceSpeed      Float       @default(1.0)
+  // 入队时锁定的语言/模型(由 pickTtsVendor() 路由结果固化)
+  targetLanguage  String?     @db.VarChar(10)
+  preferredVendor String?     @db.VarChar(20)
+  preferredModel  String?     @db.VarChar(50)
+  startedAt       DateTime?
+  completedAt     DateTime?
+  createdAt       DateTime    @default(now())
+  updatedAt       DateTime    @updatedAt
+  chapter         BookChapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
 
   @@index([taskType, status, createdAt])
   @@index([chapterId, contentHash])
@@ -422,18 +434,18 @@ model TtsTask {
 
 model AiCallLog {
   id            Int      @id @default(autoincrement())
-  callType      String   @db.VarChar(20)   // llm_chat | llm_tools | tts_synthesize | tts_create | tts_poll
-  provider      String   @db.VarChar(30)   // minimax | bailian | volcengine
-  model         String   @db.VarChar(50)   // MiniMax-M2.7 | cosyvoice-v3-flash | qwen3-tts-instruct-flash
+  callType      String   @db.VarChar(20) // llm_chat | llm_tools | tts_synthesize | tts_create | tts_poll
+  provider      String   @db.VarChar(30) // minimax | bailian | volcengine
+  model         String   @db.VarChar(50) // MiniMax-M2.7 | cosyvoice-v3-flash | qwen3-tts-instruct-flash
   userId        Int?
   bookId        Int?
   chapterId     Int?
-  textLen       Int      @default(0)       // 输入文本长度(字符数)
-  prompt        String?  @db.LongText       // 提示词内容(LLM only)
-  inputTokens   Int      @default(0)       // 输入 Token 数
-  outputTokens  Int      @default(0)       // 输出 Token 数
-  estimatedCost Float    @default(0)       // 估算成本(元)
-  duration      Int      @default(0)       // 耗时(ms)
+  textLen       Int      @default(0) // 输入文本长度(字符数)
+  prompt        String?  @db.LongText // 提示词内容(LLM only)
+  inputTokens   Int      @default(0) // 输入 Token 数
+  outputTokens  Int      @default(0) // 输出 Token 数
+  estimatedCost Float    @default(0) // 估算成本(元)
+  duration      Int      @default(0) // 耗时(ms)
   success       Boolean  @default(true)
   errorMsg      String?  @db.Text
   createdAt     DateTime @default(now())

+ 6 - 0
server/src/app.ts

@@ -27,6 +27,7 @@ import { startAudioScanner, stopAudioScanner } from './modules/book-generator/au
 import { startBookRecoveryScanner, stopBookRecoveryScanner } from './modules/book-generator/book-recovery-scanner';
 import { queueService } from './services/queue.service';
 import { validateModelsJsonStructure, printValidationReport } from './config/models-validator';
+import { checkDbSchema } from './services/db-schema-check';
 import { initSentry, sentryErrorHandler } from './services/sentry.service';
 import { xssProtection, sqlInjectionProtection } from './middleware/security';
 import { performanceMonitor, getMetrics } from './middleware/performance';
@@ -183,6 +184,11 @@ async function start() {
     await connectDatabase();
     console.log('✅ MySQL 连接成功');
 
+    // 1.1 数据库 Schema 自检(治根:防 Prisma client vs DB schema 漂移)
+    //     历史上曾因 'BookChapter.parentId does not exist' 卡住所有 outline 生成 2 个月
+    //     不通过 → 抛错让进程退出,绝不放行
+    await checkDbSchema();
+
     // 1.5. 校验 models.json 配置
     const modelIssues = validateModelsJsonStructure();
     if (modelIssues.length > 0) {

+ 835 - 0
server/src/config/alicloud-tts-lang-voice-matrix.json

@@ -0,0 +1,835 @@
+{
+  "version": "2026-07-12",
+  "note": "由 scripts/build-lang-matrix-json.js 从 alicloud 官方页面抓取数据后生成。不要手编。",
+  "recommended": [
+    "zh-CMN",
+    "zh-YUE",
+    "zh-NEN",
+    "zh-SCN",
+    "zh-SHD",
+    "zh-HEN",
+    "zh-HNN",
+    "zh-SHX",
+    "zh-AHM",
+    "zh-MNN",
+    "en-US",
+    "en-GB",
+    "ja-JP",
+    "ko-KR",
+    "id-ID"
+  ],
+  "languages": {
+    "en-GB": {
+      "label": "英语(英式)",
+      "base": "英语",
+      "dialect": "英式",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 4,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "loongluna",
+        "desc": "英语英伦少女"
+      },
+      "fallbackChain": [
+        "loongluna",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "loongemily_v3",
+          "name": "loongemily",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongeric_v3",
+          "name": "loongeric",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongluna_v3",
+          "name": "loongluna",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongluca_v3",
+          "name": "loongluca",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "en-MISC": {
+      "label": "英语",
+      "base": "英语",
+      "dialect": "通用",
+      "supportedModels": [
+        "cosyvoice-v3-flash",
+        "cosyvoice-v3-plus"
+      ],
+      "voiceCount": 68,
+      "recommended": null,
+      "fallbackChain": [
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "longanyang",
+          "name": "龙安洋",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanhuan_v3",
+          "name": "龙安欢(V3)",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanhuan",
+          "name": "龙安欢",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longhuhu_v3",
+          "name": "龙呼呼",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longpaopao_v3",
+          "name": "龙泡泡",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longjielidou_v3",
+          "name": "龙杰力豆",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longxian_v3",
+          "name": "龙仙",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longling_v3",
+          "name": "龙铃",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longshanshan_v3",
+          "name": "龙闪闪",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longniuniu_v3",
+          "name": "龙牛牛",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longjiaxin_v3",
+          "name": "龙嘉欣",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longjiayi_v3",
+          "name": "龙嘉怡",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanyue_v3",
+          "name": "龙安粤",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longlaotie_v3",
+          "name": "龙老铁",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longshange_v3",
+          "name": "龙陕哥",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanmin_v3",
+          "name": "龙安闽",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longfei_v3",
+          "name": "龙飞",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyingxiao_v3",
+          "name": "龙应笑",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyingxun_v3",
+          "name": "龙应询",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyingjing_v3",
+          "name": "龙应静",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyingling_v3",
+          "name": "龙应聆",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyingtao_v3",
+          "name": "龙应桃",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longxiaochun_v3",
+          "name": "龙小淳",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longxiaoxia_v3",
+          "name": "龙小夏",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyumi_v3",
+          "name": "YUMI",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanyun_v3",
+          "name": "龙安昀",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanwen_v3",
+          "name": "龙安温",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanli_v3",
+          "name": "龙安莉",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanlang_v3",
+          "name": "龙安朗",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyingmu_v3",
+          "name": "龙应沐",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "en-US": {
+      "label": "英语(美式)",
+      "base": "英语",
+      "dialect": "美式",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 10,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "longava",
+        "desc": "美式英语女声"
+      },
+      "fallbackChain": [
+        "longava",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "loongabby_v3",
+          "name": "loongabby",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongandy_v3",
+          "name": "loongandy",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongannie_v3",
+          "name": "loongannie",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongava_v3",
+          "name": "loongava",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongbeth_v3",
+          "name": "loongbeth",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongbetty_v3",
+          "name": "loongbetty",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongcally_v3",
+          "name": "loongcally",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongcindy_v3",
+          "name": "loongcindy",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongdavid_v3",
+          "name": "loongdavid",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongdonna_v3",
+          "name": "loongdonna",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "id-ID": {
+      "label": "印尼语",
+      "base": "印尼语",
+      "dialect": "通用",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 1,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "loongindah",
+        "desc": "印尼女声"
+      },
+      "fallbackChain": [
+        "loongindah",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "loongindah_v3",
+          "name": "loongindah",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "ja-JP": {
+      "label": "日语",
+      "base": "日语",
+      "dialect": "通用",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 5,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "loongtomoka",
+        "desc": "日语女声"
+      },
+      "fallbackChain": [
+        "loongtomoka",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "loongriko_v3",
+          "name": "Riko",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongtomoka_v3",
+          "name": "loongtomoka",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongtomoya_v3",
+          "name": "loongtomoya",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongyuuna_v3",
+          "name": "Yuuna",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongyuuma_v3",
+          "name": "Yuuma",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "ko-KR": {
+      "label": "韩语",
+      "base": "韩语",
+      "dialect": "通用",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 2,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "sohee",
+        "desc": "韩语女声"
+      },
+      "fallbackChain": [
+        "sohee",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "loongkyong_v3",
+          "name": "loongkyong",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "loongjihun_v3",
+          "name": "Jihun",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "zh-AHM": {
+      "label": "中文(安徽)",
+      "base": "中文",
+      "dialect": "安徽",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 1,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "longanhuan_v3",
+        "desc": "安徽话 → longanhuan_v3 兜底"
+      },
+      "fallbackChain": [
+        "longanhuan_v3",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "longanhuan_v3",
+          "name": "龙安欢(V3)",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "zh-CMN": {
+      "label": "中文(普通话)",
+      "base": "中文",
+      "dialect": "普通话",
+      "supportedModels": [
+        "cosyvoice-v3-flash",
+        "cosyvoice-v3-plus"
+      ],
+      "voiceCount": 62,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "longanhuan_v3",
+        "desc": "欢脱元气女,支持 9 种方言"
+      },
+      "fallbackChain": [
+        "longanhuan_v3",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "longanyang",
+          "name": "龙安洋",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanhuan_v3",
+          "name": "龙安欢(V3)",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanhuan",
+          "name": "龙安欢",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longhuhu_v3",
+          "name": "龙呼呼",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longpaopao_v3",
+          "name": "龙泡泡",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longjielidou_v3",
+          "name": "龙杰力豆",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longxian_v3",
+          "name": "龙仙",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longling_v3",
+          "name": "龙铃",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longshanshan_v3",
+          "name": "龙闪闪",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longniuniu_v3",
+          "name": "龙牛牛",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longfei_v3",
+          "name": "龙飞",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyingxiao_v3",
+          "name": "龙应笑",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyingxun_v3",
+          "name": "龙应询",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyingjing_v3",
+          "name": "龙应静",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyingling_v3",
+          "name": "龙应聆",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyingtao_v3",
+          "name": "龙应桃",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longxiaochun_v3",
+          "name": "龙小淳",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longxiaoxia_v3",
+          "name": "龙小夏",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyumi_v3",
+          "name": "YUMI",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanyun_v3",
+          "name": "龙安昀",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanwen_v3",
+          "name": "龙安温",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanli_v3",
+          "name": "龙安莉",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanlang_v3",
+          "name": "龙安朗",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyingmu_v3",
+          "name": "龙应沐",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longantai_v3",
+          "name": "龙安台",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longhua_v3",
+          "name": "龙华",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longcheng_v3",
+          "name": "龙橙",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longze_v3",
+          "name": "龙泽",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longzhe_v3",
+          "name": "龙哲",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longyan_v3",
+          "name": "龙颜",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "zh-HEN": {
+      "label": "中文(河南)",
+      "base": "中文",
+      "dialect": "河南",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 1,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "longanhuan_v3",
+        "desc": "河南话兜底"
+      },
+      "fallbackChain": [
+        "longanhuan_v3",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "longanhuan_v3",
+          "name": "龙安欢(V3)",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "zh-HNN": {
+      "label": "中文(湖南)",
+      "base": "中文",
+      "dialect": "湖南",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 1,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "longanhuan_v3",
+        "desc": "湖南话兜底"
+      },
+      "fallbackChain": [
+        "longanhuan_v3",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "longanhuan_v3",
+          "name": "龙安欢(V3)",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "zh-MNN": {
+      "label": "中文(闽南)",
+      "base": "中文",
+      "dialect": "闽南",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 1,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "longanmin",
+        "desc": "闽南话专用户"
+      },
+      "fallbackChain": [
+        "longanmin",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "longanmin_v3",
+          "name": "龙安闽",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "zh-NEN": {
+      "label": "中文(东北)",
+      "base": "中文",
+      "dialect": "东北",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 2,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "longanhuan_v3",
+        "desc": "东北话 → longanhuan_v3 兜底"
+      },
+      "fallbackChain": [
+        "longanhuan_v3",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "longanhuan_v3",
+          "name": "龙安欢(V3)",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longlaotie_v3",
+          "name": "龙老铁",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "zh-SCN": {
+      "label": "中文(四川)",
+      "base": "中文",
+      "dialect": "四川",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 1,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "longanhuan_v3",
+        "desc": "四川话兜底"
+      },
+      "fallbackChain": [
+        "longanhuan_v3",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "longanhuan_v3",
+          "name": "龙安欢(V3)",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "zh-SHD": {
+      "label": "中文(山东)",
+      "base": "中文",
+      "dialect": "山东",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 1,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "longanhuan_v3",
+        "desc": "山东话兜底"
+      },
+      "fallbackChain": [
+        "longanhuan_v3",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "longanhuan_v3",
+          "name": "龙安欢(V3)",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "zh-SHX": {
+      "label": "中文(陕西)",
+      "base": "中文",
+      "dialect": "陕西",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 2,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "longshange",
+        "desc": "陕西话专用户(陕哥)"
+      },
+      "fallbackChain": [
+        "longshange",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "longanhuan_v3",
+          "name": "龙安欢(V3)",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longshange_v3",
+          "name": "龙陕哥",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    },
+    "zh-YUE": {
+      "label": "中文(粤语)",
+      "base": "中文",
+      "dialect": "粤语",
+      "supportedModels": [
+        "cosyvoice-v3-flash"
+      ],
+      "voiceCount": 4,
+      "recommended": {
+        "vendor": "bailian",
+        "model": "cosyvoice-v3-flash",
+        "voice": "longanyue",
+        "desc": "粤语标准女声"
+      },
+      "fallbackChain": [
+        "longanyue",
+        "longanhuan_v3"
+      ],
+      "sampleVoices": [
+        {
+          "voice_id": "longanhuan_v3",
+          "name": "龙安欢(V3)",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longjiaxin_v3",
+          "name": "龙嘉欣",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longjiayi_v3",
+          "name": "龙嘉怡",
+          "model": "cosyvoice-v3-flash"
+        },
+        {
+          "voice_id": "longanyue_v3",
+          "name": "龙安粤",
+          "model": "cosyvoice-v3-flash"
+        }
+      ]
+    }
+  }
+}

+ 27 - 5
server/src/modules/book-generator/book-generator.store.ts

@@ -16,6 +16,7 @@ import { advanceChapter, regenerateChapter, safeTransitionChapter } from './stag
 import { mergeChapterAudios } from '../player/player.service';
 import { consumeAudioMinutes, canUseTtsProvider } from '../subscription/subscription.service';
 import { runWithContext } from '../../services/llm-context';
+import { pickTtsVendor } from '../../services/tts-lang-router.service';
 
 // 注意:语速决策已上移到 langgraph-controller.ts 的「创建书籍」阶段
 // 此处不再做内容检测,voiceSpeed 字段即为最终语速
@@ -512,6 +513,7 @@ export class BookStore {
     subtitle?: string;
     description: string;
     targetAudience?: string;
+    targetLanguage?: string;   // 🆕 目标语言(BCP-47),默认 zh-CMN
     style?: string;
     bookScale?: string;
     totalChapters?: number;
@@ -527,6 +529,7 @@ export class BookStore {
         subtitle: data.subtitle,
         description: data.description,
         targetAudience: data.targetAudience || '通用',
+        targetLanguage: data.targetLanguage || 'zh-CMN',
         style: data.style || '专业严谨',
         bookScale: data.bookScale || '1000',
         totalChapters: data.totalChapters ?? 10,
@@ -1121,7 +1124,12 @@ export class BookStore {
    *   - 已在 audio_generating 的章节不会被回退
    *   - 无递归调用,重试由队列处理器平铺循环控制
    */
-  async generateChapterAudioById(chapterId: number, userId?: number, voiceId?: string): Promise<{
+  async generateChapterAudioById(
+    chapterId: number,
+    userId?: number,
+    voiceId?: string,
+    options?: { language?: string },
+  ): Promise<{
     audioUrl: string;
   } | null> {
     // ===== 步骤 1:读取章节状态 =====
@@ -1224,11 +1232,18 @@ export class BookStore {
     let task: any;
     // 获取书籍的voiceSpeed
     const bookVoiceSpeed = chapterBefore.book?.voiceSpeed ?? 1.0;
+    // 🆕 决定 target language + preferred vendor/model(固化到 TtsTask,防漂移)
+    const targetLanguage = options?.language || chapterBefore.book?.targetLanguage || 'zh-CMN';
+    const langRoute = pickTtsVendor(targetLanguage);
+    const preferredVendor = langRoute?.vendor || null;
+    const preferredModel = langRoute?.model || null;
+    // 路由给的 voice 优先(用户没传 voiceId 时)
+    const routedVoiceId = langRoute?.voice || null;
     if (existingFailed) {
       // 复用已有 failed 任务,重置为 pending,但累加 retryCount(保留历史重试记录)
       const prevRetryCount = existingFailed.retryCount || 0;
       // 关键:更新 voiceId 为当前有效的音色,避免旧任务用已禁用的 MiniMax 音色导致循环失败
-      const taskVoiceId = voiceId || 'longanhuan_v3';
+      const taskVoiceId = voiceId || routedVoiceId || 'longanhuan_v3';
       task = await prisma.ttsTask.update({
         where: { id: existingFailed.id },
         data: {
@@ -1237,15 +1252,19 @@ export class BookStore {
           contentHash,
           voiceId: taskVoiceId,
           voiceSpeed: bookVoiceSpeed,
+          targetLanguage,
+          preferredVendor,
+          preferredModel,
           retryCount: prevRetryCount,  // 保留历史重试次数,不重置为0
           errorMsg: null,
           startedAt: null,
           completedAt: null,
         },
       });
-      console.log(`[Audio] 复用已有任务#${existingFailed.id}: chapterId=${chapterId}, voiceId=${taskVoiceId}, voiceSpeed=${bookVoiceSpeed}, 累计重试=${prevRetryCount}次`);
+      console.log(`[Audio] 复用已有任务#${existingFailed.id}: chapterId=${chapterId}, voiceId=${taskVoiceId}, voiceSpeed=${bookVoiceSpeed}, lang=${targetLanguage}, 累计重试=${prevRetryCount}次`);
     } else {
-      console.log(`[Audio] 创建TTS任务: chapterId=${chapterId}, 内容长度=${chapterBefore.content.length}, voiceSpeed=${bookVoiceSpeed}, hash=${contentHash.substring(0, 12)}...`);
+      console.log(`[Audio] 创建TTS任务: chapterId=${chapterId}, 内容长度=${chapterBefore.content.length}, voiceSpeed=${bookVoiceSpeed}, lang=${targetLanguage}, hash=${contentHash.substring(0, 12)}...`);
+      const taskVoiceId = voiceId || routedVoiceId || 'longanhuan_v3';
       task = await prisma.ttsTask.create({
       data: {
         taskType: 'tts',
@@ -1254,8 +1273,11 @@ export class BookStore {
         userId: userId || chapterBefore.book?.userId || null,
         contentHash,
         content: chapterBefore.content, // 保存提交时的内容副本
-        voiceId: voiceId || 'longanhuan_v3',  // 支持外部传入音色,默认兼容有声书
+        voiceId: taskVoiceId,  // 支持外部传入音色,路由 fallback,默认兼容有声书
         voiceSpeed: bookVoiceSpeed,
+        targetLanguage,
+        preferredVendor,
+        preferredModel,
         status: 'pending',
       },
     });

+ 72 - 1
server/src/modules/book-generator/book-recovery-scanner.ts

@@ -30,6 +30,7 @@ import { bookStore } from './book-generator.store';
 const SCAN_INTERVAL_MS = 60_000;          // 扫描间隔 60 秒
 const OUTLINE_READY_TIMEOUT_MS = 30 * 60 * 1000;  // outline_ready 超过 30 分钟未动 → 自愈
 const CONTENT_GENERATING_TIMEOUT_MS = 60 * 60 * 1000;  // content_generating 超过 60 分钟无章节推进 → 自愈
+const OUTLINING_TIMEOUT_MS = 15 * 60 * 1000;  // outlining 超过 15 分钟没新章节入库 → 自愈
 const RECOVER_MAX_PER_ROUND = 3;          // 每轮最多恢复 3 本书,避免 LLM 配额爆
 const LOG_STATS_INTERVAL = 10;            // 每 10 轮输出一次统计摘要
 
@@ -39,6 +40,7 @@ interface RecoverStats {
   totalScans: number;
   foundOutlineReady: number;
   foundStaleContentGen: number;
+  foundOutliningOrphan: number;
   recovered: number;
   recoverFailed: number;
   lastScanTime: number;
@@ -48,6 +50,7 @@ const stats: RecoverStats = {
   totalScans: 0,
   foundOutlineReady: 0,
   foundStaleContentGen: 0,
+  foundOutliningOrphan: 0,
   recovered: 0,
   recoverFailed: 0,
   lastScanTime: 0,
@@ -62,7 +65,7 @@ interface CandidateBook {
   description: string;
   bookScale: string | null;
   autoGenerateContent: boolean;
-  reason: 'outline_ready_timeout' | 'content_generating_timeout' | 'incomplete_content';
+  reason: 'outline_ready_timeout' | 'content_generating_timeout' | 'incomplete_content' | 'outlining_orphan';
   retryCount: number;
 }
 
@@ -163,6 +166,54 @@ async function findCandidates(): Promise<CandidateBook[]> {
     }
   }
 
+  if (candidates.length < RECOVER_MAX_PER_ROUND) {
+    // ---- 场景 4: outlining 孤儿 — 治根 book 65 这种"卡在 outlining 没新章节、也没人在跑"的书 ----
+    // 历史上 book 65 卡住 9 小时 + 的根因:
+    //   langGraphGenerator.generate() 中途 book.update() 抛 Prisma 'column does not exist'
+    //   → worker crash → Bull job 状态没回滚 → 任务从 queue 消失
+    //   → Book.genStage 永远卡在 'outlining' + updatedAt 不再变
+    // 现有 scanner 不看 'outlining' 阶段(只盯 outline_ready/content_generating),所以没人救
+    // 兜底:扫描 'outlining' 且 updatedAt < NOW()-15min 且 0 章节的书 → 视为孤儿 → 重置 + 重新入队
+    const outliningCutoff = new Date(Date.now() - OUTLINING_TIMEOUT_MS);
+    const orphanOutliningBooks = await prisma.book.findMany({
+      where: {
+        genStage: 'outlining',
+        updatedAt: { lt: outliningCutoff },
+        autoGenerateContent: { not: false },
+      },
+      select: {
+        id: true, userId: true, title: true, description: true,
+        bookScale: true, autoGenerateContent: true, totalChapters: true,
+      },
+      take: (RECOVER_MAX_PER_ROUND - candidates.length) * 2,
+      orderBy: { updatedAt: 'asc' },
+    });
+
+    for (const b of orphanOutliningBooks) {
+      // 只要 outlining 阶段超时没推进就触发,不管已有多少章节
+      // 之前的 fillRatio >= 0.5 过滤让 book 65 (6/10) 漏掉
+      const chapterCount = await prisma.bookChapter.count({ where: { bookId: b.id } });
+      const expectedTotal = b.totalChapters || 10;
+
+      candidates.push({
+        id: b.id,
+        userId: b.userId,
+        title: b.title,
+        description: b.description,
+        bookScale: b.bookScale || '1000',
+        autoGenerateContent: b.autoGenerateContent ?? true,
+        reason: 'outlining_orphan',
+        retryCount: 0,
+      });
+      stats.foundOutliningOrphan++;
+      console.log(
+        `[BookRecovery] bookId=${b.id} outlining 阶段>${OUTLINING_TIMEOUT_MS/60000}min 无推进,` +
+        `章节 ${chapterCount}/${expectedTotal}(${expectedTotal > 0 ? (chapterCount/expectedTotal*100).toFixed(0) : '?'}%),判为孤儿,加入恢复队列`
+      );
+      if (candidates.length >= RECOVER_MAX_PER_ROUND) break;
+    }
+  }
+
   if (candidates.length < RECOVER_MAX_PER_ROUND) {
     // ---- 场景 3: 书标记已完成但仍有章节漏内容(LangGraph 节点漏掉某些节) ----
     // 实际场景:book.genStage='content_completed' 或 'audio_completed',
@@ -267,6 +318,26 @@ async function recoverBook(c: CandidateBook): Promise<boolean> {
         console.log(`${logPrefix} 状态已变化 (genStage=${fresh.genStage}, hasOutline=${!!fresh.outlineJson}),跳过`);
         return false;
       }
+    } else if (c.reason === 'outlining_orphan') {
+      if (fresh.genStage !== 'outlining') {
+        console.log(`${logPrefix} 状态已变化 (genStage=${fresh.genStage}),跳过`);
+        return false;
+      }
+      // 治根:reset 到 outline_ready,让场景 1 (outline_ready_timeout) 接管,
+      //      它会调 fillMissingChapters 补全 content=NULL 的章节(langGraphGenerator.generate 本身会跳过已有 outline)。
+      //      选 outline_ready 而非 draft,是因为 draft 不在 resumeInterruptedTasks 的扫描白名单里。
+      console.log(`${logPrefix} 治根:reset genStage=outline_ready + 清 errorMsg`);
+      await prisma.book.update({
+        where: { id: c.id },
+        data: {
+          genStage: 'outline_ready',
+          errorMsg: null,
+          failedStage: null,
+          updatedAt: new Date(Date.now() - 31 * 60 * 1000), // 设成 31 分钟前,立即满足 outline_ready_timeout 阈值
+        },
+      });
+      stats.recovered++;
+      return true;
     } else if (c.reason === 'content_generating_timeout') {
       if (fresh.genStage !== 'content_generating') {
         console.log(`${logPrefix} 状态已变化 (genStage=${fresh.genStage}),跳过`);

+ 4 - 1
server/src/modules/book-generator/langgraph-controller.ts

@@ -440,6 +440,7 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
       autoGenerateContent?: boolean;
       autoGenerateAudio?: boolean;
       voiceSpeed?: number;
+      language?: string;           // 🆕 目标语言(BCP-47),如 zh-CMN / zh-YUE / en-US / ja-JP
     };
 
     // 自动生成开关:未传时默认 true(与历史行为一致)
@@ -508,10 +509,11 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
         totalChapters: scaleConfig.isShortArticle ? 1 : scaleConfig.chapters,
         estimatedWords: totalWords,
         voiceSpeed: finalVoiceSpeed,
+        targetLanguage: body.language,  // 🆕 目标语言
         autoGenerateContent: autoGenContent,
         autoGenerateAudio: autoGenAudio,
       });
-      console.log(`[LangGraph.fastPath] finalVoiceSpeed=${finalVoiceSpeed}, type=${typeof finalVoiceSpeed}, body.voiceSpeed=${body.voiceSpeed}`);
+      console.log(`[LangGraph.fastPath] finalVoiceSpeed=${finalVoiceSpeed}, targetLanguage=${body.language || 'zh-CMN'}`);
 
       if (body.immediateGenerate === false) {
         ctx.body = { code: 0, message: '书籍创建成功(交互模式)', data: { book, genStage: 'draft', mode: 'interactive' } };
@@ -575,6 +577,7 @@ router.post('/books', optionalAuth, async (ctx: Context) => {
       totalChapters: fallbackConfig.isShortArticle ? 1 : fallbackConfig.chapters,
       estimatedWords: fallbackWords,
       voiceSpeed: finalVoiceSpeed,
+      targetLanguage: body.language,  // 🆕 目标语言
       autoGenerateContent: autoGenContent,
       autoGenerateAudio: autoGenAudio,
     });

+ 49 - 4
server/src/modules/tts/tts.controller.ts

@@ -8,10 +8,38 @@ import { optionalAuth } from '../../middleware/auth';
 import { usageLimitMiddleware, checkWordLimit } from '../../middleware/usageLimit';
 import { checkAudioQuota } from '../subscription/subscription.service';
 import { prisma } from '../../models';
+import {
+  listSupportedLanguages,
+  listVoicesByLang,
+  isLanguageSupported,
+  pickTtsVendor,
+} from '../../services/tts-lang-router.service';
 import * as DocumentParser from './document-parser.service';
 
 const router = new Router();
 
+// 🆕 语言下拉数据源(给前端"语言选择器")
+router.get('/languages', async (ctx: Context) => {
+  const languages = listSupportedLanguages();
+  ctx.body = { code: 0, message: 'success', data: { languages } };
+});
+
+// 🆕 按语言查可用音色
+router.get('/voices-by-lang', async (ctx: Context) => {
+  const lang = ((ctx.query.lang as string) || '').trim();
+  if (!lang) throw new BadRequestError('需指定 ?lang 参数');
+  if (!isLanguageSupported(lang)) {
+    throw new BadRequestError(`暂不支持该语言: ${lang}`);
+  }
+  const voices = listVoicesByLang(lang);
+  const route = pickTtsVendor(lang);
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: { lang, recommended: route, voices },
+  };
+});
+
 // 获取可用音色列表
 router.get('/voices', async (ctx: Context) => {
   const voices = TtsService.getVoices();
@@ -122,16 +150,17 @@ router.post(
   optionalAuth,
   async (ctx: Context) => {
     const userId = ctx.state.user?.userId;
-    const { text, voiceId, voiceParams, bookId, chapterTitle } = ctx.request.body as {
+    const { text, voiceId, voiceParams, bookId, chapterTitle, language } = ctx.request.body as {
       text: string;
       voiceId: string;
       voiceParams?: { speed?: number; pitch?: number; volume?: number };
       bookId?: string;
       chapterTitle?: string;
+      language?: string;   // 🆕 用户选的语言(BCP-47),传空 → 用 book 的 targetLanguage
     };
 
     // 调试日志
-    console.log('📥 收到 TTS 请求:', { textLength: text?.length, voiceId, voiceParams, bookId, chapterTitle });
+    console.log('📥 收到 TTS 请求:', { textLength: text?.length, voiceId, voiceParams, bookId, chapterTitle, language });
 
     // 参数验证
     if (!text || text.trim().length === 0) {
@@ -142,7 +171,22 @@ router.post(
       throw new BadRequestError('文本过短,至少需要10个字符');
     }
 
-    // voiceId 可选:为空时后端根据内容自动选择最佳音色
+    // 🆕 路由: 用户选的语言 + 已有 voiceId 决定最终 vendor+model+voice
+    let finalVoiceId = voiceId;
+    let targetLanguage = language;
+    if (bookId && !targetLanguage) {
+      const b = await prisma.book.findUnique({ where: { id: parseInt(bookId) }, select: { targetLanguage: true } });
+      if (b?.targetLanguage) targetLanguage = b.targetLanguage;
+    }
+    if (targetLanguage && !isLanguageSupported(targetLanguage)) {
+      throw new BadRequestError(`暂不支持该语言: ${targetLanguage}`);
+    }
+    if (targetLanguage && !voiceId) {
+      const route = pickTtsVendor(targetLanguage);
+      if (!route) throw new BadRequestError(`语言路由失败: ${targetLanguage}`);
+      finalVoiceId = route.voice;
+      console.log(`🎤 [lang-router] ${targetLanguage} → ${route.vendor}/${route.model}/${route.voice} (${route.reason})`);
+    }
 
     // 如果指定了 bookId,验证书籍是否存在
     if (bookId) {
@@ -178,9 +222,10 @@ router.post(
 
     try {
       // 复用有声书生成立逻辑:创建章节 + 入队 TtsTask,立即返回
-      const result = await TtsService.requestTtsGeneration(userId, text, voiceId, params, {
+      const result = await TtsService.requestTtsGeneration(userId, text, finalVoiceId, params, {
         bookId,
         chapterTitle,
+        language: targetLanguage,    // 🆕 透传给 TTS 调度,写进 TtsTask
       });
 
       ctx.body = {

+ 5 - 2
server/src/modules/tts/tts.service.ts

@@ -970,6 +970,7 @@ export async function requestTtsGeneration(
   options?: {
     bookId?: string;
     chapterTitle?: string;
+    language?: string;   // 🆕 选定的语言
   }
 ): Promise<{
   chapterId: number;
@@ -1018,9 +1019,11 @@ export async function requestTtsGeneration(
 
   // 3. 通过有声书队列生成音频(与有声书完全相同的后续流程)
   const { bookStore } = await import('../book-generator/book-generator.store.js');
-  await bookStore.generateChapterAudioById(chapter.id, userIdNum || undefined, voiceId);
+  await bookStore.generateChapterAudioById(chapter.id, userIdNum || undefined, voiceId, {
+    language: options?.language,
+  });
 
-  console.log(`✅ [TTS] 音频任务已入队: chapterId=${chapter.id}, voiceId=${voiceId}`);
+  console.log(`✅ [TTS] 音频任务已入队: chapterId=${chapter.id}, voiceId=${voiceId}, lang=${options?.language || 'auto'}`);
 
   return {
     chapterId: chapter.id,

+ 196 - 0
server/src/services/tts-lang-router.service.ts

@@ -0,0 +1,196 @@
+/**
+ * tts-lang-router.service.ts — 按语言找 TTS vendor / model / voiceId
+ *
+ * 输入: langKey (BCP-47 风格,如 zh-CMN、zh-YUE、en-US、ja-JP)
+ * 输出: { vendor, model, voice } 三元组
+ *
+ * fallback 链(优先):
+ *   1. 矩阵中匹配的"最佳"音色 (RECOMMENDED 字段)
+ *   2. 同语言其他音色 (langVoices 列表)
+ *   3. 同 vendor 其他模型 (matrix 数据里所有 model)
+ *   4. 跨 vendor → Qwen-TTS (支持 11 语种)
+ *   5. Edge 同语种 Neural (zh-HK / en-US / en-GB / ja-JP / ko-KR / id-ID)
+ *   6. → 422 NotFound (绝不静默跨语言退化)
+ */
+import fs from 'fs';
+import path from 'path';
+
+export type Vendor = 'bailian' | 'edge';
+export interface LangRoute {
+  vendor: Vendor;
+  model: string;
+  voice: string;
+  preferredName: string;  // provider registry key, 例 'bailian-tts' / 'edge-tts'
+  reason: string;         // 选这条的 log 用
+}
+
+interface LangDef {
+  label: string;
+  base: string;
+  dialect: string;
+  supportedModels: string[];
+  voiceCount: number;
+  recommended: LangRoute | null;
+  fallbackChain: string[];
+  sampleVoices: Array<{ voice_id: string; name: string; model: string }>;
+}
+
+interface MatrixFile {
+  version: string;
+  recommended: string[];
+  languages: Record<string, LangDef>;
+}
+
+// 启动时一次性加载(缓存到 hot path)
+let MATRIX: MatrixFile | null = null;
+const LANG_MAP: Map<string, LangDef> = new Map();
+
+function ensureLoaded(): void {
+  if (MATRIX) return;
+  // 编译后 dist/ 目录或源码 src/ 目录
+  const candidates = [
+    path.join(__dirname, '..', 'config', 'alicloud-tts-lang-voice-matrix.json'),
+    path.join(__dirname, '..', '..', 'src', 'config', 'alicloud-tts-lang-voice-matrix.json'),
+    path.join(process.cwd(), 'src', 'config', 'alicloud-tts-lang-voice-matrix.json'),
+  ];
+  let raw: string | null = null;
+  for (const p of candidates) {
+    try { raw = fs.readFileSync(p, 'utf8'); break; } catch { /* continue */ }
+  }
+  if (!raw) {
+    console.warn('[tts-lang-router] 矩阵 JSON 加载失败,所有语言查询都会 fallback');
+    MATRIX = { version: '0', recommended: [], languages: {} };
+    return;
+  }
+  MATRIX = JSON.parse(raw);
+  for (const [key, def] of Object.entries(MATRIX.languages || {})) {
+    LANG_MAP.set(key, def);
+  }
+  console.log(`[tts-lang-router] 加载 ${LANG_MAP.size} 个 lang_key,推荐 ${MATRIX.recommended?.length || 0} 个`);
+}
+
+// Edge TTS 同语种 fallback(用在矩阵都没匹配时)
+function edgeFallbackVoice(langKey: string): { vendor: Vendor; model: string; voice: string; reason: string } | null {
+  const map: Record<string, string> = {
+    'zh-CMN': 'zh-CN-XiaoxiaoNeural',
+    'zh-YUE': 'zh-HK-HiuMaanNeural',
+    'zh-NEN': 'zh-CN-liaoning-XiaobeiNeural',  // 东北方言
+    'en-US': 'en-US-JennyNeural',
+    'en-GB': 'en-GB-RyanNeural',
+    'ja-JP': 'ja-JP-NanamiNeural',
+    'ko-KR': 'ko-KR-SunHiNeural',
+    'id-ID': 'id-ID-ArdiNeural',
+  };
+  const voice = map[langKey];
+  if (!voice) return null;
+  return { vendor: 'edge', model: 'edge-tts', voice, reason: `Edge fallback for ${langKey}` };
+}
+
+// ─────────────────────────────────────────────────────────────
+// 公开 API
+// ─────────────────────────────────────────────────────────────
+
+/** 主入口: TTS 任务入队时必调 */
+export function pickTtsVendor(langKey?: string): LangRoute | null {
+  ensureLoaded();
+  if (!langKey) return null;
+
+  const def = LANG_MAP.get(langKey);
+  // 1. exact match: matrices 推荐音色
+  if (def?.recommended) {
+    return { ...def.recommended, preferredName: `${def.recommended.vendor}-tts` };
+  }
+
+  // 2. langKey 存在但无 recommended(实际数据但没给最佳)→ 取第一个 sampleVoice
+  if (def && def.sampleVoices.length > 0) {
+    const v = def.sampleVoices[0];
+    return {
+      vendor: 'bailian',
+      model: v.model,
+      voice: v.voice_id,
+      preferredName: 'bailian-tts',
+      reason: `langKey=${langKey} match, 取首个 sampleVoice ${v.voice_id}`,
+    };
+  }
+
+  // 3. 跨语种 fallback:同 base
+  if (def) {
+    // 例如 zh-MNN 没有 fallback 链但有 langVoices,降级到 zh-CMN
+    const baseKey = langKey.split('-')[0] + '-' + 'CMN';
+    const baseDef = LANG_MAP.get(baseKey);
+    if (baseDef?.recommended) {
+      return { ...baseDef.recommended, preferredName: 'bailian-tts', reason: `langKey=${langKey} 非主流,降级到 ${baseKey}` };
+    }
+  }
+
+  // 4. 最后 Edge 同语种 fallback
+  const edge = edgeFallbackVoice(langKey);
+  if (edge) {
+    return { ...edge, preferredName: 'edge-tts' };
+  }
+
+  // 5. 完全不支持 → 返回 null(让 controller 抛 422)
+  return null;
+}
+
+/** 给前端"选语言后展示可选音色" */
+export function listVoicesByLang(langKey: string, preferCheap = false): Array<{
+  vendor: Vendor; model: string; voiceId: string; voiceName: string; desc?: string;
+}> {
+  ensureLoaded();
+  const def = LANG_MAP.get(langKey);
+  if (!def) return [];
+  const out: Array<{ vendor: Vendor; model: string; voiceId: string; voiceName: string; desc?: string }> = [];
+  for (const sv of def.sampleVoices) {
+    if (preferCheap && sv.model.includes('cosyvoice-v2')) continue;
+    out.push({
+      vendor: 'bailian',
+      model: sv.model,
+      voiceId: sv.voice_id,
+      voiceName: sv.name || sv.voice_id,
+      desc: def.label,
+    });
+  }
+  return out;
+}
+
+/** 给前端"语言选择器"用 */
+export function listSupportedLanguages(): Array<{
+  key: string; label: string; recommended: boolean; vendorHint?: string;
+}> {
+  ensureLoaded();
+  const recommended = new Set(MATRIX?.recommended || []);
+  const out: Array<{ key: string; label: string; recommended: boolean; vendorHint?: string }> = [];
+  for (const [key, def] of [...LANG_MAP.entries()].sort()) {
+    out.push({
+      key,
+      label: def.label,
+      recommended: recommended.has(key),
+      vendorHint: def.recommended ? `${def.recommended.vendor}/${def.recommended.model}` : undefined,
+    });
+  }
+  return out;
+}
+
+/** 校验:客户端传来 langKey 是否支持 */
+export function isLanguageSupported(langKey: string): boolean {
+  ensureLoaded();
+  if (LANG_MAP.has(langKey)) return true;
+  // Edge fallback 也算"支持"(虽然音色不在矩阵)
+  return edgeFallbackVoice(langKey) !== null;
+}
+
+/** 反向查询: 这个 voice 能不能说这种语言 */
+export function supportsLanguage(voiceId: string, langKey: string): boolean {
+  ensureLoaded();
+  if (!voiceId) return false;
+  const def = LANG_MAP.get(langKey);
+  if (!def) return false;
+  return def.sampleVoices.some(v => v.voice_id === voiceId);
+}
+
+/** 调试用:获取整个矩阵 */
+export function _getMatrix(): MatrixFile | null {
+  ensureLoaded();
+  return MATRIX;
+}