ソースを参照

feat: 更新TTS provider.registry - 完善CosyVoice支持 + ttsLogger日志

- provider.registry: 支持模型级apiPath构建ttsApiPath
- 新增tts-logger.ts日志模块
- 同步服务端的provider.registry.ts到master分支

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MyFramework User 3 ヶ月 前
コミット
4cabfaae50

+ 11 - 0
server/src/app.ts

@@ -20,6 +20,7 @@ import { resumeInterruptedTasks } from './modules/book-generator/book-queue.proc
 import { startTtsQueue, stopTtsQueue } from './modules/book-generator/tts-queue';
 import { startAudioScanner, stopAudioScanner } from './modules/book-generator/audio-scanner';
 import { queueService } from './services/queue.service';
+import { validateModelsJsonStructure, printValidationReport } from './config/models-validator';
 import { initSentry, sentryErrorHandler } from './services/sentry.service';
 import { xssProtection, sqlInjectionProtection } from './middleware/security';
 import { performanceMonitor, getMetrics } from './middleware/performance';
@@ -143,6 +144,16 @@ async function start() {
     // 1. 连接数据库
     await connectDatabase();
     console.log('✅ MySQL 连接成功');
+
+    // 1.5. 校验 models.json 配置
+    const modelIssues = validateModelsJsonStructure();
+    if (modelIssues.length > 0) {
+      printValidationReport(modelIssues);
+      const hasErrors = modelIssues.some(i => i.severity === 'error');
+      if (hasErrors) {
+        console.error('❌ models.json 配置错误,服务无法启动');
+      }
+    }
     
     // 2. 测试 Redis 连接
     const redisOk = await redisService.testConnection();

+ 1 - 13
server/src/config/index.ts

@@ -103,19 +103,7 @@ export const config = {
     expiresIn: process.env.JWT_EXPIRES_IN || '7d',
   },
 
-  // 阿里云百炼 DashScope TTS
-  dashscope: {
-    apiKey: process.env.DASHSCOPE_API_KEY || '',
-    model: process.env.DASHSCOPE_MODEL || 'qwen3-tts-instruct-flash',
-    voice: process.env.DASHSCOPE_VOICE || 'Cherry',
-    // 可用 TTS 模型列表(随机选用)
-    ttsModels: (process.env.DASHSCOPE_TTS_MODELS || 'qwen3-tts-instruct-flash').split(',').filter(Boolean),
-    // 长文本模式使用 WebSocket 实时合成
-    useRealtime: process.env.DASHSCOPE_USE_REALTIME === 'true',
-    realtimeModel: process.env.DASHSCOPE_REALTIME_MODEL || 'qwen3-tts-instruct-flash-realtime',
-  },
-
-  // 模型配置(统一管理)
+  // 模型配置(统一管理,所有 TTS/LLM 配置均从 models.json 读取)
   models: {
     vendors: modelsConfig.vendors,
     list: getAllModels(),

+ 179 - 3
server/src/config/models-validator.ts

@@ -1,11 +1,158 @@
 /**
- * 模型验证工具 - 批量验证所有模型是否可用
+ * 模型配置校验工具
+ *
+ * 两层校验:
+ * 1. 结构校验(JSON Schema)- 检查必填字段、枚举值、格式
+ * 2. 可用性校验(API 测试)- 检查模型是否真正可用
  */
 
 import { ChatOpenAI } from '@langchain/openai';
 import { config } from './index';
 import fs from 'fs';
 import path from 'path';
+import { log } from '../services/logger.service';
+
+// ============ 结构校验(无需外部依赖)============
+
+const ALLOWED_INPUTS = ['text', 'tts', 'image', 'video'];
+const ALLOWED_API_TYPES = ['openai-chat'];
+
+interface ValidationIssue {
+  severity: 'error' | 'warn';
+  path: string;       // JSON path, e.g. "vendors.minimax.models[0]"
+  message: string;
+  fix?: string;
+}
+
+/**
+ * 校验 models.json 结构(启动时调用,不依赖外部 API)
+ * 返回 issues 列表,error=必须修复,warn=建议修复
+ */
+function validateModelsJsonStructure(): ValidationIssue[] {
+  const issues: ValidationIssue[] = [];
+  const models = config.models as any;
+
+  if (!models?.vendors) {
+    issues.push({ severity: 'error', path: 'vendors', message: '缺少 vendors 配置' });
+    return issues;
+  }
+
+  for (const [vendorKey, vendorData] of Object.entries(models.vendors) as [string, any][]) {
+    // 检查必填字段
+    if (!vendorData.name) {
+      issues.push({ severity: 'warn', path: `vendors.${vendorKey}`, message: '缺少 name 字段' });
+    }
+    if (!vendorData.apiKey) {
+      issues.push({ severity: 'error', path: `vendors.${vendorKey}.apiKey`, message: '缺少 apiKey 字段' });
+    }
+    if (!vendorData.baseUrl) {
+      issues.push({ severity: 'warn', path: `vendors.${vendorKey}.baseUrl`, message: '缺少 baseUrl 字段' });
+    }
+
+    // 检查 models 数组
+    const modelsArr = vendorData.models || [];
+    if (!Array.isArray(modelsArr)) {
+      issues.push({ severity: 'error', path: `vendors.${vendorKey}.models`, message: 'models 必须是数组' });
+      continue;
+    }
+
+    const seenIds = new Set<string>();
+    for (let i = 0; i < modelsArr.length; i++) {
+      const model = modelsArr[i];
+      const modelPath = `vendors.${vendorKey}.models[${i}]`;
+
+      // 检查 id
+      if (!model.id) {
+        issues.push({ severity: 'error', path: modelPath, message: '缺少 id 字段' });
+        continue;
+      }
+
+      // 检查 id 重复
+      if (seenIds.has(model.id)) {
+        issues.push({ severity: 'error', path: modelPath, message: `id 重复: ${model.id}` });
+      }
+      seenIds.add(model.id);
+
+      // 检查 input 字段
+      if (!model.input) {
+        issues.push({ severity: 'error', path: modelPath, message: '缺少 input 字段' });
+      } else if (!Array.isArray(model.input)) {
+        issues.push({ severity: 'error', path: modelPath, message: `input 必须是数组,当前为 ${typeof model.input}` });
+      } else {
+        for (const inputVal of model.input) {
+          if (!ALLOWED_INPUTS.includes(inputVal)) {
+            issues.push({
+              severity: 'warn',
+              path: `${modelPath}.input`,
+              message: `input 包含未知值 "${inputVal}",可选值: ${ALLOWED_INPUTS.join(', ')}`,
+              fix: `移除 "${inputVal}" 或使用允许的值`,
+            });
+          }
+        }
+      }
+
+      // TTS 模型必须检查
+      if (model.input?.includes('tts')) {
+        if (!model.enabled) {
+          issues.push({ severity: 'warn', path: modelPath, message: `TTS 模型 ${model.id} 已禁用` });
+        }
+        if (!model.maxTextLength) {
+          issues.push({ severity: 'warn', path: modelPath, message: `TTS 模型 ${model.id} 缺少 maxTextLength,将使用默认值 1000` });
+        }
+      }
+
+      // text 模型必须检查
+      if (model.input?.includes('text')) {
+        if (model.enabled && !vendorData.apiKey) {
+          issues.push({ severity: 'error', path: modelPath, message: `文本模型 ${model.id} 启用但 vendor 缺少 apiKey` });
+        }
+      }
+    }
+  }
+
+  // 检查顶层配置
+  if (!models.textGeneration?.defaultModel) {
+    issues.push({ severity: 'warn', path: 'textGeneration.defaultModel', message: '缺少默认文本模型配置' });
+  }
+  if (!models.tts?.defaultModel) {
+    issues.push({ severity: 'warn', path: 'tts.defaultModel', message: '缺少默认 TTS 模型配置' });
+  }
+
+  return issues;
+}
+
+/**
+ * 打印校验结果(友好格式)
+ */
+function printValidationReport(issues: ValidationIssue[]): void {
+  if (issues.length === 0) {
+    log.info('✅ models.json 结构校验通过');
+    return;
+  }
+
+  const errors = issues.filter(i => i.severity === 'error');
+  const warnings = issues.filter(i => i.severity === 'warn');
+
+  if (errors.length > 0) {
+    log.error(`❌ models.json 结构校验失败 (${errors.length} 个错误):`);
+    for (const issue of errors) {
+      log.error(`   [${issue.path}] ${issue.message}`);
+      if (issue.fix) log.error(`      修复: ${issue.fix}`);
+    }
+  }
+
+  if (warnings.length > 0) {
+    log.warn(`⚠️  models.json 结构校验警告 (${warnings.length} 个):`);
+    for (const issue of warnings) {
+      log.warn(`   [${issue.path}] ${issue.message}`);
+      if (issue.fix) log.warn(`      修复: ${issue.fix}`);
+    }
+  }
+}
+
+// ============ 可用性校验(需要外部 API)============
+
+// ============ 可用性校验(需要外部 API)============
 
 interface ModelValidationResult {
   id: string;
@@ -159,19 +306,48 @@ async function validateModelsByType(type: 'text' | 'tts' | 'image' | 'video'): P
   return report;
 }
 
-// 如果直接运行此文件,执行验
+// 如果直接运行此文件,执行
 if (require.main === module) {
   const args = process.argv.slice(2);
   const type = args[0] as 'text' | 'tts' | 'image' | 'video' | undefined;
+  const onlyStructure = args.includes('--check');
 
   (async () => {
+    console.log('\n========== 模型配置校验 ==========\n');
+
+    // 1. 结构校验(无需 API 调用)
+    const structureIssues = validateModelsJsonStructure();
+    printValidationReport(structureIssues);
+
+    const hasErrors = structureIssues.some(i => i.severity === 'error');
+    if (hasErrors) {
+      console.log('\n❌ 结构校验失败,跳过可用性校验');
+      process.exit(1);
+    }
+
+    if (onlyStructure) {
+      console.log('\n✅ 结构校验通过(--check 模式,仅做结构检查)');
+      process.exit(0);
+    }
+
+    // 2. 可用性校验
     if (type) {
       await validateModelsByType(type);
     } else {
       await validateAllModels();
     }
+
     process.exit(0);
   })();
 }
 
-export { validateAllModels, validateModelsByType, validateModel, type ModelValidationResult, type ValidationReport };
+export {
+  validateModelsJsonStructure,
+  printValidationReport,
+  validateAllModels,
+  validateModelsByType,
+  validateModel,
+  type ModelValidationResult,
+  type ValidationReport,
+  type ValidationIssue,
+};

+ 31 - 24
server/src/modules/tts/ai-summary.service.ts

@@ -1,4 +1,5 @@
 import axios from 'axios';
+import { withAiLog } from '../../services/ai-call-logger';
 
 /**
  * AI 文本摘要服务
@@ -41,19 +42,22 @@ export class AISummaryService {
   private async callAIAPI(text: string, maxLength: number): Promise<string> {
     const prompt = `请为以下文本生成一个${maxLength}字以内的摘要,要求简洁明了,突出核心内容:\n\n${text.slice(0, 1000)}`;
 
-    const response = await axios.post(
-      this.apiEndpoint,
-      {
-        prompt,
-        max_tokens: maxLength,
-        temperature: 0.7,
-      },
-      {
-        headers: {
-          Authorization: `Bearer ${this.apiKey}`,
-          'Content-Type': 'application/json',
+    const response = await withAiLog(
+      () => axios.post(
+        this.apiEndpoint,
+        {
+          prompt,
+          max_tokens: maxLength,
+          temperature: 0.7,
         },
-      }
+        {
+          headers: {
+            Authorization: `Bearer ${this.apiKey}`,
+            'Content-Type': 'application/json',
+          },
+        }
+      ),
+      { callType: 'llm_chat', provider: 'ai-summary', model: 'summary-model', textLen: prompt.length }
     );
 
     return response.data.result || response.data.choices?.[0]?.text || '';
@@ -118,19 +122,22 @@ export class AISummaryService {
   private async callAITitle(text: string): Promise<string> {
     const prompt = `请为以下文本生成一个吸引人的标题,不超过 30 个字:\n\n${text.slice(0, 500)}`;
 
-    const response = await axios.post(
-      this.apiEndpoint,
-      {
-        prompt,
-        max_tokens: 30,
-        temperature: 0.8,
-      },
-      {
-        headers: {
-          Authorization: `Bearer ${this.apiKey}`,
-          'Content-Type': 'application/json',
+    const response = await withAiLog(
+      () => axios.post(
+        this.apiEndpoint,
+        {
+          prompt,
+          max_tokens: 30,
+          temperature: 0.8,
         },
-      }
+        {
+          headers: {
+            Authorization: `Bearer ${this.apiKey}`,
+            'Content-Type': 'application/json',
+          },
+        }
+      ),
+      { callType: 'llm_chat', provider: 'ai-summary', model: 'title-model', textLen: prompt.length }
     );
 
     return response.data.result || response.data.choices?.[0]?.text || 'AI 生成的音频内容';

+ 5 - 3
server/src/modules/tts/aliyun-realtime.provider.ts

@@ -15,9 +15,11 @@ export class AliyunRealtimeTtsProvider {
   private baseUrl = 'wss://dashscope.aliyuncs.com/api-ws/v1/realtime';
 
   constructor() {
-    this.apiKey = config.dashscope.apiKey;
-    this.model = config.dashscope.realtimeModel;
-    this.voice = config.dashscope.voice;
+    // 从 models.json 统一获取百炼配置(已消除 dashscope 冗余配置源)
+    const vendorConfig = (config.models as any).vendors?.bailian;
+    this.apiKey = vendorConfig?.apiKey || '';
+    this.model = this.model || 'qwen3-tts-instruct-flash-realtime';
+    this.voice = (config.models as any).tts?.defaultVoice || 'Cherry';
   }
 
   // 语音合成(WebSocket 流式)

+ 93 - 40
server/src/modules/tts/aliyun.provider.ts

@@ -44,19 +44,23 @@ export class AliyunTtsProvider implements ITtsProvider {
    */
   constructor(vendorKey: string = 'bailian', apiKey?: string, modelId?: string, ttsApiPath?: string, maxTextLength?: number) {
     this.name = `${vendorKey}-tts`;
-    this.modelId = modelId || config.dashscope.model || 'qwen3-tts-instruct-flash';
+    // 从 models.json 获取默认 TTS 模型(兜底:qwen3-tts-instruct-flash)
+    this.modelId = modelId || (config.models as any).tts?.defaultModel || 'qwen3-tts-instruct-flash';
     this.ttsApiPath = ttsApiPath || 'https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation';
     if (maxTextLength) this.maxTextLength = maxTextLength;
     if (apiKey) {
       this.apiKey = apiKey;
     } else {
-      this.apiKey = config.dashscope.apiKey;
+      // 从 models.json 获取对应 vendor 的 apiKey
+      const vendorConfig = (config.models as any).vendors?.[vendorKey];
+      this.apiKey = vendorConfig?.apiKey || '';
     }
-    this.voice = config.dashscope.voice;
+    // 默认音色从 models.json TTS 配置读取
+    this.voice = (config.models as any).tts?.defaultVoice || 'Cherry';
   }
 
   /**
-   * 语音合成(同步模式:直接返回音频 URL → 下载
+   * 语音合成(CosyVoice SSE 流式,千问同步非流式
    */
   async synthesize(
     text: string,
@@ -67,6 +71,7 @@ export class AliyunTtsProvider implements ITtsProvider {
     modelOverride?: string,
   ): Promise<string> {
     const activeModel = modelOverride || this.modelId;
+    const isCosyVoice = activeModel.includes('cosyvoice');
     let lastError: Error | null = null;
 
     for (let attempt = 1; attempt <= retries; attempt++) {
@@ -77,55 +82,46 @@ export class AliyunTtsProvider implements ITtsProvider {
           input: {
             text: text,
             voice: voiceId || this.voice,
-            language_type: 'Chinese',
+            ...(isCosyVoice ? {} : { language_type: 'Chinese' }),
           },
         };
-
-        // 使用 instruct 模型时支持指令控制
-        if (activeModel.includes('instruct')) {
+        if (isCosyVoice) {
+          requestBody.input.format = 'mp3';
+          requestBody.input.sample_rate = 24000;
+          const parts: string[] = [];
+          if (params.speed !== undefined && params.speed !== 1) parts.push(`语速${params.speed > 1 ? '偏快' : '偏慢'}`);
+          if (params.pitch !== undefined && params.pitch !== 0) parts.push(`音调${params.pitch > 0 ? '偏高' : '偏低'}`);
+          if (params.volume !== undefined && params.volume !== 50) parts.push(`音量${params.volume > 50 ? '较大' : '较小'}`);
+          if (parts.length > 0) requestBody.input.instructions = parts.join(',') + '。';
+        }
+        if (!isCosyVoice && activeModel.includes('instruct')) {
           const instructions: string[] = [];
-          if (params.speed !== 1) {
-            const speedDesc = params.speed > 1 ? '较快' : '较慢';
-            instructions.push(`语速${speedDesc}`);
-          }
-          if (params.pitch !== 0) {
-            const pitchDesc = params.pitch > 0 ? '较高' : '较低';
-            instructions.push(`音调${pitchDesc}`);
-          }
-          if (instructions.length > 0) {
-            requestBody.input.instructions = instructions.join(',');
-          }
+          if (params.speed !== 1) instructions.push(`语速${params.speed > 1 ? '较快' : '较慢'}`);
+          if (params.pitch !== 0) instructions.push(`音调${params.pitch > 0 ? '较高' : '较低'}`);
+          if (instructions.length > 0) requestBody.input.instructions = instructions.join(',');
         }
 
-        console.log(`📤 [Aliyun TTS] 尝试 ${attempt}/${retries}, model: ${activeModel}, voice: ${voiceId || this.voice}, text length: ${text.length}`);
+        console.log(`📤 [Aliyun TTS] ${isCosyVoice?'CosyVoice SSE':'Qwen'} ${attempt}/${retries}, model: ${activeModel}, voice: ${voiceId || this.voice}, text: ${text.length}字`);
 
-        // 同步调用(不加 X-DashScope-Async,Qwen-TTS 不支持异步模式)
+        // CosyVoice: SSE 流式,边收边存,无超时
+        if (isCosyVoice) {
+          return await this.synthesizeStream(requestBody, outputPath);
+        }
+
+        // 千问: 同步非流式
         const response = await withAiLog(
           () => axios.post(this.ttsApiPath, requestBody, {
-            headers: {
-              'Authorization': `Bearer ${this.apiKey}`,
-              'Content-Type': 'application/json',
-            },
-            timeout: 60000,
+            headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
+            timeout: 600000,
           }),
           { callType: 'tts_synthesize', provider: this.vendor, model: activeModel, textLen: text.length }
         );
 
-        // 检查响应
-        if (response.status !== 200) {
-          throw new Error(`Aliyun TTS 请求失败: HTTP ${response.status}`);
-        }
-
+        if (response.status !== 200) throw new Error(`Aliyun TTS 请求失败: HTTP ${response.status}`);
         const data = response.data;
-        if (data.code) {
-          throw new Error(`Aliyun TTS 错误: ${data.message || JSON.stringify(data)}`);
-        }
-
-        // 获取音频 URL(同步模式直接在 output.audio.url 中返回)
+        if (data.code) throw new Error(`Aliyun TTS 错误: ${data.message || JSON.stringify(data)}`);
         const audioUrl = data.output?.audio?.url;
-        if (!audioUrl) {
-          throw new Error(`Aliyun TTS 未返回音频 URL: ${JSON.stringify(data).substring(0, 200)}`);
-        }
+        if (!audioUrl) throw new Error(`Aliyun TTS 未返回音频 URL`);
 
         console.log(`🔗 [Aliyun TTS] 获取音频 URL: ${audioUrl.substring(0, 80)}...`);
 
@@ -159,6 +155,63 @@ export class AliyunTtsProvider implements ITtsProvider {
     throw lastError || new Error('Aliyun TTS 服务调用失败');
   }
 
+  /**
+   * CosyVoice SSE 流式合成:边生成边接收,无超时限制
+   */
+  private async synthesizeStream(requestBody: any, outputPath: string): Promise<string> {
+    const response = await axios.post(this.ttsApiPath, requestBody, {
+      headers: {
+        'Authorization': `Bearer ${this.apiKey}`,
+        'Content-Type': 'application/json',
+        'X-DashScope-SSE': 'enable',
+      },
+      responseType: 'stream',
+      timeout: 0,
+    });
+
+    if (response.status !== 200) {
+      throw new Error(`Aliyun SSE HTTP ${response.status}`);
+    }
+
+    return new Promise((resolve, reject) => {
+      const chunks: Buffer[] = [];
+      let buffer = '';
+
+      response.data.on('data', (chunk: Buffer) => {
+        buffer += chunk.toString();
+        const lines = buffer.split('\n');
+        buffer = lines.pop() || '';
+
+        for (const line of lines) {
+          if (line.startsWith('data:')) {
+            try {
+              const json = JSON.parse(line.substring(5).trim());
+              const audioData = json.output?.audio?.data;
+              if (audioData) {
+                chunks.push(Buffer.from(audioData, 'base64'));
+              }
+            } catch {}
+          }
+        }
+      });
+
+      response.data.on('end', () => {
+        if (chunks.length === 0) {
+          reject(new Error('CosyVoice SSE 未收到音频数据'));
+          return;
+        }
+        const audioBuffer = Buffer.concat(chunks);
+        const dir = path.dirname(outputPath);
+        if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+        fs.writeFileSync(outputPath, audioBuffer);
+        console.log(`✅ [Aliyun SSE] 流式完成: ${outputPath} (${audioBuffer.length} bytes)`);
+        resolve(outputPath);
+      });
+
+      response.data.on('error', reject);
+    });
+  }
+
   /**
    * 下载音频文件到本地
    */
@@ -168,7 +221,7 @@ export class AliyunTtsProvider implements ITtsProvider {
     const response = await withAiLog(
       () => axios.get(audioUrl, {
         responseType: 'arraybuffer',
-        timeout: 120000,
+        timeout: 600000,
       }),
       { callType: 'tts_download', provider: this.vendor, model: this.modelId }
     );

+ 17 - 13
server/src/modules/tts/provider.registry.ts

@@ -10,6 +10,7 @@ import { ITtsProvider } from './provider.interface';
 import { MiniMaxTtsProvider } from './minimax.provider';
 import { AliyunTtsProvider } from './aliyun.provider';
 import { MockTtsProvider } from './mock.provider';
+import { ttsLogger } from './tts-logger';
 import { config } from '../../config';
 
 let _registry: ProviderRegistry<ITtsProvider> | null = null;
@@ -52,7 +53,7 @@ export function initTtsRegistry(): ProviderRegistry<ITtsProvider> {
         ?? (isMiniMaxVendor(vendorKey) ? MiniMaxTtsProvider : undefined);
 
       if (!ProviderClass) {
-        console.log(`[TTS Registry] ${vendorKey} 有 TTS 模型但无对应 Provider 实现,跳过`);
+        ttsLogger.registry('skip', vendorKey, '无对应 Provider 实现');
         continue;
       }
 
@@ -62,7 +63,10 @@ export function initTtsRegistry(): ProviderRegistry<ITtsProvider> {
           .filter((m: any) => m.input?.includes('tts') && m.enabled);
         const ttsModel = ttsModels[0]; // 取第一个启用的 TTS 模型
         const modelId = ttsModel?.id;
-        const ttsApiPath = vendorData.ttsApiPath;
+        // 模型有独立 apiPath 时用它,否则用 vendor 级 ttsApiPath
+        const ttsApiPath = ttsModel?.apiPath
+          ? `https://dashscope.aliyuncs.com${ttsModel.apiPath}`
+          : vendorData.ttsApiPath;
 
         const maxTextLen = ttsModel?.maxTextLength;
         const provider = new ProviderClass(vendorKey, vendorData.apiKey, modelId, ttsApiPath, maxTextLen);
@@ -70,9 +74,9 @@ export function initTtsRegistry(): ProviderRegistry<ITtsProvider> {
           enabled: true,
           breakerConfig: { name: `${vendorKey}-tts`, failureThreshold: 3, cooldownMs: 60000 },
         });
-        console.log(`[TTS Registry] ${vendorData.name || vendorKey} TTS 已注册 (model=${modelId})`);
+        ttsLogger.registry('register', provider.name, `model=${modelId}, vendor=${vendorKey}`);
       } catch (e) {
-        console.warn(`[TTS Registry] ${vendorKey} TTS 注册失败:`, (e as Error).message);
+        ttsLogger.error(`注册 ${vendorKey} TTS 失败`, e as Error);
       }
     }
   }
@@ -84,10 +88,10 @@ export function initTtsRegistry(): ProviderRegistry<ITtsProvider> {
       enabled: false,
       breakerConfig: { name: 'mock-tts', failureThreshold: 999, cooldownMs: 1000 },
     });
-    console.log('[TTS Registry] Mock TTS 已注册(已禁用)');
+    ttsLogger.registry('register', mockProvider.name, '已禁用');
   }
 
-  console.log(`[TTS Registry] 共注册 ${_registry.size} 个 Provider`);
+  ttsLogger.info(`TTS Registry 初始化完成,共 ${_registry.size} 个 Provider`);
   return _registry;
 }
 
@@ -120,7 +124,7 @@ export function getAvailableTtsProvider(preferredName?: string): {
     // 优先的不可用,尝试下一个
     const next = registry.getNextAvailable(preferredName);
     if (next) {
-      console.log(`[TTS Registry] ${preferredName} 不可用,降级到 ${next.provider.name}`);
+      ttsLogger.fallback(preferredName, next.provider.name, '优先 Provider 不可用');
       return { provider: next.provider, node: next };
     }
   }
@@ -151,13 +155,13 @@ export function startTtsHealthCheck(): void {
       try {
         const state = node.breaker.getState();
         if (state === 'HALF_OPEN') {
-          console.log(`[TTS HealthCheck] ${node.provider.name} 熔断冷却期已过,尝试探测...`);
+          ttsLogger.healthCheck(node.provider.name, 'still_down', '熔断冷却期已过,尝试探测...');
           const healthy = await node.provider.healthCheck?.();
           if (healthy) {
             node.breaker.reset();
-            console.log(`[TTS HealthCheck] ✅ ${node.provider.name} 熔断器已恢复`);
+            ttsLogger.circuitBreaker(node.provider.name, 'closed', '探测成功');
           } else {
-            console.log(`[TTS HealthCheck] ❌ ${node.provider.name} 探测失败,继续熔断`);
+            ttsLogger.healthCheck(node.provider.name, 'still_down', '探测失败,继续熔断');
           }
         }
       } catch {
@@ -173,7 +177,7 @@ export function startTtsHealthCheck(): void {
         const healthy = await node.provider.healthCheck?.();
         if (healthy) {
           registry.clearExhausted(node.provider.name);
-          console.log(`[TTS HealthCheck] ✅ ${node.provider.name} 额度已恢复`);
+          ttsLogger.quota(node.provider.name, 'recovered');
         }
       } catch {
         // 探测失败,保持耗尽
@@ -181,7 +185,7 @@ export function startTtsHealthCheck(): void {
     }
   }, HEALTH_CHECK_INTERVAL_MS);
 
-  console.log(`[TTS HealthCheck] 已启动,间隔 ${HEALTH_CHECK_INTERVAL_MS / 1000}s`);
+  ttsLogger.info(`TTS HealthCheck 已启动,间隔 ${HEALTH_CHECK_INTERVAL_MS / 1000}s`);
 }
 
 /** 停止 TTS 定时健康检查 */
@@ -189,6 +193,6 @@ export function stopTtsHealthCheck(): void {
   if (_healthCheckTimer) {
     clearInterval(_healthCheckTimer);
     _healthCheckTimer = null;
-    console.log('[TTS HealthCheck] 已停止');
+    ttsLogger.info('TTS HealthCheck 已停止');
   }
 }

+ 143 - 0
server/src/modules/tts/tts-logger.ts

@@ -0,0 +1,143 @@
+/**
+ * TTS 模块专用日志
+ *
+ * 解决分散日志问题:
+ * 1. console.log / logToFile 混用 → 统一到 logger
+ * 2. tts-debug.log 只在本地开发有效 → 统一到 logs/tts.log
+ * 3. 没有按 provider 分类 → 按 name 字段区分
+ */
+
+// 简易 console logger(logger.service 暂未创建)
+const logger = {
+  info: (msg: string) => console.log(msg),
+  warn: (msg: string) => console.warn(msg),
+  error: (msg: string) => console.error(msg),
+  debug: (msg: string) => console.debug(msg),
+};
+import path from 'path';
+import fs from 'fs';
+
+const LOG_FILE = path.join(process.cwd(), 'logs', 'tts.log');
+
+function ensureLogDir() {
+  const dir = path.dirname(LOG_FILE);
+  if (!fs.existsSync(dir)) {
+    fs.mkdirSync(dir, { recursive: true });
+  }
+}
+
+function writeToFile(msg: string) {
+  const timestamp = new Date().toISOString();
+  const line = `[${timestamp}] ${msg}\n`;
+  fs.appendFileSync(LOG_FILE, line, 'utf-8');
+}
+
+export const ttsLogger = {
+  /** Provider 注册/注销 */
+  registry(action: 'register' | 'unregister' | 'skip', providerName: string, detail?: string) {
+    const msg = `[Registry] ${action === 'skip' ? '跳过' : action === 'register' ? '注册' : '注销'} ${providerName}${detail ? `: ${detail}` : ''}`;
+    if (action === 'skip') {
+      logger.warn(msg);
+    } else {
+      logger.info(msg);
+    }
+    writeToFile(msg);
+  },
+
+  /** 熔断器状态变化 */
+  circuitBreaker(providerName: string, state: 'open' | 'half_open' | 'closed', detail?: string) {
+    const icons = { open: '🔴', half_open: '🟡', closed: '🟢' };
+    const msg = `[CircuitBreaker] ${icons[state]} ${providerName} 熔断器: ${state}${detail ? ` (${detail})` : ''}`;
+    logger.warn(msg);
+    writeToFile(msg);
+  },
+
+  /** 额度耗尽/恢复 */
+  quota(providerName: string, action: 'exhausted' | 'recovered', reason?: string) {
+    const msg = `[Quota] ${action === 'exhausted' ? '⚠️ 额度耗尽' : '✅ 额度恢复'} ${providerName}${reason ? `: ${reason}` : ''}`;
+    if (action === 'exhausted') {
+      logger.warn(msg);
+    } else {
+      logger.info(msg);
+    }
+    writeToFile(msg);
+  },
+
+  /** 音频生成开始 */
+  synthesisStart(audioId: string, providerName: string, textLength: number, segmentCount: number) {
+    const msg = `[Synth] 📝 开始合成 audioId=${audioId}, provider=${providerName}, 文本=${textLength}字, 分段=${segmentCount}`;
+    logger.info(msg);
+    writeToFile(msg);
+  },
+
+  /** 单段合成完成 */
+  segmentDone(audioId: string, providerName: string, segmentIndex: number, filePath: string, ms: number) {
+    const msg = `[Synth]   段落[${segmentIndex}] ✅ ${providerName} ${ms}ms → ${path.basename(filePath)}`;
+    logger.debug(msg);
+    writeToFile(msg);
+  },
+
+  /** 音频生成完成 */
+  synthesisDone(audioId: string, providerName: string, duration: number, audioUrl: string, ms: number) {
+    const msg = `[Synth] ✅ 完成 audioId=${audioId}, provider=${providerName}, 时长=${duration}s, 耗时=${(ms / 1000).toFixed(1)}s, url=${audioUrl.substring(0, 60)}...`;
+    logger.info(msg);
+    writeToFile(msg);
+  },
+
+  /** 音频生成失败 */
+  synthesisFailed(audioId: string, providerName: string, error: string, willRetry: boolean) {
+    const msg = `[Synth] ❌ 失败 audioId=${audioId}, provider=${providerName}, error=${error.substring(0, 100)}${willRetry ? ' (将重试)' : ' (不重试)'}`;
+    logger.error(msg);
+    writeToFile(msg);
+  },
+
+  /** Provider 降级切换 */
+  fallback(fromProvider: string, toProvider: string, reason: string) {
+    const msg = `[Fallback] 🔄 ${fromProvider} → ${toProvider}: ${reason}`;
+    logger.warn(msg);
+    writeToFile(msg);
+  },
+
+  /** 健康检查 */
+  healthCheck(providerName: string, state: 'recovered' | 'still_down', detail?: string) {
+    const msg = `[HealthCheck] ${state === 'recovered' ? '✅' : '❌'} ${providerName}${detail ? `: ${detail}` : ''}`;
+    if (state === 'recovered') {
+      logger.info(msg);
+    } else {
+      logger.warn(msg);
+    }
+    writeToFile(msg);
+  },
+
+  /** 错误日志 */
+  error(label: string, error: string | Error, context?: Record<string, any>) {
+    const errorMsg = error instanceof Error ? error.message : error;
+    const msg = `[Error] ${label}: ${errorMsg}${context ? ` | ${JSON.stringify(context)}` : ''}`;
+    logger.error(msg);
+    writeToFile(msg);
+  },
+
+  /** 通用信息 */
+  info(message: string) {
+    logger.info(`[TTS] ${message}`);
+    writeToFile(`[Info] ${message}`);
+  },
+
+  /** 通用警告 */
+  warn(message: string) {
+    logger.warn(`[TTS] ${message}`);
+    writeToFile(`[Warn] ${message}`);
+  },
+
+  /** LRC 歌词生成(调试级) */
+  lrc(message: string) {
+    logger.debug(`[LRC] ${message}`);
+    writeToFile(`[LRC] ${message}`);
+  },
+
+  /** Provider 信息(调试级) */
+  debug(message: string) {
+    logger.debug(`[TTS] ${message}`);
+    writeToFile(`[Debug] ${message}`);
+  },
+};

+ 92 - 132
server/src/modules/tts/tts.service.ts

@@ -4,7 +4,6 @@ import { v4 as uuidv4 } from 'uuid';
 import { config } from '../../config';
 import { prisma } from '../../models';
 import { VoiceParams, Voice } from '../../types';
-import { getMiniMaxVoice } from './minimax.provider';
 import { AudioMerger } from './audio-merger';
 import { aiSummaryService } from './ai-summary.service';
 import { storageService } from '../../services/storage.service';
@@ -12,102 +11,56 @@ import { getTtsRegistry, getAvailableTtsProvider, startTtsHealthCheck } from './
 import { ITtsProvider } from './provider.interface';
 import { CircuitBreakerOpenError } from '../../common/circuit-breaker';
 import { ProviderNode } from '../../common/provider-registry';
+import { ttsLogger } from './tts-logger';
 import axios from 'axios';
 
-// 日志文件路径
-const LOG_FILE = path.join(process.cwd(), 'tts-debug.log');
+// ============ 统一音色定义(10个固定音色,前端使用)============
+// 前端使用统一 ID,后端根据 Provider 类型映射到真实音色
+const UNIFIED_VOICES: Voice[] = [
+  { id: 'voice_01', name: '温柔女声',  gender: 'female', description: '柔和温暖,适合情感故事' },
+  { id: 'voice_02', name: '磁性男声',  gender: 'male',   description: '低沉有力,适合悬疑推理' },
+  { id: 'voice_03', name: '活泼女声',  gender: 'female', description: '清新明亮,适合儿童故事' },
+  { id: 'voice_04', name: '知性女声',  gender: 'female', description: '知性稳重,适合科普知识' },
+  { id: 'voice_05', name: '阳光男声',  gender: 'male',   description: '阳光活力,适合校园青春' },
+  { id: 'voice_06', name: '沧桑男声',  gender: 'male',   description: '成熟沧桑,适合历史军事' },
+  { id: 'voice_07', name: '甜美女声',  gender: 'female', description: '甜美可爱,适合爱情都市' },
+  { id: 'voice_08', name: '清朗男声',  gender: 'male',   description: '清朗干练,适合职场商战' },
+  { id: 'voice_09', name: '亲切女声',  gender: 'female', description: '亲切自然,适合日常叙事' },
+  { id: 'voice_10', name: '稚嫩童声',  gender: 'female', description: '稚嫩天真,适合童话寓言' },
+];
 
-function logToFile(msg: string) {
-  const timestamp = new Date().toISOString();
-  fs.appendFileSync(LOG_FILE, `[${timestamp}] ${msg}\n`);
+// 统一音色 → MiniMax 真实音色映射
+const MINIMAX_VOICE_MAP: Record<string, string> = {
+  voice_01: 'cherry',   voice_02: 'ethan',    voice_03: 'chelsie',
+  voice_04: 'serena',   voice_05: 'kai',      voice_06: 'nofish',
+  voice_07: 'momo',     voice_08: 'moon',     voice_09: 'maia',
+  voice_10: 'cherry',   // MiniMax无童声,用cherry代替
+};
+
+// 统一音色 → 阿里云 真实音色映射
+const ALIYUN_VOICE_MAP: Record<string, string> = {
+  voice_01: 'longanyang',    voice_02: 'longsanshu_v3', voice_03: 'longhuhu_v3',
+  voice_04: 'longyue_v3',    voice_05: 'longyichen_v3', voice_06: 'longlaobo_v3',
+  voice_07: 'longmiao_v3',   voice_08: 'longshuo_v3',   voice_09: 'longwan_v3',
+  voice_10: 'longhuhu_v3',
+};
+
+function mapToProviderVoice(unifiedVoiceId: string, provider: string): string {
+  if (provider.startsWith('minimax')) {
+    return MINIMAX_VOICE_MAP[unifiedVoiceId] || MINIMAX_VOICE_MAP['voice_01'];
+  }
+  // 阿里云: CosyVoice 音色ID直接作API参数名(如 longanyang, longyingling_v3)
+  return ALIYUN_VOICE_MAP[unifiedVoiceId] || unifiedVoiceId;
 }
 
-// 可用音色列表(CosyVoice v3-flash - 67个音色)
-// 数据来源:阿里云百炼官方文档 2026-05
-export const VOICES: Voice[] = [
-  // 标杆音色
+// 兼容旧代码的旧版音色列表(保留但不再推荐使用)
+const LEGACY_VOICES: Voice[] = [
   { id: 'longanyang',    name: '龙安洋',   gender: 'male',   description: '阳光大男孩 20~30岁' },
   { id: 'longanhuan',    name: '龙安欢',   gender: 'female', description: '欢脱元气女 20~30岁' },
-  { id: 'longhuhu_v3',   name: '龙呼呼',   gender: 'female', description: '天真烂漫女童 6~10岁' },
-  // 有声书 - 男声
-  { id: 'longsanshu_v3', name: '龙三叔',   gender: 'male',   description: '沉稳质感男 25~45岁' },
-  { id: 'longxiu_v3',    name: '龙修',     gender: 'male',   description: '博才说书男 25~35岁' },
-  { id: 'longnan_v3',    name: '龙楠',     gender: 'male',   description: '睿智青年男 25~30岁' },
-  { id: 'longyichen_v3', name: '龙逸尘',   gender: 'male',   description: '洒脱活力男 20~30岁' },
-  { id: 'longlaobo_v3',  name: '龙老伯',   gender: 'male',   description: '沧桑岁月爷 60岁以上' },
-  // 有声书 - 女声
-  { id: 'longmiao_v3',   name: '龙妙',     gender: 'female', description: '抑扬顿挫女 25~30岁' },
-  { id: 'longyuan_v3',   name: '龙媛',     gender: 'female', description: '温暖治愈女 35~40岁' },
-  { id: 'longyue_v3',    name: '龙悦',     gender: 'female', description: '温暖磁性女 30~35岁' },
-  { id: 'longwanjun_v3', name: '龙婉君',   gender: 'female', description: '细腻柔声女 20~30岁' },
-  { id: 'longlaoyi_v3',  name: '龙老姨',   gender: 'female', description: '烟火从容阿姨 60岁以上' },
-  // 客服/电话
-  { id: 'longyingxun_v3',  name: '龙应询',  gender: 'male',   description: '年轻青涩男 20~25岁' },
-  { id: 'longyingjing_v3', name: '龙应静',  gender: 'female', description: '低调冷静女 25~35岁' },
-  { id: 'longyingling_v3', name: '龙应聆',  gender: 'female', description: '温和共情女 25~30岁' },
-  { id: 'longyingtao_v3',  name: '龙应桃',  gender: 'female', description: '温柔淡定女 25~30岁' },
-  { id: 'longyingmu_v3',   name: '龙应沐',  gender: 'female', description: '优雅知性女 25~30岁' },
-  { id: 'longyingxiao_v3', name: '龙应笑',  gender: 'female', description: '清甜推销女 20~25岁' },
-  // 语音助手
-  { id: 'longxiaochun_v3', name: '龙小淳',  gender: 'female', description: '知性积极女 25~30岁' },
-  { id: 'longxiaoxia_v3',  name: '龙小夏',  gender: 'female', description: '沉稳权威女 25~30岁' },
-  { id: 'longanyun_v3',    name: '龙安昀',  gender: 'male',   description: '居家暖男 30~35岁' },
-  { id: 'longanwen_v3',    name: '龙安温',  gender: 'female', description: '优雅知性女 25~35岁' },
-  { id: 'longanli_v3',     name: '龙安莉',  gender: 'female', description: '利落从容女 25~35岁' },
-  { id: 'longanlang_v3',   name: '龙安朗',  gender: 'male',   description: '清爽利落男 20~25岁' },
-  { id: 'longyumi_v3',     name: 'YUMI',    gender: 'female', description: '正经青年女 20~25岁' },
-  // 社交陪伴
-  { id: 'longanqin_v3',  name: '龙安亲',  gender: 'female', description: '亲和活泼女 20~25岁' },
-  { id: 'longanya_v3',   name: '龙安雅',  gender: 'female', description: '高雅气质女 25~35岁' },
-  { id: 'longanzhi_v3',  name: '龙安智',  gender: 'male',   description: '睿智轻熟男 25~35岁' },
-  { id: 'longanling_v3', name: '龙安灵',  gender: 'female', description: '思维灵动女 20~30岁' },
-  { id: 'longantai_v3',  name: '龙安台',  gender: 'female', description: '嗲甜台湾女 20~25岁' },
-  { id: 'longhua_v3',    name: '龙华',    gender: 'female', description: '元气甜美女 20~25岁' },
-  { id: 'longcheng_v3',  name: '龙橙',    gender: 'male',   description: '智慧青年男 20~25岁' },
-  { id: 'longze_v3',     name: '龙泽',    gender: 'male',   description: '温暖元气男 25~30岁' },
-  { id: 'longzhe_v3',    name: '龙哲',    gender: 'male',   description: '呆板大暖男 25~30岁' },
-  { id: 'longyan_v3',    name: '龙颜',    gender: 'female', description: '温暖春风女 30~35岁' },
-  { id: 'longxing_v3',   name: '龙星',    gender: 'female', description: '温婉邻家女 20~25岁' },
-  { id: 'longtian_v3',   name: '龙天',    gender: 'male',   description: '磁性理智男 30~35岁' },
-  { id: 'longwan_v3',    name: '龙婉',    gender: 'female', description: '细腻柔声女 20~30岁' },
-  { id: 'longqiang_v3',  name: '龙嫱',    gender: 'female', description: '浪漫风情女 30~35岁' },
-  { id: 'longfeifei_v3', name: '龙菲菲',  gender: 'female', description: '甜美娇气女 20~25岁' },
-  { id: 'longhao_v3',    name: '龙浩',    gender: 'male',   description: '多情忧郁男 30~35岁' },
-  { id: 'longanrou_v3',  name: '龙安柔',  gender: 'female', description: '温柔闺蜜女 20~35岁' },
-  { id: 'longhan_v3',    name: '龙寒',    gender: 'male',   description: '温暖痴情男 30~35岁' },
-  // 童声
-  { id: 'longpaopao_v3',    name: '龙泡泡',   gender: 'female', description: '飞天泡泡音 6~15岁' },
-  { id: 'longjielidou_v3',  name: '龙杰力豆', gender: 'male',   description: '阳光顽皮男 10岁' },
-  { id: 'longxian_v3',      name: '龙仙',     gender: 'female', description: '豪放可爱女 12岁' },
-  { id: 'longling_v3',      name: '龙铃',     gender: 'female', description: '稚气呆板女 10岁' },
-  { id: 'longshanshan_v3',  name: '龙闪闪',   gender: 'female', description: '戏剧化童声 6~15岁' },
-  { id: 'longniuniu_v3',    name: '龙牛牛',   gender: 'male',   description: '阳光男童声 6~15岁' },
-  // 方言
-  { id: 'longjiaxin_v3',  name: '龙嘉欣',  gender: 'female', description: '优雅粤语女 30~35岁' },
-  { id: 'longjiayi_v3',   name: '龙嘉怡',  gender: 'female', description: '知性粤语女 25~30岁' },
-  { id: 'longanyue_v3',   name: '龙安粤',  gender: 'male',   description: '欢脱粤语男 25~35岁' },
-  { id: 'longlaotie_v3',  name: '龙老铁',  gender: 'male',   description: '东北直率男 25~30岁' },
-  { id: 'longshange_v3',  name: '龙陕哥',  gender: 'male',   description: '陕北原味男 25~35岁' },
-  // 新闻/直播
-  { id: 'longshuo_v3',    name: '龙硕',    gender: 'male',   description: '博才干练男 25~30岁' },
-  { id: 'longshu_v3',     name: '龙书',    gender: 'male',   description: '沉稳青年男 20~25岁' },
-  { id: 'loongbella_v3',  name: 'Bella3.0',gender: 'female', description: '精准干练女 25~30岁' },
-  { id: 'longanran_v3',   name: '龙安燃',  gender: 'female', description: '活泼质感女 30~40岁' },
-  { id: 'longanxuan_v3',  name: '龙安宣',  gender: 'female', description: '经典直播女 30~40岁' },
-  // 特色
-  { id: 'longjiqi_v3',   name: '龙机器',  gender: 'male',   description: '呆萌机器人 20~30岁' },
-  { id: 'longhouge_v3',  name: '龙猴哥',  gender: 'male',   description: '经典猴哥 20~25岁' },
-  { id: 'longdaiyu_v3',  name: '龙黛玉',  gender: 'female', description: '娇率才女音 15~25岁' },
-  { id: 'longfei_v3',    name: '龙飞',    gender: 'male',   description: '热血磁性男 30~35岁' },
-  // 外语
-  { id: 'loongkyong_v3', name: '韩语女',   gender: 'female', description: '韩语女声' },
-  { id: 'loongriko_v3',  name: 'Riko霓虹', gender: 'female', description: '二次元日语女' },
-  { id: 'loongabby_v3',  name: '美语女',   gender: 'female', description: '美式英文女' },
-  { id: 'loongandy_v3',  name: '美语男',   gender: 'male',   description: '美式英文男' },
 ];
 
-// CosyVoice v3-flash 音色ID直接用作API参数名,无需映射
 export function getAliyunVoice(voiceId: string): string {
+  // 旧音色ID直接返回(新音色已统一映射)
   return voiceId;
 }
 
@@ -135,7 +88,7 @@ async function getOrCreateDefaultBook(userId: string): Promise<number> {
       description: '我的语音合成音频收藏',
       userId: userIdNum,
       genStage: 'content_completed',
-      bookScale: 'short',
+      bookScale: '2000',
       totalChapters: 0,
       estimatedWords: 0,
     },
@@ -379,9 +332,8 @@ async function processAudioGeneration(
     ttsProvider?: 'aliyun' | 'minimax';
   }
 ) {
-  const logMsg = `🔄 开始处理音频 ID: ${audioId}, 文本长度: ${text.length}, voiceId: ${voiceId}`;
-  console.log(logMsg);
-  logToFile(logMsg);
+  const logMsg = `开始处理音频 ID: ${audioId}, 文本长度: ${text.length}, voiceId: ${voiceId}`;
+  ttsLogger.info(logMsg);
 
   const registry = getTtsRegistry();
 
@@ -418,10 +370,10 @@ async function processAudioGeneration(
 
     try {
     console.log(`🔊 使用 TTS Provider: ${tts.name} (vendor=${tts.vendor}, mode=${tts.mode})`);
-    logToFile(`Provider: ${tts.name}, vendor=${tts.vendor}, mode=${tts.mode}`);
+    ttsLogger.debug(`Provider: ${tts.name}, vendor=${tts.vendor}, mode=${tts.mode}`);
 
-    // 解析音色名称(MiniMax 和阿里云用不同的音色映射表
-    const voiceName = tts.vendor.startsWith('minimax') ? getMiniMaxVoice(voiceId) : getAliyunVoice(voiceId);
+    // 解析音色名称(使用统一音色映射到具体Provider的真实音色
+    const voiceName = mapToProviderVoice(voiceId, tts.vendor);
 
     // 按模型配置分段:有 maxTextLength 就用它的 80%,没配置就用默认 1000
     const segmentMax = tts.maxTextLength ? Math.floor(tts.maxTextLength * 0.8) : 1000;
@@ -506,16 +458,7 @@ async function processAudioGeneration(
     ]);
     const finalAudioUrl = audioUrl;
 
-    // LRC 歌词时间轴
-    let lrcLyrics = '';
-    try {
-      const localAudioPath = path.join(audioDir, 'output.mp3');
-      lrcLyrics = generateLrc(text, duration, localAudioPath);
-    } catch (lrcErr: any) {
-      console.error(`❌ LRC 生成异常:`, lrcErr.message);
-    }
-
-    // 保存到书籍章节
+    // 保存到书籍章节(先不带LRC,因为异步生成可能还没完成)
     const targetBookId = options?.bookId ? parseInt(options.bookId) : null;
     const targetChapterId = options?.chapterId ?? null;
     if (targetBookId && targetChapterId) {
@@ -525,7 +468,6 @@ async function processAudioGeneration(
           data: {
             audioUrl: finalAudioUrl,
             audioDuration: duration,
-            lrcLyrics: lrcLyrics || null,
             generatedAt: new Date(),
           },
         });
@@ -535,6 +477,22 @@ async function processAudioGeneration(
       }
     }
 
+    // LRC 歌词时间轴(异步,不阻塞主流程,失败可接受)
+    const localAudioPath = path.join(audioDir, 'output.mp3');
+    Promise.resolve().then(async () => {
+      try {
+        const lrc = await generateLrc(text, duration, localAudioPath);
+        if (lrc && targetChapterId) {
+          prisma.bookChapter.update({
+            where: { id: targetChapterId },
+            data: { lrcLyrics: lrc },
+          }).catch(() => {});
+        }
+      } catch (lrcErr: any) {
+        console.warn(`⚠️ LRC 生成失败: ${lrcErr.message}`);
+      }
+    });
+
     // 更新 AudioRecord 为完成
     try {
       await prisma.audioRecord.update({
@@ -568,29 +526,32 @@ async function processAudioGeneration(
       return { audioId, audioUrl: finalAudioUrl, bookId: targetBookId };
 
     } catch (error: any) {
-      // 自动降级:与 LLM 共用 shouldSwitchModel 判断可恢复错误
       const errorMsg = error?.message || '';
-      const isRecoverable = error instanceof CircuitBreakerOpenError
-        || config.models.shouldSwitchModel(error);
-
-      // 额度耗尽立即标记(4小时自动恢复)
-      if (['quota', 'balance', 'insufficient', 'usage limit'].some(k => errorMsg.toLowerCase().includes(k))) {
-        registry.markExhausted(tts.name, errorMsg, 4 * 60 * 60 * 1000);
-      }
+      
+      // 如果还有下一个 Provider 可降级,就尝试
+      const nextNode = registry.getNextAvailable(tts.name);
+      if (nextNode && !visited.has(nextNode.provider.name)) {
+        ttsLogger.fallback(tts.name, nextNode.provider.name, errorMsg.substring(0, 80));
+
+        // 标记当前 Provider 的健康状态(用于后续请求)
+        if (['quota', 'balance', 'insufficient', 'usage limit'].some(k => errorMsg.toLowerCase().includes(k))) {
+          registry.markExhausted(tts.name, errorMsg, 4 * 60 * 60 * 1000);
+          ttsLogger.quota(tts.name, 'exhausted', errorMsg);
+        }
 
-      if (isRecoverable) {
-        console.error(`❌ Provider ${tts.name} 失败(可恢复),通过 getNextAvailable 降级...`);
-        currentNode = registry.getNextAvailable(tts.name);
+        currentNode = nextNode;
         continue;
       }
 
-      // 非可恢复错误,直接抛出
-      console.error(`❌ processAudioGeneration 失败(非可恢复错误):`, errorMsg);
-      throw error;
+      // 没有更多 Provider 可降级
+      const triedList = [...visited, tts.name].join(' → ');
+      const detailMsg = `所有 Provider 尝试失败 [${triedList}],最后错误: ${errorMsg.substring(0, 100)}`;
+      ttsLogger.synthesisFailed(audioId, tts.name, errorMsg, false);
+      throw new Error(detailMsg);
     }
   }
 
-  // 所有 Provider 都试过了,仍然失败
+  // 所有 Provider 都试过了
   throw new Error('所有 TTS Provider 都已尝试,均无法生成音频');
 }
 
@@ -619,8 +580,7 @@ async function synthesizeSegmentWithRetry(
     if (!config.models.shouldSwitchModel(err)) throw err;
 
     // 可恢复错误:延迟后重试1次(与 LLM 相同的 2s 延迟)
-    console.log(`[TTS] 片段合成失败(${errorMsg.substring(0, 80)}),2s后重试...`);
-    logToFile(`[Retry] 片段合成失败,2s后重试: ${errorMsg.substring(0, 100)}`);
+    ttsLogger.warn(`片段合成失败,2s后重试: ${errorMsg.substring(0, 80)}`);
     await new Promise(resolve => setTimeout(resolve, 2000));
 
     return await node.breaker.call(() =>
@@ -684,9 +644,9 @@ export async function getAudioStatus(audioId: string): Promise<{ status: string;
   return { status: 'not_found' };
 }
 
-// 获取可用音色
+// 获取可用音色(统一10个音色)
 export function getVoices(): Voice[] {
-  return VOICES;
+  return UNIFIED_VOICES;
 }
 
 // ============ LRC 歌词生成 ============
@@ -877,7 +837,7 @@ export function generateLrc(text: string, duration: number, audioPath?: string):
     const sentences = splitIntoLrcSentences(text);
     if (sentences.length === 0) return '';
 
-    logToFile(`🎵 LRC 拆分: ${sentences.length} 句, 总时长=${duration}s`);
+    ttsLogger.lrc(`拆分: ${sentences.length} 句, 总时长=${duration}s`);
 
     // 计算总可见字符数
     const totalChars = sentences.reduce((sum, s) => sum + countVisible(s), 0);
@@ -885,7 +845,7 @@ export function generateLrc(text: string, duration: number, audioPath?: string):
 
     // 核心:均匀语速 = 总时长 / 总字数
     const speechRate = duration / totalChars; // 秒/字
-    logToFile(`🎵 语速: ${speechRate.toFixed(3)}s/字, 总字数=${totalChars}`);
+    ttsLogger.lrc(`语速: ${speechRate.toFixed(3)}s/字, 总字数=${totalChars}`);
 
     const lines: string[] = [];
     let currentTime = 0;
@@ -899,7 +859,7 @@ export function generateLrc(text: string, duration: number, audioPath?: string):
 
       lines.push(`[${formatLrcTimestamp(currentTime)}] ${sanitizeLrcText(sentences[i])}`);
       
-      logToFile(`  [${formatLrcTimestamp(currentTime)}] ${charCount}字 ${sentences[i].substring(0, 30)}...`);
+      ttsLogger.lrc(`[${formatLrcTimestamp(currentTime)}] ${charCount}字 ${sentences[i].substring(0, 30)}...`);
       
       currentTime += lineDuration;
     }
@@ -919,17 +879,17 @@ export function generateLrc(text: string, duration: number, audioPath?: string):
         rescaledLines.push(`[${formatLrcTimestamp(rescaledTime)}] ${sanitizeLrcText(sentences[i])}`);
         rescaledTime += scaledLineDuration;
       }
-      logToFile(`🎵 LRC 标题补偿缩放: 原始总时长=${currentTime.toFixed(2)}s, 缩放因子=${scaleFactor.toFixed(3)}`);
+      ttsLogger.lrc(`标题补偿缩放: 原始总时长=${currentTime.toFixed(2)}s, 缩放因子=${scaleFactor.toFixed(3)}`);
       return rescaledLines.join('\n');
     }
 
     // 确保最后一行不超过总时长
-    logToFile(`🎵 LRC 完成: ${lines.length} 行, 末尾时间=${currentTime.toFixed(2)}s, 音频时长=${duration}s`);
+    ttsLogger.lrc(`完成: ${lines.length} 行, 末尾时间=${currentTime.toFixed(2)}s, 音频时长=${duration}s`);
 
     return lines.join('\n');
   } catch (err: any) {
-    logToFile(`❌ generateLrc 异常: ${err.message}`);
-    console.error(`❌ generateLrc 异常:`, err.message);
+    ttsLogger.error('generateLrc 异常', err as Error);
+    console.error(`❌ generateLrc 异常:`, (err as Error).message);
     return buildSimpleLrc(text, duration);
   }
 }
@@ -1018,7 +978,7 @@ export async function generatePreview(
     if (node.exhausted || node.breaker.isOpen()) continue;
 
     const tts = node.provider;
-    const voiceName = tts.vendor.startsWith('minimax') ? getMiniMaxVoice(voiceId) : getAliyunVoice(voiceId);
+    const voiceName = mapToProviderVoice(voiceId, tts.vendor);
 
     if (tts.mode === 'mock') {
       const mockPath = path.join(audioDir, 'preview.mp3');

+ 2 - 0
server/src/services/ai-call-logger.ts

@@ -11,6 +11,7 @@ export interface LogCallParams {
   provider: string;    // minimax | bailian | volcengine
   model: string;       // speech-2.8-hd | qwen3-tts-instruct-flash | MiniMax-M2.7
   textLen?: number;
+  prompt?: string;     // 提示词内容
   tokenCount?: number;
   duration?: number;
   success?: boolean;
@@ -32,6 +33,7 @@ export function logAiCall(params: LogCallParams): void {
       provider: params.provider,
       model: params.model,
       textLen: params.textLen ?? 0,
+      prompt: params.prompt ?? null,
       duration: params.duration ?? 0,
       success: params.success ?? true,
       errorMsg: params.errorMsg?.substring(0, 200) ?? null,

+ 5 - 3
server/src/services/llm/index.ts

@@ -294,9 +294,10 @@ export async function callLLMWithMessages(
   try {
     const llm = getLLM(id, maxTokens);
     const baseMessages = toBaseMessages(messages);
+    const prompt = messages.map(m => `${m.role}: ${m.content}`).join('\n');
     const response = await withAiLog(
       () => llm.invoke(baseMessages as any),
-      { callType: 'llm_chat', provider, model: id, textLen: messages.reduce((s, m) => s + (m.content?.length || 0), 0) }
+      { callType: 'llm_chat', provider, model: id, textLen: messages.reduce((s, m) => s + (m.content?.length || 0), 0), prompt }
     );
 
     const responseContent = cleanLlmResponse(response.content as string);
@@ -321,7 +322,7 @@ export async function callLLMWithMessages(
       const baseMessages = toBaseMessages(messages);
       const response = await withAiLog(
         () => llm.invoke(baseMessages as any),
-        { callType: 'llm_chat_retry', provider, model: id, textLen: messages.reduce((s, m) => s + (m.content?.length || 0), 0) }
+        { callType: 'llm_chat_retry', provider, model: id, textLen: messages.reduce((s, m) => s + (m.content?.length || 0), 0), prompt }
       );
       const responseContent = cleanLlmResponse(response.content as string);
       console.log('[AI响应] 重试成功 (前1000字):', responseContent.substring(0, 1000));
@@ -401,9 +402,10 @@ async function callLLMWithToolsImpl(
   for (let round = 0; round < maxRounds; round++) {
     let response: any;
     try {
+      const prompt = conversationMessages.map(m => `${m._getType()}: ${m.content}`).join('\n');
       response = await withAiLog(
         () => llmWithTools.invoke(conversationMessages as any),
-        { callType: 'llm_tools', provider: resolveProviderKey(modelId), model: modelId }
+        { callType: 'llm_tools', provider: resolveProviderKey(modelId), model: modelId, prompt }
       );
     } catch (err: any) {
       console.warn(`[LLM] 模型 ${modelId} 调用失败: ${err?.message},尝试下一个模型`);