Jelajahi Sumber

refactor: TTS直接生成复用有声书逻辑,移除MiniMax TTS

MyFramework User 3 bulan lalu
induk
melakukan
268e28a232

+ 14 - 273
my-uniapp-vue3/src/pages/create/index.vue

@@ -147,7 +147,7 @@
       </view>
 
       <!-- 视频生成入口 -->
-      <view class="video-entry-card" @click="goToVideoGenerator">
+      <view v-if="false" class="video-entry-card" @click="goToVideoGenerator">
         <view class="video-entry-content">
           <text class="video-entry-icon">🎬</text>
           <view class="video-entry-text">
@@ -159,39 +159,6 @@
       </view>
     </view>
 
-    <!-- 生成成功弹窗 -->
-    <view v-if="showSuccessModal" class="success-modal" @click="closeSuccessModal">
-      <view class="success-content" @click.stop>
-        <!-- 成功动画 -->
-        <view class="success-animation">
-          <view class="success-circle">
-            <text class="success-checkmark">✓</text>
-          </view>
-          <view class="success-sparkles">
-            <text class="sparkle">✨</text>
-            <text class="sparkle">🌟</text>
-            <text class="sparkle">✨</text>
-          </view>
-        </view>
-
-        <text class="success-title">生成成功!</text>
-        <text class="success-desc">音频已准备就绪</text>
-
-        <!-- 操作按钮 -->
-        <view class="success-actions">
-          <button class="success-btn play-btn" @click="goToPlayer">
-            <text>▶ 立即播放</text>
-          </button>
-          <button class="success-btn share-btn" @click="shareAudio">
-            <text>📤 分享给好友</text>
-          </button>
-          <button class="success-btn close-btn" @click="closeSuccessModal">
-            <text>继续生成</text>
-          </button>
-        </view>
-      </view>
-    </view>
-
     <!-- 新手引导 -->
     <view v-if="showGuide" class="guide-overlay">
       <view class="guide-content">
@@ -320,7 +287,7 @@ const audioStore = useAudioStore();
 
 // 状态
 const text = ref('');
-const selectedVoice = ref('cherry');
+const selectedVoice = ref('voice_01');
 const voiceParams = ref<VoiceParams>({
   speed: 1.0,
   pitch: 0,
@@ -342,11 +309,6 @@ const showAlbumPanel = ref(false);
 const newAlbumTitle = ref('');
 const newAlbumDescription = ref('');
 
-// 成功弹窗状态
-const showSuccessModal = ref(false);
-const generatedAudioId = ref('');
-const generatedBookId = ref<number | null>(null);
-
 // 新手引导状态
 const showGuide = ref(false);
 const guideStep = ref(1);
@@ -551,27 +513,8 @@ async function handleGenerate() {
     return;
   }
 
-  // 如果没有选择专辑,自动创建默认专辑
-  let bookId = selectedAlbum.value?.id;
-  if (!bookId) {
-    try {
-      uni.showLoading({ title: '创建专辑...' });
-      const result = await post<{ id: number | string; title: string; description?: string }>('/book-generator/albums', {
-        title: '我的音频',
-        description: '自动创建的默认专辑',
-      });
-      if (result && result.id) {
-        bookId = String(result.id);
-        selectedAlbum.value = { id: bookId, title: result.title };
-        await fetchAlbums();
-        uni.showToast({ title: '已创建默认专辑', icon: 'none' });
-      }
-    } catch (error) {
-      console.error('创建默认专辑失败:', error);
-    } finally {
-      uni.hideLoading();
-    }
-  }
+  // 如果没有选择专辑,使用后端自动创建的默认书籍
+  const bookId = selectedAlbum.value?.id;
 
   generating.value = true;
   try {
@@ -579,29 +522,27 @@ async function handleGenerate() {
       text.value,
       selectedVoice.value,
       voiceParams.value,
-      {
-        bookId,
-      }
+      { bookId }
     );
 
-    // 保存生成的音频信息
-    generatedAudioId.value = result.audioId;
-    generatedBookId.value = result.bookId || null;
-
-    // 显示成功弹窗
-    showSuccessModal.value = true;
-
     // 清空文本,方便下次输入
     text.value = '';
+
+    // 和书籍创建一样,跳转到详情页等待音频生成
+    uni.showToast({ title: '任务已提交', icon: 'success' });
+    setTimeout(() => {
+      uni.navigateTo({
+        url: `/pages/book-generator/detail?id=${result.bookId}`
+      });
+    }, 800);
   } catch (error: any) {
     console.error('生成失败:', error);
 
-    // 检查是否是额度用尽错误
     if (error.message?.includes('额度') || error.message?.includes('quota')) {
       showQuotaModal.value = true;
     } else {
       uni.showToast({
-        title: error.message || '生成失败,请重试',
+        title: error.message || '提交失败,请重试',
         icon: 'none'
       });
     }
@@ -610,54 +551,6 @@ async function handleGenerate() {
   }
 }
 
-// 关闭成功弹窗
-function closeSuccessModal() {
-  showSuccessModal.value = false;
-  generatedAudioId.value = '';
-  generatedBookId.value = null;
-}
-
-// 跳转到专辑详情页
-function goToPlayer() {
-  if (generatedAudioId.value) {
-    // 音频生成是异步的,audioUrl 可能还是空,用 /uploads/:audioId/output.mp3 直接播放
-    audioStore.play({
-      id: generatedAudioId.value,
-      title: '新生成音频',
-      audioDuration: 0,
-      wordCount: 0,
-      audioUrl: `/uploads/${generatedAudioId.value}/output.mp3`,
-    } as any);
-    closeSuccessModal();
-  } else if (generatedBookId.value) {
-    uni.navigateTo({
-      url: `/pages/album/index?id=${generatedBookId.value}`,
-    });
-    closeSuccessModal();
-  } else {
-    closeSuccessModal();
-  }
-}
-
-// 分享音频
-function shareAudio() {
-  if (generatedBookId.value) {
-    // #ifdef H5
-    const shareUrl = `${window.location.origin}/#/pages/album/index?id=${generatedBookId.value}`;
-    uni.setClipboardData({
-      data: shareUrl,
-      success: () => {
-        uni.showToast({ title: '链接已复制', icon: 'success' });
-      }
-    });
-    // #endif
-
-    // #ifndef H5
-    uni.showToast({ title: '分享功能开发中', icon: 'none' });
-    // #endif
-  }
-}
-
 // 新手引导 - 下一步
 function nextGuideStep() {
   if (guideStep.value < guideSteps.length) {
@@ -1236,158 +1129,6 @@ function goToMember() {
   gap: 8rpx;
 }
 
-/* 成功弹窗 */
-.success-modal {
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  background: rgba(0, 0, 0, 0.6);
-  z-index: 2000;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.success-content {
-  width: 600rpx;
-  background: #ffffff;
-  border-radius: 32rpx;
-  padding: 60rpx 40rpx;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-}
-
-.success-animation {
-  position: relative;
-  margin-bottom: 40rpx;
-}
-
-.success-circle {
-  width: 160rpx;
-  height: 160rpx;
-  border-radius: 50%;
-  background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  animation: success-pop 0.5s ease;
-}
-
-.success-checkmark {
-  font-size: 80rpx;
-  color: #ffffff;
-  font-weight: bold;
-}
-
-.success-sparkles {
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-  width: 200rpx;
-  height: 200rpx;
-}
-
-.sparkle {
-  position: absolute;
-  font-size: 32rpx;
-  animation: sparkle 1.5s ease-in-out infinite;
-}
-
-.sparkle:nth-child(1) {
-  top: 0;
-  left: 50%;
-  animation-delay: 0s;
-}
-
-.sparkle:nth-child(2) {
-  top: 50%;
-  right: 0;
-  animation-delay: 0.5s;
-}
-
-.sparkle:nth-child(3) {
-  bottom: 0;
-  left: 50%;
-  animation-delay: 1s;
-}
-
-@keyframes success-pop {
-  0% {
-    transform: scale(0);
-  }
-  50% {
-    transform: scale(1.1);
-  }
-  100% {
-    transform: scale(1);
-  }
-}
-
-@keyframes sparkle {
-  0%, 100% {
-    opacity: 0;
-    transform: scale(0.5);
-  }
-  50% {
-    opacity: 1;
-    transform: scale(1);
-  }
-}
-
-.success-title {
-  font-size: 40rpx;
-  font-weight: 600;
-  color: #1f2937;
-  margin-bottom: 12rpx;
-}
-
-.success-desc {
-  font-size: 28rpx;
-  color: #6b7280;
-  margin-bottom: 40rpx;
-}
-
-.success-actions {
-  width: 100%;
-  display: flex;
-  flex-direction: column;
-  gap: 20rpx;
-}
-
-.success-btn {
-  width: 100%;
-  height: 88rpx;
-  border-radius: 44rpx;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  font-size: 30rpx;
-  border: none;
-}
-
-.success-btn::after {
-  border: none;
-}
-
-.play-btn {
-  background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
-  color: #ffffff;
-}
-
-.share-btn {
-  background: #f3f4f6;
-  color: #4b5563;
-}
-
-.close-btn {
-  background: transparent;
-  color: #9ca3af;
-}
-
 /* 新手引导 */
 .guide-overlay {
   position: fixed;

+ 27 - 8
my-uniapp-vue3/src/store/audio.ts

@@ -152,20 +152,24 @@ export const useAudioStore = defineStore('audio', () => {
     return result.voices;
   }
 
-  // 生成音频
+  // 生成音频(异步模式 - 复用有声书逻辑)
   async function generateAudio(
     text: string,
     voiceId: string,
-    voiceParams: VoiceParams
+    voiceParams: VoiceParams,
+    options?: { bookId?: string }
   ) {
-    uni.showLoading({ title: '生成中...', mask: true });
+    uni.showLoading({ title: '提交中...', mask: true });
     try {
       const res = await post<{
-        audioId: string;
-        audioUrl: string;
-        duration: number;
-        size: number;
-      }>('/tts/generate', { text, voiceId, voiceParams });
+        chapterId: number;
+        bookId: number;
+      }>('/tts/generate', {
+        text,
+        voiceId,
+        voiceParams,
+        bookId: options?.bookId,
+      });
 
       uni.hideLoading();
       return res;
@@ -175,6 +179,20 @@ export const useAudioStore = defineStore('audio', () => {
     }
   }
 
+  // 轮询章节音频生成状态
+  async function fetchChapterStatus(chapterId: number) {
+    const res = await get<{
+      chapterId: number;
+      genStage: string;
+      audioUrl: string | null;
+      audioDuration: number;
+      title: string;
+      isReady: boolean;
+      isFailed: boolean;
+    }>(`/tts/chapter-status/${chapterId}`);
+    return res;
+  }
+
   // 播放音频
   function play(audio: AudioItem) {
     initAudioContext();
@@ -371,6 +389,7 @@ export const useAudioStore = defineStore('audio', () => {
     // 方法
     fetchVoices,
     generateAudio,
+    fetchChapterStatus,
     play,
     pause,
     resume,

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

@@ -54,6 +54,7 @@ function shouldSwitchModel(error: any): boolean {
     'invalid token', 'token expired',
     'permission denied', 'access denied',
     'invalid request', 'bad request',
+    'invalidparameter', 'invalid_parameter',  // TTS API 参数错误(如文本过短)
   ];
   if (nonSwitchablePatterns.some(p => message.includes(p))) return false;
   if (status === 401) return false; // 认证失败

+ 1 - 3
server/src/config/models.json

@@ -10,7 +10,6 @@
       "models": [
         {"id": "MiniMax-M2.7","name": "MiniMax M2.7","input": ["text"],"contextWindow": 204800,"maxTokens": 16000,"temperature": 0.7,"supportsToolCall": true,"enabled": true},
         {"id": "MiniMax-M2.7-highspeed","name": "MiniMax M2.7 极速版","input": ["text"],"contextWindow": 204800,"maxTokens": 16000,"temperature": 0.7,"supportsToolCall": true,"enabled": true},
-        {"id": "speech-2.8-hd","name": "MiniMax Speech-2.8-HD","input": ["tts"],"apiPath": "/t2a_async_v2","maxTextLength": 800000,"enabled": false, "reason": "TTS主力用CosyVoice"},
         {"id": "MiniMax-image-01","name": "MiniMax image-01","input": ["image"],"apiPath": "/image_generation","enabled": true},
         {"id": "MiniMax-Hailuo-02","name": "MiniMax Hailuo 02","input": ["video"],"apiPath": "/video_generation","enabled": true}
       ]
@@ -24,8 +23,7 @@
       "priority": 2,
       "models": [
         {"id": "MiniMax-M2.7","name": "MiniMax M2.7","input": ["text"],"contextWindow": 204800,"maxTokens": 16000,"temperature": 0.7,"supportsToolCall": true,"enabled": true},
-        {"id": "MiniMax-M2.7-highspeed","name": "MiniMax M2.7 极速版","input": ["text"],"contextWindow": 204800,"maxTokens": 16000,"temperature": 0.7,"supportsToolCall": true,"enabled": true},
-        {"id": "speech-2.8-hd","name": "MiniMax Speech-2.8-HD","input": ["tts"],"apiPath": "/t2a_async_v2","maxTextLength": 800000,"enabled": false, "reason": "TTS主力用CosyVoice"}
+        {"id": "MiniMax-M2.7-highspeed","name": "MiniMax M2.7 极速版","input": ["text"],"contextWindow": 204800,"maxTokens": 16000,"temperature": 0.7,"supportsToolCall": true,"enabled": true}
       ]
     },
     "bailian": {

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

@@ -1023,7 +1023,7 @@ export class BookStore {
    *   - 已在 audio_generating 的章节不会被回退
    *   - 无递归调用,重试由队列处理器平铺循环控制
    */
-  async generateChapterAudioById(chapterId: number, userId?: number): Promise<{
+  async generateChapterAudioById(chapterId: number, userId?: number, voiceId?: string): Promise<{
     audioUrl: string;
   } | null> {
     // ===== 步骤 1:读取章节状态 =====
@@ -1105,19 +1105,22 @@ export class BookStore {
     if (existingFailed) {
       // 复用已有 failed 任务,重置为 pending,但累加 retryCount(保留历史重试记录)
       const prevRetryCount = existingFailed.retryCount || 0;
+      // 关键:更新 voiceId 为当前有效的音色,避免旧任务用已禁用的 MiniMax 音色导致循环失败
+      const taskVoiceId = voiceId || 'longyingling_v3';
       task = await prisma.ttsTask.update({
         where: { id: existingFailed.id },
         data: {
           status: 'pending',
           content: chapterBefore.content,
           contentHash,
+          voiceId: taskVoiceId,
           retryCount: prevRetryCount,  // 保留历史重试次数,不重置为0
           errorMsg: null,
           startedAt: null,
           completedAt: null,
         },
       });
-      console.log(`[Audio] 复用已有任务#${existingFailed.id}: chapterId=${chapterId}, 累计重试=${prevRetryCount}次`);
+      console.log(`[Audio] 复用已有任务#${existingFailed.id}: chapterId=${chapterId}, voiceId=${taskVoiceId}, 累计重试=${prevRetryCount}次`);
     } else {
       console.log(`[Audio] 创建TTS任务: chapterId=${chapterId}, 内容长度=${chapterBefore.content.length}, hash=${contentHash.substring(0, 12)}...`);
       task = await prisma.ttsTask.create({
@@ -1128,7 +1131,7 @@ export class BookStore {
         userId: userId || chapterBefore.book?.userId || null,
         contentHash,
         content: chapterBefore.content, // 保存提交时的内容副本
-        voiceId: 'longyingling_v3',
+        voiceId: voiceId || 'longyingling_v3',  // 支持外部传入音色,默认兼容有声书
         status: 'pending',
       },
     });
@@ -1369,6 +1372,12 @@ export class BookStore {
       return;
     }
 
+    // 最小字数校验:过短文本 TTS API 会报 InvalidParameter,触发熔断
+    if (content.trim().length < 10) {
+      await this._failTask(taskId, '文本过短(少于10字),无法生成音频');
+      return;
+    }
+
     console.log(`[TtsTask] 开始处理任务#${taskId}, chapterId=${chapterId}, 内容长度=${content.length}`);
 
     try {
@@ -1384,7 +1393,7 @@ export class BookStore {
       const result = await generateAudio(
         String(task.userId || chapter.book?.userId || '0'),
         content,
-        'longyingling_v3',
+        task.voiceId || 'longyingling_v3',  // 使用任务中的音色,兼容旧数据
         { speed: 1.0, pitch: 0, volume: 50 },
         async (audioUrl: string, duration: number) => {
           console.log(`[TtsTask] #${taskId} 音频就绪: ${audioUrl?.substring(0, 60)}...`);
@@ -1423,6 +1432,20 @@ export class BookStore {
       console.error(`[TtsTask] ❌ 任务#${taskId} 失败:`, errorMsg);
       logAiCall({ callType: 'tts_task_failed', provider: 'tts', model: 'tts-task', textLen: content?.length || 0, success: false, errorMsg, chapterId, bookId: task.bookId ?? undefined });
       await this._failTask(taskId, errorMsg);
+
+      // 不可恢复错误(参数错误等)→ 标记章节为 failed,阻止 AudioScanner 反复重试
+      const lowerMsg = errorMsg.toLowerCase();
+      if (lowerMsg.includes('invalidparameter') || lowerMsg.includes('invalid_parameter')) {
+        try {
+          await prisma.bookChapter.update({
+            where: { id: chapterId },
+            data: { genStage: 'failed', contentError: errorMsg.substring(0, 500) },
+          });
+          console.log(`[TtsTask] 🔒 章节${chapterId}标记为failed(不可恢复错误),不再自动重试`);
+        } catch (chapterErr) {
+          console.warn(`[TtsTask] 标记章节失败状态失败:`, chapterErr);
+        }
+      }
     }
   }
 

+ 9 - 0
server/src/modules/subscription/subscription.service.ts

@@ -610,6 +610,10 @@ export async function getUserAudioBalance(userId: number) {
   }
   
   const memberLevel = user.memberLevel as MemberLevel;
+  // 测试账号:memberLevel = -1 表示无限额度
+  if (memberLevel === -1) {
+    return { memberLevel: -1, totalMinutes: -1, usedMinutes: 0, remainingMinutes: -1, overageEnabled: false, overagePrice: 0, resetDate: null };
+  }
   const monthlyMinutes = AUDIO_BILLING_CONFIG.monthlyMinutes[memberLevel] || 10;
   const overageEnabled = AUDIO_BILLING_CONFIG.overagePrice[memberLevel] !== null;
   const overagePrice = AUDIO_BILLING_CONFIG.overagePrice[memberLevel];
@@ -657,6 +661,11 @@ export async function checkAudioQuota(
   const audioMinutes = calculateAudioDuration(textLength);
   const balance = await getUserAudioBalance(userId);
   
+  // 测试账号:无限额度
+  if (balance.totalMinutes === -1) {
+    return { allowed: true, reason: null, audioMinutes, remainingMinutes: -1, totalMinutes: -1 };
+  }
+  
   // 免费版不支持超出配额
   if (balance.remainingMinutes < audioMinutes && !balance.overageEnabled) {
     return {

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

@@ -89,6 +89,8 @@ export class AliyunTtsProvider implements ITtsProvider {
           requestBody.input.format = 'mp3';
           requestBody.input.sample_rate = 24000;
           const parts: string[] = [];
+          // Instrut 情感/场景控制(优先级最高,放在最前面)
+          if ((params as any).instructText) parts.push((params as any).instructText);
           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 ? '较大' : '较小'}`);
@@ -96,6 +98,7 @@ export class AliyunTtsProvider implements ITtsProvider {
         }
         if (!isCosyVoice && activeModel.includes('instruct')) {
           const instructions: string[] = [];
+          if ((params as any).instructText) instructions.push((params as any).instructText);
           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(',');

+ 0 - 332
server/src/modules/tts/minimax.provider.ts

@@ -1,332 +0,0 @@
-/**
- * MiniMax TTS Provider (异步长文本语音合成)
- * 文档: https://platform.minimaxi.com/docs/api-reference/speech-t2a-async-create
- *
- * API 限制:
- *   - 异步长文本: 1,000,000 字符(业界最长)
- *   - 同步接口: 10,000 字符(>3,000 推荐异步)
- *
- * 流程:
- * 1. POST /v1/t2a_async_v2 创建任务,获取 task_id / task_token / file_id
- * 2. GET /v1/query/t2a_async_query_v2 轮询任务状态 (响应字段: status)
- * 3. GET /v1/files/retrieve_content?file_id=xxx 下载音频
- *
- * 实测可用模型: speech-2.8-hd (speech-2.8-turbo 等需要付费计划)
- */
-
-import axios from 'axios';
-import fs from 'fs';
-import path from 'path';
-import { config } from '../../config';
-import { VoiceParams } from '../../types';
-import { withAiLog } from '../../services/ai-call-logger';
-import { ITtsProvider } from './provider.interface';
-
-const API_BASE = 'https://api.minimaxi.com';
-const POLL_INTERVAL_BASE = 3000; // 基础轮询间隔 3 秒
-
-/** 根据文本长度计算轮询间隔:越长等越久,减少无效查询 */
-function getPollInterval(textLength: number): number {
-  // 每 200 字加 5 秒,下限 10s,上限 120s
-  return Math.max(10000, Math.min(Math.floor(textLength / 200) * 5000, 120000));
-}
-
-/** 根据文本长度计算最大轮询超时:基础 2 分钟 + 每 1000 字 1 分钟,上限 30 分钟 */
-function getMaxPollTime(textLength: number): number {
-  const base = 2 * 60 * 1000;              // 2 分钟
-  const perChar = (textLength / 1000) * 60 * 1000; // 每千字 1 分钟
-  return Math.min(base + perChar, 30 * 60 * 1000);
-}
-
-// MiniMax 预置音色(实测可用)
-const MINI_MAX_VOICES: Record<string, string> = {
-  cherry: 'audiobook_female_1',
-  serena: 'audiobook_female_2',
-  ethan: 'audiobook_male_1',
-  chelsie: 'audiobook_female_3',
-  momo: 'audiobook_female_4',
-  vivian: 'audiobook_female_5',
-  moon: 'audiobook_male_2',
-  maia: 'audiobook_female_6',
-  kai: 'audiobook_male_3',
-  nofish: 'audiobook_male_4',
-};
-
-export function getMiniMaxVoice(voiceId: string): string {
-  return MINI_MAX_VOICES[voiceId] || 'audiobook_female_1';
-}
-
-export class MiniMaxTtsProvider implements ITtsProvider {
-  readonly name: string;
-  readonly vendor: string;
-  readonly mode = 'async' as const;
-  maxTextLength = 0; // 由 models.json 配置,未配置时用默认 1000
-  readonly concurrency = 3; // MiniMax 异步长文本 API 支持并发提交
-  readonly modelId: string;
-  readonly ttsApiPath: string;
-
-  private apiKey: string;
-
-  /**
-   * @param vendorKey  供应商 key(如 'minimax', 'minimax-key2')
-   * @param apiKey     API Key
-   * @param modelId    TTS 模型 ID(来自 models.json,如 'speech-2.8-hd')
-   * @param ttsApiPath TTS API 基础路径(来自 models.json vendor 的 ttsApiPath)
-   */
-  constructor(vendorKey: string = 'minimax', apiKey?: string, modelId?: string, ttsApiPath?: string, maxTextLength?: number) {
-    this.vendor = vendorKey;
-    this.name = `${vendorKey}-tts`;
-    this.modelId = modelId || 'speech-2.8-hd';
-    this.ttsApiPath = ttsApiPath || API_BASE;
-    if (maxTextLength) this.maxTextLength = maxTextLength;
-    if (apiKey) {
-      this.apiKey = apiKey;
-    } else {
-      const vendorConfig = (config.models as any).vendors?.[vendorKey];
-      this.apiKey = vendorConfig?.apiKey || '';
-    }
-    if (!this.apiKey) {
-      throw new Error(`MiniMax TTS API Key 未配置 (vendor=${vendorKey})`);
-    }
-  }
-
-  /**
-   * 创建异步 TTS 任务
-   */
-  private async createTask(
-    text: string,
-    voiceId: string,
-    params: VoiceParams,
-  ): Promise<{ task_id: string; task_token: string; file_id: string }> {
-    const voiceName = getMiniMaxVoice(voiceId);
-
-    const body: any = {
-      model: this.modelId,
-      text,
-      voice_setting: {
-        voice_id: voiceName,
-        speed: params.speed || 1,
-        vol: (params.volume || 50) / 50, // 0-100 -> 0-2
-        pitch: 1 + (params.pitch || 0) / 500, // -500~500 -> 0~2, 1 为默认
-      },
-      audio_setting: {
-        audio_sample_rate: 32000,
-        bitrate: 128000,
-        format: 'mp3',
-        channel: 1,
-      },
-    };
-
-    console.log(`📤 [MiniMax TTS] 创建任务, model: ${this.modelId}, voice: ${voiceName}, text length: ${text.length}`);
-
-    let response;
-    try {
-      response = await withAiLog(
-        () => axios.post(`${this.ttsApiPath}/v1/t2a_async_v2`, body, {
-          headers: { 'Authorization': `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
-          timeout: 30000,
-        }),
-        { callType: 'tts_create', provider: this.vendor, model: this.modelId, textLen: text.length }
-      );
-    } catch (err: any) {
-      console.log(`📤 [MiniMax TTS] HTTP错误: status=${err.response?.status}, data=`, JSON.stringify(err.response?.data));
-      throw new Error(`MiniMax 创建任务失败: ${err.response?.data?.base_resp?.status_msg || err.message}`);
-    }
-
-    const data = response.data;
-    console.log(`📤 [MiniMax TTS] 响应:`, JSON.stringify(data));
-    if (data.base_resp?.status_code !== 0) {
-      throw new Error(`MiniMax 创建任务失败: ${data.base_resp?.status_msg || JSON.stringify(data)}`);
-    }
-
-    return {
-      task_id: String(data.task_id),
-      task_token: data.task_token,
-      file_id: String(data.file_id),
-    };
-  }
-
-  /**
-   * 查询任务状态
-   * 注意: 响应字段是 status 不是 task_status
-   */
-  private async queryTask(task_id: string, task_token: string): Promise<{
-    status: string;
-    file_id?: string;
-    status_msg?: string;
-  }> {
-    const response = await withAiLog(
-      () => axios.get(`${this.ttsApiPath}/v1/query/t2a_async_query_v2`, {
-        params: { task_id, task_token },
-        headers: { 'Authorization': `Bearer ${this.apiKey}` },
-        timeout: 30000,
-      }),
-      { callType: 'tts_poll', provider: this.vendor, model: this.modelId }
-    );
-
-    const data = response.data;
-    if (data.base_resp?.status_code !== 0) {
-      throw new Error(`MiniMax 查询任务失败: ${data.base_resp?.status_msg || JSON.stringify(data)}`);
-    }
-
-    return {
-      status: data.status,
-      file_id: data.file_id ? String(data.file_id) : undefined,
-      status_msg: data.status_msg,
-    };
-  }
-
-  /**
-   * 轮询直到任务完成
-   */
-  private async pollUntilComplete(task_id: string, task_token: string, textLength: number): Promise<string> {
-    const startTime = Date.now();
-    const pollInterval = getPollInterval(textLength);
-    const maxPollTime = getMaxPollTime(textLength);
-
-    while (Date.now() - startTime < maxPollTime) {
-      const result = await this.queryTask(task_id, task_token);
-
-      if (result.status === 'Success') {
-        if (!result.file_id) {
-          throw new Error('MiniMax 任务完成但未返回 file_id');
-        }
-        return result.file_id;
-      }
-
-      if (result.status === 'Failed') {
-        throw new Error(`MiniMax 任务失败: ${result.status_msg || '未知错误'}`);
-      }
-
-      // PENDING 或 Processing,继续轮询
-      await new Promise(resolve => setTimeout(resolve, pollInterval));
-    }
-
-    throw new Error(`MiniMax 任务超时 (${Math.round(maxPollTime / 60000)}分钟)`);
-  }
-
-  /**
-   * 通过 file_id 下载音频
-   * MiniMax 返回的是 tar 格式,需要提取其中的 MP3
-   */
-  private async downloadAudio(fileId: string, outputPath: string): Promise<string> {
-    console.log(`⬇️ [MiniMax TTS] 下载音频, file_id: ${fileId}`);
-
-    const response = await withAiLog(
-      () => axios.get(`${this.ttsApiPath}/v1/files/retrieve_content`, {
-      params: { file_id: fileId },
-      headers: {
-        'Authorization': `Bearer ${this.apiKey}`,
-        'Content-Type': 'application/json',
-      },
-      responseType: 'arraybuffer',
-      timeout: 60000,
-    }),
-    { callType: 'tts_download', provider: this.vendor, model: this.modelId }
-  );
-
-    const dir = path.dirname(outputPath);
-    if (!fs.existsSync(dir)) {
-      fs.mkdirSync(dir, { recursive: true });
-    }
-
-    const finalPath = outputPath.endsWith('.mp3') ? outputPath : outputPath.replace(/\.[^.]+$/, '.mp3');
-    const buffer = Buffer.from(response.data);
-
-    // MiniMax 返回 tar 格式,解析并提取 MP3 文件
-    const mp3Data = this.extractMp3FromTar(buffer);
-    if (!mp3Data) {
-      throw new Error('MiniMax 返回的 tar 中未找到 MP3 文件');
-    }
-
-    fs.writeFileSync(finalPath, mp3Data);
-    console.log(`✅ [MiniMax TTS] 下载完成: ${finalPath} (${mp3Data.length} bytes)`);
-
-    return finalPath;
-  }
-
-  /**
-   * 从 tar 中提取 MP3 文件
-   * tar 格式:512 字节头 + 内容(对齐到 512 字节)+ 下一个条目 + 两个 512 零块结束
-   */
-  private extractMp3FromTar(buffer: Buffer): Buffer | null {
-    let offset = 0;
-
-    while (offset < buffer.length - 512) {
-      // 读取 tar 头部的文件名(偏移 0,最多 100 字节)
-      const nameSlice = buffer.slice(offset, offset + 100);
-      const nameEnd = nameSlice.indexOf(0);
-      const filename = nameEnd >= 0 ? nameSlice.slice(0, nameEnd).toString() : nameSlice.toString();
-
-      if (!filename || filename.trim().length === 0) {
-        // 零块 = tar 结束
-        break;
-      }
-
-      // 读取文件大小(偏移 124,12 字节八进制)
-      const sizeStr = buffer.slice(offset + 124, offset + 135).toString().trim();
-      const fileSize = parseInt(sizeStr, 8);
-
-      // 内容从 512 字节后开始
-      const contentOffset = offset + 512;
-
-      // 如果是 MP3 文件,返回内容
-      if (filename.endsWith('.mp3')) {
-        console.log(`📦 [MiniMax TTS] 从 tar 中提取 ${filename} (${fileSize} bytes)`);
-        return buffer.slice(contentOffset, contentOffset + fileSize);
-      }
-
-      // 跳过当前条目:512 头 + 内容(向上对齐到 512)
-      const paddedSize = Math.ceil(fileSize / 512) * 512;
-      offset = contentOffset + paddedSize;
-    }
-
-    return null;
-  }
-
-  /**
-   * 语音合成(完整流程:创建任务 → 轮询 → 下载)
-   */
-  async synthesize(
-    text: string,
-    voiceId: string,
-    params: VoiceParams,
-    outputPath: string,
-    retries: number = 3,
-    _modelOverride?: string,
-  ): Promise<string> {
-    let lastError: Error | null = null;
-
-    for (let attempt = 1; attempt <= retries; attempt++) {
-      try {
-        // 1. 创建任务
-        const { task_id, task_token } = await this.createTask(text, voiceId, params);
-        console.log(`🆔 [MiniMax TTS] task_id: ${task_id}`);
-
-        // 2. 轮询完成,获取 file_id(根据文本长度调整间隔)
-        const fileId = await this.pollUntilComplete(task_id, task_token, text.length);
-
-        // 3. 下载音频
-        return await this.downloadAudio(fileId, outputPath);
-      } catch (error: any) {
-        const errorDetails = error.response?.data || error.message;
-        const isRateLimit = error.response?.status === 429;
-        const isServerError = error.response?.status >= 500;
-
-        console.error(`❌ [MiniMax TTS] 失败 (尝试 ${attempt}/${retries}):`, error.message);
-
-        if ((isRateLimit || isServerError) && attempt < retries) {
-          const waitTime = Math.pow(2, attempt) * 1000;
-          console.warn(`⏳ 等待 ${waitTime}ms 后重试...`);
-          await new Promise(resolve => setTimeout(resolve, waitTime));
-          lastError = new Error(`MiniMax TTS 临时错误: ${error.message}`);
-          continue;
-        }
-
-        lastError = new Error(`MiniMax TTS 调用失败: ${error.message}`);
-      }
-    }
-
-    throw lastError || new Error('MiniMax TTS 调用失败');
-  }
-}

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

@@ -7,7 +7,6 @@
 
 import { ProviderRegistry } from '../../common/provider-registry';
 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';
@@ -15,23 +14,10 @@ import { config } from '../../config';
 
 let _registry: ProviderRegistry<ITtsProvider> | null = null;
 
-/**
- * 供应商 key → TTS Provider 构造函数映射
- * 每个供应商默认都会提供文本生成模型和 TTS 模型,
- * 根据供应商 key 选择对应的 TTS Provider 实现。
- * 新增供应商只需:1) models.json 加 TTS 模型条目  2) 此处加映射
- */
 const TTS_PROVIDER_MAP: Record<string, new (vendorKey: string, apiKey?: string, modelId?: string, ttsApiPath?: string, maxTextLength?: number) => ITtsProvider> = {
-  minimax: MiniMaxTtsProvider,
-  'minimax-key2': MiniMaxTtsProvider,
   bailian: AliyunTtsProvider,
 };
 
-/** 判断供应商 key 是否属于 MiniMax 系列 */
-function isMiniMaxVendor(vendorKey: string): boolean {
-  return vendorKey.startsWith('minimax');
-}
-
 /**
  * 初始化 TTS Provider 注册表(懒加载,服务启动时调用一次)
  */
@@ -48,9 +34,8 @@ export function initTtsRegistry(): ProviderRegistry<ITtsProvider> {
       const hasTts = vendorData.models?.some((m: any) => m.input?.includes('tts') && m.enabled);
       if (!hasTts) continue;
 
-      // 1. 优先匹配映射表中的精确 key
-      const ProviderClass = TTS_PROVIDER_MAP[vendorKey]
-        ?? (isMiniMaxVendor(vendorKey) ? MiniMaxTtsProvider : undefined);
+      // 1. 匹配映射表中的 key
+      const ProviderClass = TTS_PROVIDER_MAP[vendorKey];
 
       if (!ProviderClass) {
         ttsLogger.registry('skip', vendorKey, '无对应 Provider 实现');

+ 50 - 19
server/src/modules/tts/tts.controller.ts

@@ -6,7 +6,7 @@ import * as TtsService from './tts.service';
 import { BadRequestError, NotFoundError } from '../../middleware/errorHandler';
 import { optionalAuth } from '../../middleware/auth';
 import { usageLimitMiddleware, checkWordLimit } from '../../middleware/usageLimit';
-import { checkAudioQuota, consumeAudioMinutes, estimateAudioMinutesFromWords } from '../subscription/subscription.service';
+import { checkAudioQuota } from '../subscription/subscription.service';
 import { prisma } from '../../models';
 
 const router = new Router();
@@ -60,19 +60,18 @@ router.get('/test-db', async (ctx: Context) => {
   }
 });
 
-// 生成音频(异步模式)
+// 生成音频(异步模式 - 复用有声书生成立逻辑
 router.post(
   '/generate',
   optionalAuth,
   async (ctx: Context) => {
     const userId = ctx.state.user?.userId;
-    const { text, voiceId, voiceParams, bookId, chapterTitle, ttsProvider } = ctx.request.body as {
+    const { text, voiceId, voiceParams, bookId, chapterTitle } = ctx.request.body as {
       text: string;
       voiceId: string;
       voiceParams?: { speed?: number; pitch?: number; volume?: number };
       bookId?: string;
       chapterTitle?: string;
-      ttsProvider?: 'aliyun' | 'minimax';
     };
 
     // 调试日志
@@ -83,6 +82,10 @@ router.post(
       throw new BadRequestError('请输入要转换的文本');
     }
 
+    if (text.trim().length < 10) {
+      throw new BadRequestError('文本过短,至少需要10个字符');
+    }
+
     if (!voiceId) {
       throw new BadRequestError('请选择音色');
     }
@@ -113,26 +116,16 @@ router.post(
       volume: voiceParams?.volume || 50,
     };
 
-    // 异步生成音频(立即返回)
-    const result = await TtsService.generateAudio(userId, text, voiceId, params, undefined, {
+    // 复用有声书生成立逻辑:创建章节 + 入队 TtsTask,立即返回
+    const result = await TtsService.requestTtsGeneration(userId, text, voiceId, params, {
       bookId,
       chapterTitle,
-      ttsProvider,
     });
 
-    // 生成成功后消耗配额
-    if (effectiveUserId) {
-      try {
-        await consumeAudioMinutes(effectiveUserId, wordCount, chapterTitle || `TTS音频「${text.slice(0, 20)}...」`);
-      } catch (consumeErr) {
-        console.warn('[TTS] 消耗音频分钟失败:', consumeErr);
-      }
-    }
-
     ctx.body = {
       code: 0,
-      message: '音频生成任务已创建',
-      data: result, // { audioId, audioUrl: '' }
+      message: '音频生成任务已创建,正在排队处理',
+      data: result, // { chapterId, bookId }
     };
   }
 );
@@ -153,12 +146,50 @@ router.get('/status/:audioId', async (ctx: Context) => {
   };
 });
 
+// 获取章节音频生成状态(供前端轮询)
+router.get('/chapter-status/:chapterId', async (ctx: Context) => {
+  const { chapterId } = ctx.params;
+  const id = parseInt(chapterId);
+
+  if (isNaN(id)) {
+    throw new BadRequestError('无效的章节ID');
+  }
+
+  const chapter = await prisma.bookChapter.findUnique({
+    where: { id },
+    select: {
+      id: true,
+      genStage: true,
+      audioUrl: true,
+      audioDuration: true,
+      title: true,
+    },
+  });
+
+  if (!chapter) {
+    throw new NotFoundError('章节不存在');
+  }
+
+  ctx.body = {
+    code: 0,
+    message: 'success',
+    data: {
+      chapterId: chapter.id,
+      genStage: chapter.genStage,
+      audioUrl: chapter.audioUrl,
+      audioDuration: chapter.audioDuration,
+      title: chapter.title,
+      isReady: chapter.genStage === 'audio_completed' || chapter.genStage === 'video_generating' || chapter.genStage === 'video_completed',
+      isFailed: chapter.genStage === 'failed',
+    },
+  };
+});
+
 // 预览音色 - 优先使用本地样本文件,不存在时才调用API生成
 router.post('/preview', async (ctx: Context) => {
   const { voiceId } = ctx.request.body as {
     voiceId: string;
     voiceParams?: { speed?: number; pitch?: number; volume?: number };
-    ttsProvider?: 'aliyun' | 'minimax';
   };
 
   if (!voiceId) {

+ 157 - 13
server/src/modules/tts/tts.service.ts

@@ -29,14 +29,6 @@ const UNIFIED_VOICES: Voice[] = [
   { id: 'voice_10', name: '稚嫩童声',  gender: 'female', description: '稚嫩天真,适合童话寓言' },
 ];
 
-// 统一音色 → 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',
@@ -45,12 +37,82 @@ const ALIYUN_VOICE_MAP: Record<string, string> = {
   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'];
+function mapToProviderVoice(unifiedVoiceId: string, _provider: string): string {
+  const mapped = ALIYUN_VOICE_MAP[unifiedVoiceId];
+  if (mapped) return mapped;
+  // 不在映射表中(如遗留的 'cherry' 等旧 MiniMax 音色)→ 用 CosyVoice 默认音色
+  console.warn(`⚠️ [VoiceMap] 未识别的音色ID: "${unifiedVoiceId}",降级使用默认音色 longyingling_v3`);
+  return 'longyingling_v3';
+}
+
+// ============ Aliyun Instruct 情感/场景控制 ============
+// 后期优化功能,暂不启用。启用时改为 true
+const INSTRUCT_ENABLED = false;
+
+interface EmotionScene {
+  emotion: string;  // neutral | fearful | angry | sad | surprised | happy | disgusted
+  scene: string;    // 闲聊互动 | 新闻播报 | 广告促销 | 比赛解说 | 一些儿童内容解说 | 语音导航 | 脱口秀表演
+}
+
+// 情感关键词库
+const EMOTION_KEYWORDS: { emotion: string; keywords: string[] }[] = [
+  { emotion: 'fearful',   keywords: ['恐怖', '可怕', '惊悚', '恐惧', '阴森', '黑暗', '鬼', '死亡', '谋杀', '悬疑', '危险', '深渊', '噩梦'] },
+  { emotion: 'sad',       keywords: ['悲伤', '难过', '哭泣', '眼泪', '心痛', '遗憾', '孤独', '寂寞', '失落', '离别', '思念', '哀伤', '去世', '失去'] },
+  { emotion: 'angry',     keywords: ['愤怒', '生气', '怒火', '仇恨', '战斗', '厮杀', '复仇', '战争', '侵略', '暴怒'] },
+  { emotion: 'happy',     keywords: ['快乐', '开心', '幸福', '欢笑', '庆祝', '美好', '甜蜜', '温暖', '阳光', '喜悦', '高兴', '浪漫', '恋爱', '美好'] },
+  { emotion: 'surprised', keywords: ['惊奇', '惊喜', '意外', '奇迹', '神奇', '魔法', '童话', '幻想', '奇妙'] },
+  { emotion: 'disgusted', keywords: ['恶心', '厌恶', '肮脏', '丑陋', '卑鄙'] },
+];
+
+// 场景关键词库
+const SCENE_KEYWORDS: { scene: string; keywords: string[] }[] = [
+  { scene: '新闻播报',         keywords: ['新闻', '报道', '消息', '公告', '通知', '声明', '记者', '据悉', '据新华社', '人民日报'] },
+  { scene: '一些儿童内容解说',  keywords: ['童话', '儿童', '小朋友', '宝宝', '故事', '小熊', '小兔', '公主', '王子', '森林', '魔法', '精灵'] },
+  { scene: '广告促销',         keywords: ['促销', '优惠', '折扣', '限时', '秒杀', '购买', '抢购', '免费', '特价'] },
+  { scene: '脱口秀表演',       keywords: ['搞笑', '幽默', '笑话', '段子', '吐槽', '趣事'] },
+  { scene: '语音导航',         keywords: ['导航', '前方', '左转', '右转', '直行', '到达', '目的地', '路线'] },
+  { scene: '比赛解说',         keywords: ['比赛', '进球', '得分', '冠军', '决赛', '球队', '选手', '比分'] },
+];
+
+/** 根据文本内容分析情感 */
+function detectEmotion(text: string): string {
+  const scores: Record<string, number> = {};
+  for (const { emotion, keywords } of EMOTION_KEYWORDS) {
+    scores[emotion] = 0;
+    for (const kw of keywords) {
+      if (text.includes(kw)) scores[emotion]++;
+    }
   }
-  // 阿里云: CosyVoice 音色ID直接作API参数名(如 longanyang, longyingling_v3)
-  return ALIYUN_VOICE_MAP[unifiedVoiceId] || unifiedVoiceId;
+  let best = 'neutral';
+  let bestScore = 0;
+  for (const [emotion, score] of Object.entries(scores)) {
+    if (score > bestScore) { best = emotion; bestScore = score; }
+  }
+  return best;
+}
+
+/** 根据文本内容分析场景 */
+function detectScene(text: string): string {
+  const scores: Record<string, number> = {};
+  for (const { scene, keywords } of SCENE_KEYWORDS) {
+    scores[scene] = 0;
+    for (const kw of keywords) {
+      if (text.includes(kw)) scores[scene]++;
+    }
+  }
+  let best = '闲聊互动';
+  let bestScore = 0;
+  for (const [scene, score] of Object.entries(scores)) {
+    if (score > bestScore) { best = scene; bestScore = score; }
+  }
+  return best;
+}
+
+/** 根据文本内容动态生成 Aliyun Instruct 文本 */
+export function getVoiceInstruct(text: string): string {
+  const emotion = detectEmotion(text);
+  const scene = detectScene(text);
+  return `你正在进行${scene},你说话的情感是${emotion}。`;
 }
 
 // 兼容旧代码的旧版音色列表(保留但不再推荐使用)
@@ -223,6 +285,80 @@ export function splitText(text: string, maxLength: number = SEGMENT_MAX_LENGTH):
 // │ OpenAI       │ TTS-1/HD           │ 4,096     │ 隐藏限制             │
 // └──────────────┴────────────────────┴───────────┴──────────────────────┘
 
+/**
+ * TTS 直接生成请求(复用有声书生成立逻辑)
+ * 
+ * 与有声书的核心区别:内容由用户直接提供(跳过 AI 内容生成),只创建单章节。
+ * 创建章节后通过 TtsTask 队列异步生成音频,与有声书完全相同的后续流程。
+ * 
+ * @returns { chapterId, bookId } - 前端通过轮询 genStage 或 WebSocket 获取进度
+ */
+export async function requestTtsGeneration(
+  userId: string,
+  text: string,
+  voiceId: string,
+  voiceParams: VoiceParams,
+  options?: {
+    bookId?: string;
+    chapterTitle?: string;
+  }
+): Promise<{
+  chapterId: number;
+  bookId: number;
+}> {
+  const userIdNum = userId ? parseInt(userId) : null;
+
+  // 1. 获取或创建默认书籍
+  let targetBookId: number;
+  if (options?.bookId) {
+    targetBookId = parseInt(options.bookId);
+    // 验证书籍存在
+    const book = await prisma.book.findUnique({ where: { id: targetBookId } });
+    if (!book) {
+      throw new Error('指定的书籍不存在');
+    }
+  } else {
+    targetBookId = await getOrCreateDefaultBook(userId);
+  }
+
+  // 2. 在默认书籍下创建章节(内容=用户输入,跳过AI生成)
+  const chapterTitle = options?.chapterTitle || text.replace(/[\n\r]/g, ' ').substring(0, 30) + (text.length > 30 ? '...' : '');
+  const wordCount = (text.match(/[\u4e00-\u9fa5]/g) || []).length;
+
+  // 找到当前书籍的最大章节序号
+  const maxChapter = await prisma.bookChapter.findFirst({
+    where: { bookId: targetBookId, parentId: 0 },
+    orderBy: { number: 'desc' },
+  });
+  const chapterNumber = (maxChapter?.number || 0) + 1;
+
+  const chapter = await prisma.bookChapter.create({
+    data: {
+      bookId: targetBookId,
+      parentId: 0,      // 顶层章节
+      level: 1,         // 章级别
+      number: chapterNumber,
+      title: chapterTitle,
+      content: text,
+      wordCount,
+      genStage: 'content_completed',  // 跳过AI内容生成,直接标记内容已完成
+    },
+  });
+
+  console.log(`📝 [TTS] 创建章节: chapterId=${chapter.id}, bookId=${targetBookId}, 标题="${chapterTitle}", 字数=${wordCount}`);
+
+  // 3. 通过有声书队列生成音频(与有声书完全相同的后续流程)
+  const { bookStore } = await import('../book-generator/book-generator.store.js');
+  await bookStore.generateChapterAudioById(chapter.id, userIdNum || undefined, voiceId);
+
+  console.log(`✅ [TTS] 音频任务已入队: chapterId=${chapter.id}, voiceId=${voiceId}`);
+
+  return {
+    chapterId: chapter.id,
+    bookId: targetBookId,
+  };
+}
+
 // 生成音频(异步模式,不再创建 Audio 记录)
 export async function generateAudio(
   userId: string,
@@ -375,6 +511,14 @@ async function processAudioGeneration(
     // 解析音色名称(使用统一音色映射到具体Provider的真实音色)
     const voiceName = mapToProviderVoice(voiceId, tts.vendor);
 
+    // 为 Aliyun 注入 Instruct 情感/场景控制(后期优化,暂不启用)
+    if (INSTRUCT_ENABLED && tts.vendor === 'bailian') {
+      const instructText = getVoiceInstruct(text);
+      if (instructText) {
+        voiceParams = { ...voiceParams, instructText };
+      }
+    }
+
     // 按模型配置分段:有 maxTextLength 就用它的 80%,没配置就用默认 1000
     const segmentMax = tts.maxTextLength ? Math.floor(tts.maxTextLength * 0.8) : 1000;
     const segments = splitText(text, segmentMax);

+ 3 - 1
server/src/types/index.ts

@@ -15,7 +15,7 @@ export interface IUser {
   updatedAt: Date;
 }
 
-export type MemberLevel = 0 | 1 | 2 | 3 | 4; // 0免费 1入门 2专业 3旗舰 4企业
+export type MemberLevel = -1 | 0 | 1 | 2 | 3 | 4; // -1无限额度(测试) 0免费 1入门 2专业 3旗舰 4企业
 
 // 音频相关类型
 export interface IAudio {
@@ -41,6 +41,7 @@ export interface VoiceParams {
   speed: number; // 0.5 - 2.0
   pitch: number; // -500 - 500
   volume: number; // 0 - 100
+  instructText?: string; // Aliyun Instruct 情感/场景控制文本
 }
 
 export type AudioStatus = 'processing' | 'completed' | 'failed';
@@ -118,6 +119,7 @@ export interface MemberQuota {
 }
 
 export const MEMBER_QUOTA: Record<MemberLevel, MemberQuota> = {
+  '-1': { dailyLimit: -1, wordLimit: -1 },
   0: { dailyLimit: 3, wordLimit: 5000 },
   1: { dailyLimit: 10, wordLimit: 10000 },
   2: { dailyLimit: -1, wordLimit: 10000 },