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

feat: 批量更新 - 优化音频播放、TTS服务、支付流程等多项功能

- 优化音频播放器状态管理和进度同步
- 改进 TTS 音频合并和阿里云/MiniMax 提供商
- 新增安全 JSON 解析工具
- 优化页面组件(首页、搜索、播放页等)
- 更新数据库 schema
- 移除废弃的测试文件

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MyFramework User 3 месяцев назад
Родитель
Сommit
6c6ae36e07
36 измененных файлов с 1618 добавлено и 750 удалено
  1. 3 15
      my-uniapp-vue3/src/components/MiniPlayer.vue
  2. 145 0
      my-uniapp-vue3/src/composables/useCoverStyle.ts
  3. 31 2
      my-uniapp-vue3/src/pages/album/index.vue
  4. 29 16
      my-uniapp-vue3/src/pages/albums/index.vue
  5. 13 33
      my-uniapp-vue3/src/pages/book-generator/index.vue
  6. 63 1
      my-uniapp-vue3/src/pages/book-generator/interactive.vue
  7. 242 23
      my-uniapp-vue3/src/pages/history/index.vue
  8. 29 16
      my-uniapp-vue3/src/pages/mine/index.vue
  9. 37 17
      my-uniapp-vue3/src/pages/player/index.vue
  10. 31 20
      my-uniapp-vue3/src/pages/search/index.vue
  11. 113 23
      my-uniapp-vue3/src/store/audio.ts
  12. 2 0
      my-uniapp-vue3/src/types/index.ts
  13. 5 1
      server/prisma/schema.prisma
  14. 2 2
      server/src/middleware/auth.ts
  15. 4 2
      server/src/modules/auth/auth.controller.ts
  16. 1 1
      server/src/modules/auth/auth.service.ts
  17. 9 5
      server/src/modules/book-generator/album-controller.ts
  18. 43 31
      server/src/modules/book-generator/book-generator.service.ts
  19. 37 11
      server/src/modules/book-generator/book-generator.store.ts
  20. 101 35
      server/src/modules/book-generator/langgraph-controller.ts
  21. 39 80
      server/src/modules/member/member.service.ts
  22. 2 2
      server/src/modules/notifications/notifications.controller.ts
  23. 3 1
      server/src/modules/notifications/notifications.service.ts
  24. 9 8
      server/src/modules/payment/payment.controller.ts
  25. 10 7
      server/src/modules/payment/payment.service.ts
  26. 82 26
      server/src/modules/player/player.service.ts
  27. 68 57
      server/src/modules/tts/aliyun.provider.ts
  28. 41 10
      server/src/modules/tts/audio-merger.ts
  29. 4 0
      server/src/modules/tts/minimax.provider.ts
  30. 394 188
      server/src/modules/tts/tts.service.ts
  31. 1 3
      server/src/modules/video-generator/video-generator.service.ts
  32. 3 3
      server/src/services/ffmpeg.processor.ts
  33. 5 3
      server/src/types/index.ts
  34. 17 0
      server/src/utils/safe-parse.ts
  35. 0 62
      server/test-split.js
  36. 0 46
      server/test-tts.js

+ 3 - 15
my-uniapp-vue3/src/components/MiniPlayer.vue

@@ -2,7 +2,7 @@
   <view v-if="showMiniPlayer" class="mini-player" @click="goToPlayer">
     <!-- 封面 -->
     <view class="mini-cover" :style="{ background: coverGradient }">
-      <text class="cover-icon">🎵</text>
+      <text class="cover-icon">{{ getTitleLetter(audioStore.currentAudio?.title || '') }}</text>
     </view>
 
     <!-- 信息 -->
@@ -27,6 +27,7 @@
 import { computed } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
 import { useAudioStore } from '../store/audio';
+import { getCoverGradient, getTitleLetter } from '../composables/useCoverStyle';
 
 const audioStore = useAudioStore();
 
@@ -48,20 +49,7 @@ const showMiniPlayer = computed(() => {
 // 封面颜色
 const coverGradient = computed(() => {
   const voiceId = audioStore.currentAudio?.voiceId || 'cherry';
-  const voiceColors: Record<string, string[]> = {
-    cherry: ['#667eea', '#764ba2'],
-    ethan: ['#f093fb', '#f5576c'],
-    serena: ['#4facfe', '#00f2fe'],
-    chelsie: ['#43e97b', '#38f9d7'],
-    momo: ['#fa709a', '#fee140'],
-    vivian: ['#a8edea', '#fed6e3'],
-    moon: ['#5ee7df', '#b490ca'],
-    maia: ['#d299c2', '#fef9d7'],
-    kai: ['#89f7fe', '#66a6ff'],
-    nofish: ['#cd9cf2', '#f6f3ff'],
-  };
-  const colors = voiceColors[voiceId] || ['#667eea', '#764ba2'];
-  return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
+  return getCoverGradient(voiceId);
 });
 
 // 格式化时间

+ 145 - 0
my-uniapp-vue3/src/composables/useCoverStyle.ts

@@ -0,0 +1,145 @@
+/**
+ * useCoverStyle - 封面视觉升级(文字海报风格)
+ * 
+ * 提供统一的封面样式计算,类似 Apple Books 风格:
+ * 超大首字母(半透明装饰)+ 书名 + 章节进度
+ */
+
+// 统一音色封面配色方案
+const VOICE_COLORS: Record<string, string[]> = {
+  cherry: ['#667eea', '#764ba2'],
+  ethan: ['#f093fb', '#f5576c'],
+  serena: ['#4facfe', '#00f2fe'],
+  chelsie: ['#43e97b', '#38f9d7'],
+  momo: ['#fa709a', '#fee140'],
+  vivian: ['#a8edea', '#fed6e3'],
+  moon: ['#5ee7df', '#b490ca'],
+  maia: ['#d299c2', '#fef9d7'],
+  kai: ['#89f7fe', '#66a6ff'],
+  nofish: ['#cd9cf2', '#f6f3ff'],
+};
+
+// 通用书籍封面配色(基于 ID 轮转)
+const BOOK_COLORS = [
+  ['#4f46e5', '#818cf8'],
+  ['#ef4444', '#f87171'],
+  ['#f97316', '#fb923c'],
+  ['#22c55e', '#4ade80'],
+  ['#3b82f6', '#60a5fa'],
+  ['#a855f7', '#c084fc'],
+  ['#ec4899', '#f472b6'],
+  ['#14b8a6', '#2dd4bf'],
+];
+
+/**
+ * 根据音色ID获取渐变色
+ */
+export function getCoverGradient(voiceId?: string | null): string {
+  if (voiceId && VOICE_COLORS[voiceId]) {
+    const colors = VOICE_COLORS[voiceId];
+    return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
+  }
+  return `linear-gradient(135deg, #667eea 0%, #764ba2 100%)`;
+}
+
+/**
+ * 基于数值ID获取轮转渐变色
+ */
+export function getBookGradient(id: number): string {
+  const colors = BOOK_COLORS[id % BOOK_COLORS.length] || BOOK_COLORS[0];
+  return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
+}
+
+/**
+ * 获取字符串哈希对应的渐变色
+ */
+export function getHashGradient(str: string): string {
+  const gradients = [
+    'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+    'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
+    'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
+    'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
+    'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
+    'linear-gradient(135deg, #a8edea 0%, #fed6e3 100%)',
+    'linear-gradient(135deg, #5ee7df 0%, #b490ca 100%)',
+  ];
+  let hash = 0;
+  for (let i = 0; i < str.length; i++) {
+    hash = str.charCodeAt(i) + ((hash << 5) - hash);
+  }
+  return gradients[Math.abs(hash) % gradients.length];
+}
+
+/**
+ * 获取标题首字母作为装饰文字
+ */
+export function getTitleLetter(title: string): string {
+  if (!title) return '?';
+  const first = title.trim().charAt(0);
+  // 中文字符直接返回
+  if (/[\u4e00-\u9fa5]/.test(first)) {
+    return first;
+  }
+  return first.toUpperCase();
+}
+
+/**
+ * 获取增强封面样式对象(Apple Books 风格)
+ * 返回完整的封面展示数据
+ */
+export interface CoverStyle {
+  background: string;
+  decorLetter: string;
+  decoratedTitle?: string;
+  progress?: number;
+  progressText?: string;
+}
+
+export function getCoverStyle(params: {
+  id?: number;
+  voiceId?: string | null;
+  title?: string;
+  progress?: number;
+  totalChapters?: number;
+  completedChapters?: number;
+}): CoverStyle {
+  const { id, voiceId, title, progress, totalChapters, completedChapters } = params;
+  
+  // 确定渐变色
+  let background: string;
+  if (id !== undefined) {
+    background = getBookGradient(id);
+  } else if (voiceId) {
+    background = getCoverGradient(voiceId);
+  } else {
+    background = getCoverGradient(null);
+  }
+  
+  // 装饰首字母
+  const decorLetter = getTitleLetter(title || '');
+  
+  // 进度信息
+  let progressText: string | undefined;
+  let computedProgress: number | undefined;
+  
+  if (totalChapters && totalChapters > 0 && completedChapters !== undefined) {
+    computedProgress = Math.round((completedChapters / totalChapters) * 100);
+    progressText = `${completedChapters}/${totalChapters}章`;
+  } else if (progress !== undefined) {
+    computedProgress = progress;
+    progressText = `${progress}%`;
+  }
+  
+  return {
+    background,
+    decorLetter,
+    decoratedTitle: title,
+    progress: computedProgress,
+    progressText,
+  };
+}
+
+/**
+ * 导出配色方案供直接使用
+ */
+export { VOICE_COLORS, BOOK_COLORS };

+ 31 - 2
my-uniapp-vue3/src/pages/album/index.vue

@@ -23,7 +23,8 @@
     <!-- 书籍信息 -->
     <view v-else-if="book" class="album-header">
       <view class="album-cover" :style="{ background: getCoverGradient() }">
-        <text class="cover-icon">📖</text>
+        <text class="cover-letter">{{ getTitleLetter(book?.title || '') }}</text>
+        <text class="cover-title-small">{{ book?.title }}</text>
       </view>
       <view class="album-info">
         <text class="album-title">{{ book.title }}</text>
@@ -219,6 +220,7 @@ import { useAudioStore } from '../../store/audio';
 import * as bookApi from '../../utils/book-generator-api';
 import type { Book, Chapter } from '../../utils/book-generator-api';
 import { get, put } from '../../utils/request';
+import { getBookGradient, getTitleLetter } from '../../composables/useCoverStyle';
 
 const audioStore = useAudioStore();
 
@@ -251,7 +253,7 @@ const completedChapters = computed(() => {
 
 // 获取封面渐变色
 function getCoverGradient(): string {
-  return 'linear-gradient(135deg, #4f46e5 0%, #818cf8 100%)';
+  return getBookGradient(Number(book.value?.id) || 1);
 }
 
 // 格式化时长
@@ -562,6 +564,8 @@ onMounted(() => {
   align-items: center;
   justify-content: center;
   flex-shrink: 0;
+  position: relative;
+  overflow: hidden;
 }
 
 .cover-icon {
@@ -569,6 +573,31 @@ onMounted(() => {
   opacity: 0.6;
 }
 
+/* 文字海报风格封面 */
+.cover-letter {
+  position: absolute;
+  top: -20rpx;
+  left: 12rpx;
+  font-size: 160rpx;
+  font-weight: 900;
+  color: rgba(255, 255, 255, 0.12);
+  line-height: 1;
+  pointer-events: none;
+}
+
+.cover-title-small {
+  position: absolute;
+  bottom: 50rpx;
+  left: 20rpx;
+  right: 20rpx;
+  font-size: 24rpx;
+  color: rgba(255, 255, 255, 0.85);
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .album-info {
   flex: 1;
   display: flex;

+ 29 - 16
my-uniapp-vue3/src/pages/albums/index.vue

@@ -36,7 +36,8 @@
           @click="goToAlbumDetail(item.id)"
         >
           <view class="album-cover" :style="{ background: getCoverGradient(item) }">
-            <text class="cover-icon">🎵</text>
+            <text class="cover-letter">{{ getTitleLetter(item.name) }}</text>
+            <text class="cover-title-small">{{ item.name }}</text>
             <view class="album-count">{{ item.subscriberCount || 0 }}人订阅</view>
           </view>
           <view class="album-info">
@@ -60,6 +61,7 @@
 <script setup lang="ts">
 import { ref, onMounted } from 'vue';
 import { get } from '../../utils/request';
+import { getBookGradient, getTitleLetter } from '../../composables/useCoverStyle';
 
 // scroll-top 用于避免 scrollTop 错误
 const scrollTop = ref(0);
@@ -79,23 +81,9 @@ const total = ref(0);
 const loading = ref(false);
 const hasMore = ref(true);
 
-// 音色封面颜色映射
-const voiceColors: Record<string, string[]> = {
-  default: ['#4f46e5', '#818cf8'],
-  red: ['#ef4444', '#f87171'],
-  orange: ['#f97316', '#fb923c'],
-  green: ['#22c55e', '#4ade80'],
-  blue: ['#3b82f6', '#60a5fa'],
-  purple: ['#a855f7', '#c084fc'],
-};
-
 // 获取封面渐变色
 function getCoverGradient(item: Album): string {
-  // 使用 ID 映射到不同的颜色
-  const colorKeys = Object.keys(voiceColors);
-  const colorKey = colorKeys[item.id % colorKeys.length] || 'default';
-  const colors = voiceColors[colorKey];
-  return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
+  return getBookGradient(item.id);
 }
 
 // 返回
@@ -276,6 +264,31 @@ onMounted(async () => {
   opacity: 0.6;
 }
 
+/* 文字海报风格封面 */
+.cover-letter {
+  position: absolute;
+  top: -16rpx;
+  left: 12rpx;
+  font-size: 130rpx;
+  font-weight: 900;
+  color: rgba(255, 255, 255, 0.12);
+  line-height: 1;
+  pointer-events: none;
+}
+
+.cover-title-small {
+  position: absolute;
+  bottom: 40rpx;
+  left: 16rpx;
+  right: 16rpx;
+  font-size: 22rpx;
+  color: rgba(255, 255, 255, 0.85);
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .album-count {
   position: absolute;
   bottom: 12rpx;

+ 13 - 33
my-uniapp-vue3/src/pages/book-generator/index.vue

@@ -88,7 +88,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue';
 import { onShow } from '@dcloudio/uni-app';
 import * as api from '../../utils/book-generator-api';
 import type { Book } from '../../utils/book-generator-api';
-import { wsManager } from '../../utils/websocket';
+import { useNotificationStore } from '../../store/notification';
 import GenerationStatusBadge from '../../components/GenerationStatusBadge.vue';
 
 // Tab 页标识
@@ -172,7 +172,7 @@ function getBookVideoStatus(book: Book) {
   const leafNodes = getBookLeafNodes(book, leafLevel);
   const total = leafNodes.length;
   const completed = leafNodes.filter(n => n.videoUrl).length;
-  let status: 'none' | 'partial' | 'completed' = 'none';
+  let status: 'none' | 'partial' | 'completed' | 'generating' = 'none';
   if (completed === total && total > 0) status = 'completed';
   else if (completed > 0) status = 'partial';
   if (generatingVideo.value[book.id]) status = 'generating';
@@ -242,6 +242,9 @@ async function handleGenerateAllAudio(book: Book) {
           generatingAudio.value[book.id] = false;
           await loadBooks();
           uni.showToast({ title: '音频生成完成', icon: 'success' });
+          // 写入通知
+          const notifStore = useNotificationStore();
+          notifStore.add({ type: 'audio_complete', title: '音频生成完成', message: `《${book.title}》音频已全部生成完毕`, bookId: String(book.id) });
         }
       } catch (e) {
         console.error('轮询音频状态失败:', e);
@@ -297,6 +300,9 @@ async function handleGenerateAllVideo(book: Book) {
           generatingVideo.value[book.id] = false;
           await loadBooks();
           uni.showToast({ title: '视频生成完成', icon: 'success' });
+          // 写入通知
+          const notifStore = useNotificationStore();
+          notifStore.add({ type: 'video_complete', title: '视频生成完成', message: `《${book.title}》视频已全部生成完毕`, bookId: String(book.id) });
         }
       } catch (e) {
         console.error('轮询视频状态失败:', e);
@@ -378,42 +384,16 @@ onShow(() => {
   loadBooks();
 });
 
-// WebSocket 事件处理
-function handleAudioGenerationComplete(data: { bookId: string; chapterId: number; status: 'completed' | 'failed' }) {
-  if (data.bookId && generatingAudio.value[data.bookId]) {
-    clearAudioPollTimer(data.bookId);
-    generatingAudio.value[data.bookId] = false;
-    loadBooks();
-    uni.showToast({ title: data.status === 'completed' ? '音频生成完成' : '音频生成失败', icon: data.status === 'completed' ? 'success' : 'none' });
-  }
-}
-
-function handleVideoGenerationComplete(data: { bookId: string; chapterId: number; status: 'completed' | 'failed' }) {
-  if (data.bookId && generatingVideo.value[data.bookId]) {
-    clearVideoPollTimer(data.bookId);
-    generatingVideo.value[data.bookId] = false;
-    loadBooks();
-    uni.showToast({ title: data.status === 'completed' ? '视频生成完成' : '视频生成失败', icon: data.status === 'completed' ? 'success' : 'none' });
-  }
-}
-
-// 初始化 WebSocket 连接
+// 初始化
 onMounted(() => {
   loadBooks();
-  // 连接 WebSocket 并订阅事件
-  wsManager.connect().then(() => {
-    wsManager.on('audio_generation_complete', handleAudioGenerationComplete);
-    wsManager.on('video_generation_complete', handleVideoGenerationComplete);
-  }).catch((e) => {
-    console.error('[BookGenerator] WebSocket 连接失败:', e);
-  });
 });
 
-// 页面卸载时关闭 WebSocket
+// 页面卸载时清理所有轮询
 onUnmounted(() => {
-  // 取消订阅
-  wsManager.off('audio_generation_complete', handleAudioGenerationComplete);
-  wsManager.off('video_generation_complete', handleVideoGenerationComplete);
+  // 清理所有轮询定时器
+  Object.keys(audioPollTimers.value).forEach(key => clearAudioPollTimer(key));
+  Object.keys(videoPollTimers.value).forEach(key => clearVideoPollTimer(key));
 });
 </script>
 

+ 63 - 1
my-uniapp-vue3/src/pages/book-generator/interactive.vue

@@ -39,6 +39,12 @@
         <view class="form-item">
           <text class="form-label">内容描述 *</text>
           <textarea v-model="form.description" class="form-textarea" placeholder="描述这本书的内容、主题、写作目的..." :maxlength="500" />
+          <view class="ai-recommend-row">
+            <button class="btn-ai-recommend" :disabled="!form.description.trim() || recommendLoading" @click="doSmartRecommend">
+              {{ recommendLoading ? 'AI分析中...' : '🤖 AI智能推荐' }}
+            </button>
+            <text class="ai-recommend-hint">根据描述自动填充目标人群和书籍类型</text>
+          </view>
         </view>
 
         <view class="form-item">
@@ -133,6 +139,12 @@
           <text v-if="planLoading" class="loading-hint">AI 正在分析...</text>
         </view>
 
+        <!-- AI自动生成提示 -->
+        <view v-if="!planLoading && planData" class="ai-generated-notice">
+          <text class="notice-icon">✨</text>
+          <text class="notice-text">AI 已根据你的描述自动生成了以下方案,可直接确认或提出修改意见后重新分析</text>
+        </view>
+
         <view v-if="planLoading" class="loading-box">
           <view class="loading-spinner"></view>
           <text class="loading-text">AI 正在制定书籍规划,请稍候...</text>
@@ -268,7 +280,7 @@
 
         <view v-if="!planLoading" class="btn-group">
           <button class="btn-primary" :disabled="stepLoading" @click="doSavePlan">
-            {{ stepLoading ? '保存中...' : '确认,生成大纲' }}
+            {{ stepLoading ? '保存中...' : '使用此方案,生成大纲' }}
           </button>
           <button class="btn-secondary" :disabled="regeneratingPlan" @click="doRefinePlan">
             {{ regeneratingPlan ? '重新分析中...' : '重新分析' }}
@@ -454,6 +466,45 @@ const outlineLevelOptions = [
 
 const canProceed = computed(() => form.value.title.trim() && form.value.description.trim());
 
+// AI智能推荐
+const recommendLoading = ref(false);
+
+async function doSmartRecommend() {
+  if (!form.value.description.trim() || recommendLoading.value) return;
+  recommendLoading.value = true;
+  try {
+    const response = await uni.request({
+      url: `${BASE_URL}/book-generator/langgraph/smart-recommend`,
+      method: 'POST',
+      data: { description: form.value.description, title: form.value.title },
+    });
+    const res = response.data as any;
+    if (res.code === 0 && res.data) {
+      const data = res.data;
+      // 自动填充目标人群
+      if (data.targetAudience && data.targetAudience !== '通用') {
+        const matched = audiences.find(a => a.value === data.targetAudience || a.label === data.targetAudience);
+        if (matched) {
+          form.value.targetAudience = matched.value;
+        }
+      }
+      // 自动填充书籍类型
+      if (data.industry || data.style) {
+        const typeHint = data.industry || data.style;
+        const matched = bookTypes.find(t => t.label.includes(typeHint) || typeHint.includes(t.label));
+        if (matched && matched.value !== 'auto') {
+          form.value.bookType = matched.value;
+        }
+      }
+      uni.showToast({ title: `推荐: ${data.reason || '已自动填充'}`, icon: 'none', duration: 2000 });
+    }
+  } catch (e: any) {
+    uni.showToast({ title: '推荐失败,请手动选择', icon: 'none' });
+  } finally {
+    recommendLoading.value = false;
+  }
+}
+
 // ============ Step 2: Plan ============
 const planLoading = ref(false);
 const planData = ref<any>(null);
@@ -1001,4 +1052,15 @@ button[disabled] { opacity: .5; }
 .progress-bar-wrap { padding: 16rpx 0; }
 .progress-bar { height: 12rpx; background: linear-gradient(90deg, #4f46e5, #6366f1); border-radius: 6rpx; transition: width .5s; }
 .progress-text { font-size: 24rpx; color: #4f46e5; text-align: center; display: block; margin-top: 12rpx; }
+
+/* AI智能推荐 */
+.ai-recommend-row { display: flex; align-items: center; gap: 12rpx; margin-top: 16rpx; }
+.btn-ai-recommend { padding: 14rpx 24rpx; background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: #ffffff; border-radius: 12rpx; font-size: 24rpx; font-weight: 600; border: none; white-space: nowrap; flex-shrink: 0; }
+.btn-ai-recommend[disabled] { opacity: 0.5; }
+.ai-recommend-hint { font-size: 22rpx; color: #9ca3af; flex: 1; }
+
+/* AI自动生成提示 */
+.ai-generated-notice { display: flex; align-items: flex-start; gap: 12rpx; padding: 20rpx 24rpx; background: linear-gradient(135deg, #eef2ff 0%, #f0fdf4 100%); border-radius: 12rpx; border: 2rpx solid #c7d2fe; margin-bottom: 20rpx; }
+.notice-icon { font-size: 32rpx; flex-shrink: 0; margin-top: 2rpx; }
+.notice-text { font-size: 26rpx; color: #4338ca; line-height: 1.5; }
 </style>

+ 242 - 23
my-uniapp-vue3/src/pages/history/index.vue

@@ -51,6 +51,9 @@
           </view>
         </view>
       </scroll-view>
+      <view class="group-toggle" @click="groupByBook = !groupByBook">
+        <text class="group-toggle-text">{{ groupByBook ? '📋 列表' : '📚 聚合' }}</text>
+      </view>
     </view>
 
     <!-- 音频列表 -->
@@ -67,6 +70,33 @@
 
       <!-- 音频列表 -->
       <view v-else class="audio-grid">
+        <!-- 分组模式 -->
+        <view v-if="groupByBook" v-for="group in displayList" :key="group._id" class="group-card">
+          <view class="group-header" @click="goToBookDetail(group.bookId)">
+            <text class="group-title">{{ group.bookId ? '📖 书籍 #' + group.bookId : '🎵 其他音频' }}</text>
+            <text class="group-count">{{ group.items.length }} 个音频 · {{ formatDuration(group.totalDuration) }}</text>
+          </view>
+          <view
+            v-for="item in group.items"
+            :key="item._id"
+            class="audio-card group-item"
+            :class="{ selected: selectedIds.includes(item._id) }"
+            @click="isEditing ? toggleSelect(item._id) : playAudio(item)"
+          >
+            <view v-if="isEditing" class="checkbox">
+              <text>{{ selectedIds.includes(item._id) ? '✓' : '' }}</text>
+            </view>
+            <view class="audio-cover-mini" :style="{ background: getCoverGradient(item.voiceId) }">
+              <text class="cover-icon-small">{{ getTitleLetter(item.title) }}</text>
+            </view>
+            <view class="audio-info">
+              <text class="audio-title">{{ item.title }}</text>
+              <text class="audio-meta">{{ item.wordCount }}字 · {{ formatDuration(item.audioDuration) }}</text>
+            </view>
+          </view>
+        </view>
+        <!-- 列表模式 -->
+        <template v-else>
         <view
           v-for="item in audioList"
           :key="item._id"
@@ -78,20 +108,27 @@
             <text>{{ selectedIds.includes(item._id) ? '✓' : '' }}</text>
           </view>
           <view class="audio-cover" :style="{ background: getCoverGradient(item.voiceId) }">
-            <text class="cover-icon">🎵</text>
+            <text class="cover-letter">{{ getTitleLetter(item.title) }}</text>
+            <text class="cover-title-small">{{ item.title }}</text>
             <view class="play-overlay">
               <text class="play-icon">▶</text>
             </view>
-            <text class="audio-duration-badge">{{ formatDuration(item.audioDuration) }}</text>
           </view>
           <view class="audio-info">
             <text class="audio-title">{{ item.title }}</text>
-            <text class="audio-meta">{{ item.wordCount }}字 · {{ formatDate(item.createdAt) }}</text>
+            <view class="audio-progress-row">
+              <view class="mini-progress-bar">
+                <view class="mini-progress-fill" :style="{ width: (item.progress || 0) + '%' }"></view>
+              </view>
+              <text class="audio-meta">{{ item.wordCount }}字 · {{ formatDuration(item.audioDuration) }}</text>
+            </view>
+            <text class="audio-date">{{ formatDate(item.createdAt) }}</text>
           </view>
           <view class="publish-btn" @click.stop="publishAudio(item)" v-if="!isEditing">
             <text class="publish-icon">🚀</text>
           </view>
         </view>
+        </template>
       </view>
 
       <view v-if="loading" class="loading">
@@ -106,12 +143,13 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, nextTick } from 'vue';
+import { ref, computed, onMounted, nextTick } from 'vue';
 import { useAudioStore } from '../../store/audio';
 import { get, post, getFullUrl } from '../../utils/request';
 import { getBatchDownloadUrls } from '../../api/download';
 import type { AudioItem } from '../../types';
 import SkeletonList from '../../components/SkeletonList.vue';
+import { getCoverGradient, getTitleLetter, getBookGradient } from '../../composables/useCoverStyle';
 
 const audioStore = useAudioStore();
 
@@ -144,25 +182,40 @@ const total = ref(0);
 const loading = ref(false);
 const hasMore = ref(true);
 
-// 音色封面颜色映射
-const voiceColors: Record<string, string[]> = {
-  cherry: ['#667eea', '#764ba2'],
-  ethan: ['#f093fb', '#f5576c'],
-  serena: ['#4facfe', '#00f2fe'],
-  chelsie: ['#43e97b', '#38f9d7'],
-  momo: ['#fa709a', '#fee140'],
-  vivian: ['#a8edea', '#fed6e3'],
-  moon: ['#5ee7df', '#b490ca'],
-  maia: ['#d299c2', '#fef9d7'],
-  kai: ['#89f7fe', '#66a6ff'],
-  nofish: ['#cd9cf2', '#f6f3ff'],
-};
-
-// 获取封面渐变色
-function getCoverGradient(voiceId: string): string {
-  const colors = voiceColors[voiceId] || ['#667eea', '#764ba2'];
-  return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
-}
+// 按书籍聚合
+const groupByBook = ref(false);
+
+// 分组后的列表
+const groupedAudioList = computed(() => {
+  if (!groupByBook.value) return null;
+  const groups: Record<string, { bookId: number; items: AudioItem[]; totalDuration: number }> = {};
+  audioList.value.forEach(item => {
+    const key = item.bookId ? `book_${item.bookId}` : 'other';
+    if (!groups[key]) {
+      groups[key] = { bookId: item.bookId || 0, items: [], totalDuration: 0 };
+    }
+    groups[key].items.push(item);
+    groups[key].totalDuration += item.audioDuration || 0;
+  });
+  return Object.values(groups);
+});
+
+// 用于展示的列表(根据分组模式切换)
+const displayList = computed(() => {
+  if (groupByBook.value && groupedAudioList.value) {
+    return groupedAudioList.value.map(g => ({
+      _id: `group_${g.bookId || 'other'}`,
+      isGroup: true,
+      bookId: g.bookId,
+      items: g.items,
+      totalDuration: g.totalDuration,
+      title: g.bookId ? `书籍 #${g.bookId}` : '其他音频',
+    } as any));
+  }
+  return audioList.value;
+});
+
+
 
 // 选择标签
 async function selectTab(tabId: number) {
@@ -385,11 +438,19 @@ function publishAudio(item: AudioItem) {
 
 // 格式化时长
 function formatDuration(seconds: number): string {
+  if (!seconds) return '0:00';
   const mins = Math.floor(seconds / 60);
   const secs = Math.floor(seconds % 60);
   return `${mins}:${secs.toString().padStart(2, '0')}`;
 }
 
+// 跳转书籍详情
+function goToBookDetail(bookId: number) {
+  if (bookId) {
+    uni.navigateTo({ url: `/pages/book-generator/detail?id=${bookId}` });
+  }
+}
+
 // 格式化日期
 function formatDate(dateStr: string): string {
   const date = new Date(dateStr);
@@ -644,6 +705,31 @@ onMounted(async () => {
   opacity: 0.6;
 }
 
+/* 文字海报风格封面 */
+.cover-letter {
+  position: absolute;
+  top: -10rpx;
+  left: 12rpx;
+  font-size: 140rpx;
+  font-weight: 900;
+  color: rgba(255, 255, 255, 0.12);
+  line-height: 1;
+  pointer-events: none;
+}
+
+.cover-title-small {
+  position: absolute;
+  bottom: 40rpx;
+  left: 16rpx;
+  right: 16rpx;
+  font-size: 22rpx;
+  color: rgba(255, 255, 255, 0.85);
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .play-overlay {
   position: absolute;
   top: 0;
@@ -736,4 +822,137 @@ onMounted(async () => {
   font-size: 24rpx;
   color: #9ca3af;
 }
+
+/* 分组切换按钮 */
+.group-toggle {
+  padding: 12rpx 24rpx;
+  display: flex;
+  align-items: center;
+  position: absolute;
+  right: 24rpx;
+  top: 50%;
+  transform: translateY(-50%);
+  background: rgba(79, 70, 229, 0.08);
+  border-radius: 32rpx;
+  border: 1rpx solid rgba(79, 70, 229, 0.2);
+}
+
+.group-toggle-text {
+  font-size: 24rpx;
+  color: #4f46e5;
+  white-space: nowrap;
+}
+
+/* 分组卡片 */
+.group-card {
+  width: 100%;
+  background: #ffffff;
+  border-radius: 16rpx;
+  overflow: hidden;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
+}
+
+.group-header {
+  padding: 24rpx;
+  background: linear-gradient(135deg, #f0f0ff 0%, #faf5ff 100%);
+  border-bottom: 1rpx solid #e5e7eb;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+}
+
+.group-title {
+  font-size: 28rpx;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.group-count {
+  font-size: 22rpx;
+  color: #9ca3af;
+}
+
+/* 分组内音频卡片 */
+.group-item {
+  width: 100%;
+  border-radius: 0;
+  box-shadow: none;
+  margin-bottom: 0;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding: 16rpx 24rpx;
+  border-bottom: 1rpx solid #f3f4f6;
+}
+
+.group-item:last-child {
+  border-bottom: none;
+}
+
+.group-item .checkbox {
+  position: relative;
+  top: auto;
+  left: auto;
+  margin-right: 16rpx;
+  flex-shrink: 0;
+}
+
+/* 分组模式小封面 */
+.audio-cover-mini {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+  margin-right: 20rpx;
+}
+
+.cover-icon-small {
+  font-size: 36rpx;
+}
+
+/* 进度条样式 */
+.audio-progress-row {
+  display: flex;
+  flex-direction: column;
+  gap: 8rpx;
+  margin-bottom: 4rpx;
+}
+
+.mini-progress-bar {
+  width: 100%;
+  height: 6rpx;
+  background: #e5e7eb;
+  border-radius: 3rpx;
+  overflow: hidden;
+}
+
+.mini-progress-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 100%);
+  border-radius: 3rpx;
+  transition: width 0.3s ease;
+}
+
+/* 日期标签 */
+.audio-date {
+  font-size: 20rpx;
+  color: #9ca3af;
+  margin-top: 4rpx;
+}
+
+/* 分组模式下音频信息 */
+.group-item .audio-info {
+  flex: 1;
+  min-width: 0;
+  padding: 0;
+}
+
+.group-item .audio-title {
+  font-size: 26rpx;
+  margin-bottom: 4rpx;
+}
 </style>

+ 29 - 16
my-uniapp-vue3/src/pages/mine/index.vue

@@ -68,7 +68,8 @@
           @click="goToDetail(item)"
         >
           <view class="recent-cover" :style="{ background: getCoverGradient(item.voiceId) }">
-            <text class="cover-icon">🎵</text>
+            <text class="cover-letter">{{ getTitleLetter(item.title) }}</text>
+            <text class="cover-title-small">{{ item.title }}</text>
           </view>
           <view class="recent-info">
             <text class="recent-title-text">{{ item.title }}</text>
@@ -150,6 +151,7 @@ import { onShow } from '@dcloudio/uni-app';
 import { useUserStore } from '../../store/user';
 import { get } from '../../utils/request';
 import type { AudioItem } from '../../types';
+import { getCoverGradient, getTitleLetter } from '../../composables/useCoverStyle';
 
 const userStore = useUserStore();
 
@@ -228,21 +230,6 @@ async function fetchRecentCreations() {
   }
 }
 
-// 获取封面渐变色
-function getCoverGradient(voiceId: string): string {
-  const gradients = [
-    'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
-    'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
-    'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
-    'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
-    'linear-gradient(135deg, #fa709a 0%, #fee140 100%)'
-  ];
-  let hash = 0;
-  for (let i = 0; i < voiceId.length; i++) {
-    hash = voiceId.charCodeAt(i) + ((hash << 5) - hash);
-  }
-  return gradients[Math.abs(hash) % gradients.length];
-}
 
 // 格式化日期
 function formatDate(dateStr: string): string {
@@ -621,6 +608,32 @@ function handleLogout() {
   align-items: center;
   justify-content: center;
   flex-shrink: 0;
+  position: relative;
+  overflow: hidden;
+}
+
+.cover-letter {
+  position: absolute;
+  top: -8rpx;
+  left: 4rpx;
+  font-size: 60rpx;
+  font-weight: 900;
+  color: rgba(255, 255, 255, 0.15);
+  line-height: 1;
+  pointer-events: none;
+}
+
+.cover-title-small {
+  position: absolute;
+  bottom: 4rpx;
+  left: 8rpx;
+  right: 8rpx;
+  font-size: 16rpx;
+  color: rgba(255, 255, 255, 0.8);
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
 .cover-icon {

+ 37 - 17
my-uniapp-vue3/src/pages/player/index.vue

@@ -219,25 +219,39 @@ const sleepTimerOptions = [
   { label: '当前播放结束', value: -1 },
 ];
 
-// 解析 LRC 格式歌词
+// 解析 LRC 格式歌词(兼容换行分隔和单行连续两种格式)
 function parseLrc(lrcText: string): { text: string; start: number; end: number }[] {
   if (!lrcText) return [];
 
   const timeline: { text: string; start: number; end: number }[] = [];
-  const lines = lrcText.split('\n');
-
-  for (const line of lines) {
-    // 匹配 [MM:SS.XX] 格式
-    const match = line.match(/^\[(\d{2}):(\d{2})\.(\d{2})\]\s*(.*)$/);
-    if (match) {
-      const minutes = parseInt(match[1]);
-      const seconds = parseInt(match[2]);
-      const centiseconds = parseInt(match[3]);
-      const text = match[4].trim();
 
+  // 优先按换行分割,如果只有1行则用全局匹配提取所有时间戳
+  const lines = lrcText.split('\n');
+  if (lines.length <= 1) {
+    // 单行格式:用正则提取所有 [MM:SS.XX]text 对
+    const matches = lrcText.matchAll(/\[(\d{2}):(\d{2})\.(\d{2})\]\s*([^[]*)/g);
+    for (const m of matches) {
+      const minutes = parseInt(m[1]);
+      const seconds = parseInt(m[2]);
+      const cs = parseInt(m[3]);
+      const text = m[4].trim();
       if (text) {
-        const start = minutes * 60 + seconds + centiseconds / 100;
-        timeline.push({ text, start, end: start }); // end 暂时未知,后续计算
+        timeline.push({ text, start: minutes * 60 + seconds + cs / 100, end: 0 });
+      }
+    }
+  } else {
+    // 标准格式:按行处理
+    for (const line of lines) {
+      const match = line.match(/^\[(\d{2}):(\d{2})\.(\d{2})\]\s*(.*)$/);
+      if (match) {
+        const text = match[4].trim();
+        if (text) {
+          timeline.push({
+            text,
+            start: parseInt(match[1]) * 60 + parseInt(match[2]) + parseInt(match[3]) / 100,
+            end: 0,
+          });
+        }
       }
     }
   }
@@ -246,9 +260,9 @@ function parseLrc(lrcText: string): { text: string; start: number; end: number }
   for (let i = 0; i < timeline.length - 1; i++) {
     timeline[i].end = timeline[i + 1].start;
   }
-  // 最后一歌词设置一个大致的结束时间
   if (timeline.length > 0) {
-    timeline[timeline.length - 1].end = timeline[timeline.length - 1].start + 5;
+    const last = timeline[timeline.length - 1];
+    last.end = last.start + 5;
   }
 
   return timeline;
@@ -288,7 +302,8 @@ function generateLyricsTimeline(text: string, totalDuration: number): { text: st
   // 权重分配:标题 0.3x,正文 1.0x
   const weights = sections.map(s => /^#{1,2}\s/.test(s) ? 0.3 : 1.0);
   const weightedChars = sections.map((s, i) => {
-    const chars = (s.match(/[一-龥a-zA-Z0-9]/g) || []).length;
+    // 统计所有可见字符(含标点、数字、符号),不只汉字字母
+    const chars = s.replace(/\s/g, '').length;
     return chars * weights[i];
   });
   const totalWeighted = weightedChars.reduce((a, b) => a + b, 0);
@@ -316,11 +331,14 @@ function generateLyricsTimeline(text: string, totalDuration: number): { text: st
 }
 
 // 根据当前播放时间获取当前句子索引,并自动滚动
+// 滚动到当前行的前一行,确保高亮行上下都有可见文本作为缓冲
 const currentLyricIndex = computed(() => {
   const time = currentTime.value;
   const index = lyrics.value.findIndex(l => time >= l.start && time < l.end);
   if (index >= 0) {
-    scrollIntoViewId.value = 'lyric-' + index;
+    // 滚动到前一行(最小为 0,即缓冲空行),使当前行上下文本都可见
+    const scrollIndex = Math.max(0, index - 1);
+    scrollIntoViewId.value = 'lyric-' + scrollIndex;
   }
   return index;
 });
@@ -403,6 +421,8 @@ async function fetchAudio() {
     } else {
       lyrics.value = generateLyricsTimeline(result.text || '', audioDuration);
     }
+    // 在歌词列表前插入空行作为视觉缓冲,确保高亮当前行时前后文都可见
+    lyrics.value = [{ text: '', start: 0, end: 0 }, ...lyrics.value];
 
     // 开始播放(store 中已初始化 audioContext)
     audioStore.play(result);

+ 31 - 20
my-uniapp-vue3/src/pages/search/index.vue

@@ -26,11 +26,12 @@
           @click="playAudio(item)"
         >
           <view class="result-cover" :style="{ background: getCoverGradient(item.voiceId) }">
-            <text class="cover-icon">🎵</text>
+            <text class="cover-letter">{{ getTitleLetter(item.title) }}</text>
+            <text class="cover-title-small">{{ item.title }}</text>
           </view>
           <view class="result-info">
             <text class="result-title" v-html="highlightText(item.title, searchQuery)"></text>
-            <text class="result-desc" v-if="item.description" v-html="highlightText(item.description.slice(0, 50) + '...', searchQuery)"></text>
+            <text class="result-desc" v-if="(item as any).description" v-html="highlightText((item as any).description.slice(0, 50) + '...', searchQuery)"></text>
             <text class="result-meta">{{ item.wordCount || 0 }}字</text>
           </view>
         </view>
@@ -97,6 +98,7 @@ import { useAudioStore } from '../../store/audio';
 import { get, post } from '../../utils/request';
 import type { AudioItem } from '../../types';
 import SkeletonList from '../../components/SkeletonList.vue';
+import { getCoverGradient, getTitleLetter } from '../../composables/useCoverStyle';
 
 // 缓存键名
 const HOT_SEARCH_CACHE_KEY = 'hot_search_cache';
@@ -113,24 +115,6 @@ const loading = ref(false);
 const searchHistory = ref<{ keyword: string }[]>([]);
 const hotSearches = ref<{ id: number; keyword: string; count: number }[]>([]);
 
-// 音色封面颜色映射
-const voiceColors: Record<string, string[]> = {
-  cherry: ['#667eea', '#764ba2'],
-  ethan: ['#f093fb', '#f5576c'],
-  serena: ['#4facfe', '#00f2fe'],
-  chelsie: ['#43e97b', '#38f9d7'],
-  momo: ['#fa709a', '#fee140'],
-  vivian: ['#a8edea', '#fed6e3'],
-  moon: ['#5ee7df', '#b490ca'],
-  maia: ['#d299c2', '#fef9d7'],
-  kai: ['#89f7fe', '#66a6ff'],
-  nofish: ['#cd9cf2', '#f6f3ff'],
-};
-
-function getCoverGradient(voiceId: string): string {
-  const colors = voiceColors[voiceId] || ['#667eea', '#764ba2'];
-  return `linear-gradient(135deg, ${colors[0]} 0%, ${colors[1]} 100%)`;
-}
 
 // 高亮搜索关键词
 function highlightText(text: string, keyword: string): string {
@@ -323,6 +307,8 @@ onMounted(() => {
   align-items: center;
   justify-content: center;
   flex-shrink: 0;
+  position: relative;
+  overflow: hidden;
 }
 
 .cover-icon {
@@ -330,6 +316,31 @@ onMounted(() => {
   opacity: 0.6;
 }
 
+/* 文字海报风格封面 */
+.cover-letter {
+  position: absolute;
+  top: -8rpx;
+  left: 6rpx;
+  font-size: 70rpx;
+  font-weight: 900;
+  color: rgba(255, 255, 255, 0.14);
+  line-height: 1;
+  pointer-events: none;
+}
+
+.cover-title-small {
+  position: absolute;
+  bottom: 6rpx;
+  left: 10rpx;
+  right: 10rpx;
+  font-size: 18rpx;
+  color: rgba(255, 255, 255, 0.8);
+  font-weight: 500;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
 .result-info {
   flex: 1;
   display: flex;

+ 113 - 23
my-uniapp-vue3/src/store/audio.ts

@@ -17,16 +17,49 @@ export const useAudioStore = defineStore('audio', () => {
   // 播放模式: 'sequence' | 'loop' | 'single' | 'random'
   const playMode = ref<'sequence' | 'loop' | 'single' | 'random'>('sequence');
 
-  // 音频上下文
+  // 音频上下文 - 双模式:BackgroundAudioManager(App后台播放)+ InnerAudioContext(H5降级)
   let audioContext: UniApp.InnerAudioContext | null = null;
+  let bgAudioManager: any = null;
+  // H5 环境不支持 BackgroundAudioManager,使用 InnerAudioContext
+  const useBackgroundAudio: boolean = (function() {
+    // #ifdef H5
+    return false;
+    // #endif
+    // #ifndef H5
+    return true;
+    // #endif
+  })();
 
   // 计算属性
   const hasPlaylist = computed(() => playlist.value.length > 0);
   const hasNext = computed(() => currentIndex.value < playlist.value.length - 1);
   const hasPrev = computed(() => currentIndex.value > 0);
 
+  // 获取当前活跃的音频上下文(背景模式优先)
+  function getActiveContext() {
+    if (useBackgroundAudio && bgAudioManager) return bgAudioManager;
+    return audioContext;
+  }
+
   // 初始化音频上下文
   function initAudioContext() {
+    if (useBackgroundAudio) {
+      if (!bgAudioManager) {
+        try {
+          bgAudioManager = uni.getBackgroundAudioManager();
+          setupBackgroundAudioEvents();
+          console.log('[Audio] 使用 BackgroundAudioManager 支持后台播放');
+        } catch (e) {
+          console.warn('[Audio] BackgroundAudioManager 不可用,降级到 InnerAudioContext');
+          initInnerAudioContext();
+        }
+      }
+      return;
+    }
+    initInnerAudioContext();
+  }
+
+  function initInnerAudioContext() {
     if (audioContext) return;
 
     audioContext = uni.createInnerAudioContext();
@@ -41,11 +74,9 @@ export const useAudioStore = defineStore('audio', () => {
 
     audioContext.onEnded(() => {
       isPlaying.value = false;
-      // 自动播放下一首
       if (hasNext.value) {
         playNext();
       } else {
-        // 列表播放完毕,停止播放
         handlePlayMode();
       }
     });
@@ -53,7 +84,6 @@ export const useAudioStore = defineStore('audio', () => {
     audioContext.onTimeUpdate(() => {
       currentTime.value = audioContext?.currentTime || 0;
       const d = audioContext?.duration || 0;
-      // 只有当音频元数据时长合理(小于1小时)且当前显示时长异常(为0或异常大)时才更新
       if (d > 0 && d < 3600) {
         if (duration.value === 0 || duration.value > 3600 || Math.abs(duration.value - d) < 1) {
           duration.value = d;
@@ -63,13 +93,9 @@ export const useAudioStore = defineStore('audio', () => {
 
     audioContext.onError((err: any) => {
       console.error('音频播放错误:', err);
-      console.error('错误码:', err.errCode);
-      console.error('错误信息:', err.errMsg);
       isPlaying.value = false;
-      // 尝试获取更详细的错误信息
       if (audioContext) {
         console.error('audioContext.src:', audioContext.src);
-        console.error('audioContext.readyState:', audioContext.readyState);
       }
     });
 
@@ -78,6 +104,47 @@ export const useAudioStore = defineStore('audio', () => {
     });
   }
 
+  // 设置 BackgroundAudioManager 事件
+  function setupBackgroundAudioEvents() {
+    if (!bgAudioManager) return;
+
+    bgAudioManager.onPlay(() => {
+      isPlaying.value = true;
+    });
+
+    bgAudioManager.onPause(() => {
+      isPlaying.value = false;
+    });
+
+    bgAudioManager.onStop(() => {
+      isPlaying.value = false;
+    });
+
+    bgAudioManager.onEnded(() => {
+      isPlaying.value = false;
+      if (hasNext.value) {
+        playNext();
+      } else {
+        handlePlayMode();
+      }
+    });
+
+    bgAudioManager.onTimeUpdate(() => {
+      currentTime.value = bgAudioManager?.currentTime || 0;
+      const d = bgAudioManager?.duration || 0;
+      if (d > 0 && d < 3600) {
+        if (duration.value === 0 || duration.value > 3600 || Math.abs(duration.value - d) < 1) {
+          duration.value = d;
+        }
+      }
+    });
+
+    bgAudioManager.onError((err: any) => {
+      console.error('[BackgroundAudio] 播放错误:', err);
+      isPlaying.value = false;
+    });
+  }
+
   // 获取音色列表
   async function fetchVoices() {
     const result = await get<{ voices: Voice[] }>('/tts/voices');
@@ -112,7 +179,7 @@ export const useAudioStore = defineStore('audio', () => {
   function play(audio: AudioItem) {
     initAudioContext();
 
-    // 重置时长,避免切换书籍后显示旧时长
+    // 重置时长
     currentTime.value = 0;
     duration.value = 0;
 
@@ -122,19 +189,30 @@ export const useAudioStore = defineStore('audio', () => {
     const fullUrl = getFullUrl(audio.audioUrl);
     console.log('播放音频:', audio.title, 'URL:', fullUrl, '时长:', audio.audioDuration);
 
+    const ctx = getActiveContext();
+    if (!ctx) {
+      console.error('audioContext 未初始化!');
+      return;
+    }
+
+    // BackgroundAudioManager 模式下设置锁屏信息
+    if (useBackgroundAudio && bgAudioManager) {
+      bgAudioManager.title = audio.title || '有声书';
+      bgAudioManager.epname = audio.title || '';
+      bgAudioManager.singer = 'AI有声书';
+    }
+
     // 如果音频源相同,只切换播放状态
-    if (audioContext && audioContext.src === fullUrl) {
+    if (ctx.src === fullUrl) {
       console.log('音频源相同,切换播放状态');
-      audioContext.play();
-    } else if (audioContext) {
-      console.log('设置新音频源并播放:', fullUrl);
-      audioContext.src = fullUrl;
-      audioContext.play();
+      ctx.play();
     } else {
-      console.error('audioContext 未初始化!');
+      console.log('设置新音频源并播放:', fullUrl);
+      ctx.src = fullUrl;
+      ctx.play();
     }
 
-    // 使用 API 返回的 audioDuration 作为初始时长(避免音频文件 metadata 错误的问题)
+    // 使用 API 返回的 audioDuration 作为初始时长
     if (audio.audioDuration && audio.audioDuration > 0 && audio.audioDuration < 3600) {
       duration.value = audio.audioDuration;
     }
@@ -142,15 +220,17 @@ export const useAudioStore = defineStore('audio', () => {
 
   // 暂停
   function pause() {
-    if (audioContext) {
-      audioContext.pause();
+    const ctx = getActiveContext();
+    if (ctx) {
+      ctx.pause();
     }
   }
 
   // 继续播放
   function resume() {
-    if (audioContext) {
-      audioContext.play();
+    const ctx = getActiveContext();
+    if (ctx) {
+      ctx.play();
     }
   }
 
@@ -232,8 +312,9 @@ export const useAudioStore = defineStore('audio', () => {
 
   // 跳转到指定位置
   function seek(time: number) {
-    if (audioContext) {
-      audioContext.seek(time);
+    const ctx = getActiveContext();
+    if (ctx) {
+      ctx.seek(time);
       currentTime.value = time;
     }
   }
@@ -241,6 +322,10 @@ export const useAudioStore = defineStore('audio', () => {
   // 设置播放速度
   function setPlayRate(rate: number) {
     playRate.value = rate;
+    if (useBackgroundAudio && bgAudioManager) {
+      // BackgroundAudioManager 不支持 playbackRate
+      return;
+    }
     if (audioContext) {
       audioContext.playbackRate = rate;
     }
@@ -257,10 +342,15 @@ export const useAudioStore = defineStore('audio', () => {
 
   // 销毁音频上下文
   function destroy() {
+    if (useBackgroundAudio && bgAudioManager) {
+      bgAudioManager.stop();
+      bgAudioManager = null;
+    }
     if (audioContext) {
       audioContext.destroy();
       audioContext = null;
     }
+    isPlaying.value = false;
   }
 
   return {

+ 2 - 0
my-uniapp-vue3/src/types/index.ts

@@ -38,6 +38,8 @@ export interface AudioItem {
   isPublic?: boolean;    // 是否公开(2026-04-14 新增)
   isOwner?: boolean;     // 是否为当前用户所有
   lrcLyrics?: string;     // LRC 格式歌词时间轴(OPT-17)
+  bookId?: number;        // 所属书籍ID
+  progress?: number;      // 收听进度 0-100
   createdAt: string;
   updatedAt: string;
 }

+ 5 - 1
server/prisma/schema.prisma

@@ -24,6 +24,7 @@ model User {
   comments              Comment[]
   drafts                Draft[]
   favorites             Favorite[]
+  notifications         Notification[]
   orders                Order[]
   playRecords           PlayRecord[]
   playlists             Playlist[]
@@ -120,11 +121,14 @@ model Comment {
 
 model Notification {
   id        String   @id @default(uuid())
-  userId    String
+  userId    Int
   title     String
   content   String   @db.Text
   isRead    Boolean  @default(false)
   createdAt DateTime @default(now())
+  user      User     @relation(fields: [userId], references: [id])
+
+  @@index([userId])
 }
 
 model Book {

+ 2 - 2
server/src/middleware/auth.ts

@@ -3,6 +3,7 @@ import jwt from 'jsonwebtoken';
 import { config } from '../config';
 import { UnauthorizedError, AppError } from './errorHandler';
 import { JwtPayload } from '../types';
+import { safeParseInt } from '../utils/safe-parse';
 
 export async function authMiddleware(ctx: Context, next: Next): Promise<void> {
   // 开发阶段默认跳过认证(除非显式设置 AUTH_ENABLED=true)
@@ -31,8 +32,7 @@ export async function authMiddleware(ctx: Context, next: Next): Promise<void> {
   const token = parts[1];
 
   try {
-    // 使用硬编码的secret,确保和generateToken一致
-    const secret = 'my-jwt-secret-key-2024';
+    const secret = config.jwt.secret;
     const payload = jwt.verify(token, secret) as JwtPayload;
     ctx.state.user = payload;
     await next();

+ 4 - 2
server/src/modules/auth/auth.controller.ts

@@ -4,6 +4,7 @@ import * as AuthService from './auth.service';
 import { BadRequestError } from '../../middleware/errorHandler';
 import { authMiddleware } from '../../middleware/auth';
 import { prisma } from '../../models';
+import { safeParseInt } from '../../utils/safe-parse';
 
 const router = new Router();
 
@@ -66,15 +67,16 @@ router.get('/user-info', authMiddleware, async (ctx: Context) => {
 // 更新用户信息
 router.put('/user-info', authMiddleware, async (ctx: Context) => {
   const userId = ctx.state.user.userId;
+  const uid = safeParseInt(userId);
   const { nickname, avatar } = ctx.request.body as { nickname?: string; avatar?: string };
 
-  const user = await prisma.user.findUnique({ where: { id: parseInt(userId) } });
+  const user = await prisma.user.findUnique({ where: { id: uid } });
   if (!user) {
     throw new BadRequestError('用户不存在');
   }
 
   const updatedUser = await prisma.user.update({
-    where: { id: parseInt(userId) },
+    where: { id: uid },
     data: {
       ...(nickname && { nickname }),
       ...(avatar && { avatar }),

+ 1 - 1
server/src/modules/auth/auth.service.ts

@@ -33,7 +33,7 @@ export function verifySmsCode(phone: string, code: string): boolean {
 
 // 生成 JWT Token
 export function generateToken(userId: string, phone?: string): string {
-  const secret = 'my-jwt-secret-key-2024';
+  const secret = config.jwt.secret;
   const payload: Omit<JwtPayload, 'iat' | 'exp'> = { userId, phone };
   return jwt.sign(payload, secret, {
     expiresIn: '7d',

+ 9 - 5
server/src/modules/book-generator/album-controller.ts

@@ -245,16 +245,20 @@ router.get('/albums/:id/chapters', optionalAuth, async (ctx: Context) => {
       }
     });
 
-    // 为每个章设置audioUrl(优先用小节合并后的,如果没有小节则用章自己的音频)
+    // 为每个章设置audioUrl
+    // 优先级:1. 章自身已有的合并音频(_merged) > 2. 章自身的audioUrl > 3. 仅汇总子节时长
+    // 注意:不再用 subs[0].audioUrl 覆盖章的 audioUrl,因为章的合并音频已在 mergeChapterAudios 中正确写入
     chapterMap.forEach((chapter, chapterId) => {
       const subs = subsectionsByChapter.get(chapterId) || [];
-      if (subs.length > 0) {
-        // 如果有subsection音频,使用subsection的
-        chapter.audioUrl = subs[0].audioUrl;
+      if (subs.length > 0 && (!chapter.audioUrl || chapter.audioUrl.trim() === '')) {
+        // 章自身没有音频(短文章以外的情况),仅汇总子节时长,不覆盖audioUrl
+        // 前端播放章级别时会通过 player 接口自动触发合并
+        chapter.audioDuration = subs.reduce((acc, s) => acc + s.audioDuration, 0);
+      } else if (subs.length > 0) {
+        // 章已有音频(可能是合并后的 _merged 音频),保留它,只汇总时长
         chapter.audioDuration = subs.reduce((acc, s) => acc + s.audioDuration, 0);
       }
       // 否则保持章自己的audioUrl(短文章直接生成在章上)
-      // chapter.audioUrl 已经会在 line 126 被设置为 c.audioUrl || null
     });
 
     // 转换为数组并应用过滤规则

+ 43 - 31
server/src/modules/book-generator/book-generator.service.ts

@@ -7,11 +7,9 @@ import { bookStore } from './book-generator.store';
 import { pushBatchGenerationProgress } from '../../services/websocket.service.js';
 import { createVideoProjectFromBook, generateVideoForProject } from '../video-generator/video-generator.service';
 import { mergeChapterAudios } from '../player/player.service';
-import { PrismaClient } from '@prisma/client';
+import { prisma } from '../../models';
 import { advanceChapter, regenerateChapter } from './stage-manager';
 
-const prisma = new PrismaClient();
-
 // 步骤类型
 export type GenerationStep = 'generate_content' | 'generate_audio' | 'merge_audio' | 'generate_video' | 'merge_video';
 
@@ -312,47 +310,61 @@ export class BatchGenerationOrchestrator {
 
     this.pushProgress('merge_audio', 20, `开始合并 ${leafNodes.length} 个章节音频`);
 
-    // 按父节点分组叶节点
-    const groupedByParent: { [key: number]: any[] } = {};
+    // 构建 sectionId → chapterId 映射(3层树需要)
+    const sectionToChapter = new Map<number, number>();
+    if (maxLevel >= 3) {
+      const sections = chapters.filter((c: any) => c.level === 2);
+      for (const sec of sections) {
+        if (sec.parentId != null) {
+          sectionToChapter.set(sec.id, sec.parentId);
+        }
+      }
+    }
+
+    // 按章(level=1)分组叶节点
+    const groupedByChapter: { [key: number]: any[] } = {};
     leafNodesWithAudio.forEach(node => {
-      if (node.parentId) {
-        if (!groupedByParent[node.parentId]) {
-          groupedByParent[node.parentId] = [];
+      let chapterId: number | null = null;
+      if (maxLevel === 2) {
+        chapterId = node.parentId;
+      } else if (maxLevel === 3 && node.parentId != null) {
+        chapterId = sectionToChapter.get(node.parentId) || null;
+      }
+
+      if (chapterId) {
+        if (!groupedByChapter[chapterId]) {
+          groupedByChapter[chapterId] = [];
         }
-        groupedByParent[node.parentId].push(node);
+        groupedByChapter[chapterId].push(node);
       }
     });
 
-    const totalParents = Object.keys(groupedByParent).length;
-    let processedParents = 0;
+    const chapterIds = Object.keys(groupedByChapter);
+    const totalChapters = chapterIds.length;
+    let processedChapters = 0;
 
-    // 对每个父节点下的叶节点音频进行合并
-    for (const parentId in groupedByParent) {
+    // 对每个下的叶节点音频进行合并
+    for (const chapterId of chapterIds) {
       this.checkCancellation();
 
-      const childNodes = groupedByParent[parentId];
-      if (childNodes.length > 0) {
-        const parentChapter = chapters.find((c: any) => c.id === parseInt(parentId));
+      const chId = parseInt(chapterId);
+      const childNodes = groupedByChapter[chId];
 
-        if (parentChapter) {
-          try {
-            const mergedAudioUrl = await mergeChapterAudios(parentChapter.id);
+      if (childNodes.length > 0) {
+        try {
+          const mergedAudioUrl = await mergeChapterAudios(chId);
 
-            if (mergedAudioUrl) {
-              await bookStore.updateChapterById(parentChapter.id, {
-                audioUrl: mergedAudioUrl,
-              });
-              console.log(`[BatchGen][${this.taskId}] 章节 ${parentChapter.number} 音频合并完成`);
-            }
-          } catch (error) {
-            console.error(`[BatchGen][${this.taskId}] 章节 ${parentChapter.number} 音频合并失败:`, error);
+          if (mergedAudioUrl) {
+            console.log(`[BatchGen][${this.taskId}] 章 ${chId} 音频合并完成`);
           }
+        } catch (error) {
+          console.error(`[BatchGen][${this.taskId}] 章 ${chId} 音频合并失败:`, error);
         }
       }
 
-      processedParents++;
-      const progress = 20 + Math.round((processedParents / totalParents) * 70);
-      this.pushProgress('merge_audio', Math.min(progress, 95), `音频合并中: ${processedParents}/${totalParents}`);
+      processedChapters++;
+      const progress = 20 + Math.round((processedChapters / totalChapters) * 70);
+      this.pushProgress('merge_audio', Math.min(progress, 95), `音频合并中: ${processedChapters}/${totalChapters}`);
     }
 
     this.pushProgress('merge_audio', 100, '音频合并完成');
@@ -483,7 +495,7 @@ export class BatchGenerationOrchestrator {
     // 按父节点分组叶节点
     const groupedByParent: { [key: number]: any[] } = {};
     leafNodesWithVideo.forEach(node => {
-      if (node.parentId) {
+      if (node.parentId != null) {
         if (!groupedByParent[node.parentId]) {
           groupedByParent[node.parentId] = [];
         }

+ 37 - 11
server/src/modules/book-generator/book-generator.store.ts

@@ -84,7 +84,7 @@ async function tryAutoMerge(
   leafLevel: number | null,
   leafParentId: number | null,
 ) {
-  if (!bookId || !leafLevel || leafLevel <= 1) return;
+  if (!bookId || leafLevel == null || leafLevel <= 1) return;
 
   try {
     // 1. 找到该叶节点所属的1级章节
@@ -92,7 +92,7 @@ async function tryAutoMerge(
 
     if (leafLevel === 2) {
       chapterId = leafParentId;
-    } else if (leafLevel === 3 && leafParentId) {
+    } else if (leafLevel === 3 && leafParentId != null) {
       const parentSection = await prisma.bookChapter.findUnique({
         where: { id: leafParentId },
         select: { parentId: true, level: true },
@@ -107,7 +107,7 @@ async function tryAutoMerge(
     // 2. 计算该书的最大层级
     const allChapters = await prisma.bookChapter.findMany({
       where: { bookId },
-      select: { id: true, level: true, parentId: true, audioUrl: true },
+      select: { id: true, level: true, parentId: true, audioUrl: true, audioDuration: true },
     });
 
     const maxLevel = Math.max(...allChapters.map(c => c.level));
@@ -163,10 +163,28 @@ async function tryAutoMerge(
     }
 
     // 5. 检查该章节是否已有合并音频(幂等)
+    // 但如果父章节的合并音频时长与子节时长总和不匹配,需要重新合并
     const chapter = nodeMap.get(chapterId);
     if (chapter?.audioUrl && chapter.audioUrl.includes('_merged')) {
-      console.log(`[AutoMerge] 章节${chapterId}已有合并音频,跳过`);
-      return;
+      // 检查合并音频时长是否与子节时长总和匹配
+      const childDurations = leafNodesUnderChapter
+        .filter(ch => ch.audioUrl)
+        .map(ch => ch.audioDuration || 0);
+      const totalChildDuration = childDurations.reduce((sum, d) => sum + d, 0);
+      const parentDuration = chapter.audioDuration || 0;
+
+      // 如果合并音频时长 < 子节总时长的 80%,说明合并不完整,需要重新合并
+      if (parentDuration > 0 && totalChildDuration > 0 && parentDuration < totalChildDuration * 0.8) {
+        console.log(`[AutoMerge] 章节${chapterId}合并音频时长(${parentDuration}s) < 子节总时长(${totalChildDuration}s)的80%,需要重新合并`);
+        // 清除旧的合并音频
+        await prisma.bookChapter.update({
+          where: { id: chapterId },
+          data: { audioUrl: '', audioDuration: 0 },
+        });
+      } else {
+        console.log(`[AutoMerge] 章节${chapterId}已有合并音频,跳过`);
+        return;
+      }
     }
 
     // 6. 触发合并
@@ -735,6 +753,7 @@ export class BookStore {
     contentError: string;
     audioUrl: string;
     audioDuration: number;
+    lrcLyrics: string | null;
     videoUrl: string;
     videoDuration: number;
   }>): Promise<Chapter | null> {
@@ -921,19 +940,25 @@ export class BookStore {
       return null;
     }
 
-    // 检查内容是否生成完成
-    if (!chapterBefore.content || chapterBefore.genStage === 'idle' || chapterBefore.genStage === 'content_generating') {
+    // 检查内容是否生成完成(有内容即可,genStage 不重要)
+    if (!chapterBefore.content || chapterBefore.genStage === 'idle') {
       console.warn(`[Audio] 章节内容未生成完成: ${chapterId}, genStage: ${chapterBefore.genStage}`);
       return null;
     }
 
     // 已经是 audio_generating 或更后,不需要重新生成
-    if (['audio_generating', 'audio_completed', 'video_generating', 'video_completed'].includes(chapterBefore.genStage)) {
+    // 但如果 audioUrl 为空(被 regenerate-audio 清空),允许重新生成
+    const isCompleted = ['audio_generating', 'audio_completed', 'video_generating', 'video_completed'].includes(chapterBefore.genStage);
+    if (isCompleted && chapterBefore.audioUrl) {
       console.log(`[Audio] 章节${chapterId}已在 ${chapterBefore.genStage},跳过音频生成`);
-      return { audioUrl: chapterBefore.audioUrl || '' };
+      return { audioUrl: chapterBefore.audioUrl };
     }
 
-    // 2. 推进到 audio_generating
+    // 2. 推进到 audio_generating(已在完成状态需先回退)
+    if (isCompleted) {
+      console.log(`[Audio] 章节${chapterId}当前 ${chapterBefore.genStage},先回退到 content_completed`);
+      await regenerateChapter(chapterId, 'content_completed');
+    }
     await advanceChapter(chapterId, 'audio_generating');
 
     // 3. 再次检查状态(防止并发触发)
@@ -958,9 +983,10 @@ export class BookStore {
         async (audioUrl: string, duration: number) => {
           const callbackChapterId = chapterId;  // 捕获当前闭包的 chapterId
           console.log(`[Audio] ★回调触发: callbackChapterId=${callbackChapterId}, audioUrl=${audioUrl}, duration=${duration}`);
+          console.log(`[Audio] 即将 updateChapterById audioUrl=${audioUrl?.substring(0,40)}..., dur=${duration} -- 注意:不覆盖 lrcLyrics`);
           try {
             console.log(`[Audio] 开始处理音频回调...`);
-            // 先更新音频URL和时长
+            // 先更新音频URL和时长(只更新这两个字段,不碰 lrcLyrics)
             await bookStore.updateChapterById(callbackChapterId, {
               audioUrl,
               audioDuration: duration,

+ 101 - 35
server/src/modules/book-generator/langgraph-controller.ts

@@ -7,9 +7,7 @@ import Router from '@koa/router';
 import { Context } from 'koa';
 import { langGraphGenerator, getScaleConfig, resolveGenLevel, mapBookTypeToGenLevel } from './index';
 import { bookStore } from './book-generator.store';
-import { PrismaClient } from '@prisma/client';
-
-const prisma = new PrismaClient();
+import { prisma } from '../../models';
 import { estimateBookWords, estimateAudioMinutesFromWords, checkBookGenerationQuota, checkAudioQuota } from '../subscription/subscription.service';
 import { optionalAuth } from '../../middleware/auth';
 import { getAllBookTypes, getDetectableTypes, getBookTypeConfig, BOOK_TYPE_CONFIG, DETECTABLE_TYPES } from './book-type-config';
@@ -654,6 +652,14 @@ router.get('/books/:id/progress', async (ctx: Context) => {
     const completedChapters = book.chapters.filter((c) => c.genStage === 'video_completed').length;
     const totalChapters = book.outline?.chapters?.length || book.totalChapters || 0;
 
+    // 查找当前正在生成的章节
+    const currentGenerating = book.chapters.find(
+      (c) => c.genStage === 'content_generating' || c.genStage === 'audio_generating' || c.genStage === 'video_generating'
+    );
+    const currentChapter = currentGenerating
+      ? { number: currentGenerating.number, title: currentGenerating.title, level: currentGenerating.level }
+      : null;
+
     // 如果有大纲,使用大纲章节数计算进度
     let progress = book.progress;
     if (totalChapters > 0 && book.genStage !== 'video_completed') {
@@ -669,6 +675,7 @@ router.get('/books/:id/progress', async (ctx: Context) => {
         progress,
         completedChapters,
         totalChapters,
+        currentChapter,
       },
     };
   } catch (error) {
@@ -1752,19 +1759,48 @@ router.post('/books/:id/chapters/:chapterId/regenerate-audio', async (ctx: Conte
       return;
     }
 
-    // 检查内容是否已完成(允许 content_completed/audio_completed/failed 等状态,只要有内容就能重生成音频
-    if (!chapter.content || chapter.genStage === 'idle' || chapter.genStage === 'content_generating') {
+    // 检查内容是否已完成(有内容即可重生成音频,content_generating 但 content 已存在也允许
+    if (!chapter.content || chapter.genStage === 'idle') {
       ctx.status = 400;
       ctx.body = { code: 1, message: '章节内容未生成完成,无法生成音频' };
       return;
     }
 
-    // 清空现有音频
+    // 清空现有音频并回退状态
     await bookStore.updateChapterById(chapterIdNum, {
       audioUrl: '',
       audioDuration: 0,
+      lrcLyrics: null,
+      genStage: 'content_completed',
     });
 
+    // 同时清空父章节的合并音频(因为子节音频变了,合并音频也需要重新生成)
+    if (chapter.parentId) {
+      const parentChapter = await prisma.bookChapter.findUnique({
+        where: { id: chapter.parentId },
+      });
+      if (parentChapter?.audioUrl?.includes('_merged')) {
+        console.log(`[RegenerateAudio] 清除父章节 ${parentChapter.id} 的合并音频(子节${chapterIdNum}已重新生成)`);
+        await bookStore.updateChapterById(parentChapter.id, {
+          audioUrl: '',
+          audioDuration: 0,
+        });
+        // 如果父章节之上还有更高级的合并音频,也一并清除
+        if (parentChapter.parentId) {
+          const grandParent = await prisma.bookChapter.findUnique({
+            where: { id: parentChapter.parentId },
+          });
+          if (grandParent?.audioUrl?.includes('_merged')) {
+            console.log(`[RegenerateAudio] 清除祖父章节 ${grandParent.id} 的合并音频`);
+            await bookStore.updateChapterById(grandParent.id, {
+              audioUrl: '',
+              audioDuration: 0,
+            });
+          }
+        }
+      }
+    }
+
     // 异步生成新音频
     bookStore.generateChapterAudioById(chapterIdNum, book.userId || 1);
 
@@ -2020,49 +2056,70 @@ router.post('/books/:id/merge-audio', async (ctx: Context) => {
       return;
     }
 
-    // 按父节点分组叶节点(通常是1级章节)
-    const groupedByParent: { [key: number]: any[] } = {};
+    // 构建 sectionId → chapterId 映射(3层树需要,2层树直接 parentId 就是 chapterId)
+    const sectionToChapter = new Map<number, number>();
+    if (maxLevel >= 3) {
+      const sections = chapters.filter((c: any) => c.level === 2);
+      for (const sec of sections) {
+        if (sec.parentId) {
+          sectionToChapter.set(sec.id, sec.parentId);
+        }
+      }
+    }
+
+    // 按章(level=1)分组叶节点
+    const groupedByChapter: { [key: number]: any[] } = {};
+    const chapterMeta: { [key: number]: any } = {}; // 章的基本信息
     leafNodesWithAudio.forEach(node => {
-      if (node.parentId) {
-        if (!groupedByParent[node.parentId]) {
-          groupedByParent[node.parentId] = [];
+      // 找到该叶节点所属的1级章ID
+      let chapterId: number | null = null;
+      if (maxLevel === 2) {
+        chapterId = node.parentId; // level=2叶节点的parentId就是章
+      } else if (maxLevel === 3 && node.parentId) {
+        chapterId = sectionToChapter.get(node.parentId) || null; // 通过节→章映射
+      }
+
+      if (chapterId) {
+        if (!groupedByChapter[chapterId]) {
+          groupedByChapter[chapterId] = [];
         }
-        groupedByParent[node.parentId].push(node);
+        groupedByChapter[chapterId].push(node);
       }
     });
 
-    // 对每个父节点下的叶节点音频进行合并
-    for (const parentId in groupedByParent) {
-      const childNodes = groupedByParent[parentId];
-      if (childNodes.length > 0) {
-        // 获取父章节信息
-        const parentChapter = chapters.find((c: any) => c.id === parseInt(parentId));
+    // 收集各章的元信息
+    for (const ch of chapters) {
+      if (ch.level === 1) {
+        chapterMeta[ch.id] = ch;
+      }
+    }
 
-        if (parentChapter) {
-          try {
-            // 调用合并函数
-            const mergedAudioUrl = await mergeChapterAudios(parentChapter.id);
+    const chapterIds = Object.keys(groupedByChapter);
 
-            if (mergedAudioUrl) {
-              // 更新父章节的音频URL
-              await bookStore.updateChapterById(parentChapter.id, {
-                audioUrl: mergedAudioUrl,
-              });
+    // 对每个章下的叶节点音频进行合并
+    for (const chapterId of chapterIds) {
+      const chId = parseInt(chapterId);
+      const childNodes = groupedByChapter[chId];
+      const chInfo = chapterMeta[chId];
 
-              console.log(`[Merge Audio] 章节 ${parentChapter.number}(${parentChapter.title}) 音频合并完成: ${mergedAudioUrl}`);
-            }
-          } catch (error) {
-            console.error(`[Merge Audio] 章节 ${parentChapter.number}(${parentChapter.title}) 音频合并失败:`, error);
+      if (childNodes.length > 0) {
+        try {
+          const mergedAudioUrl = await mergeChapterAudios(chId);
+
+          if (mergedAudioUrl) {
+            console.log(`[Merge Audio] 章 ${chInfo?.number || chId}(${chInfo?.title || ''}) 音频合并完成: ${mergedAudioUrl}`);
           }
+        } catch (error) {
+          console.error(`[Merge Audio] 章 ${chInfo?.number || chId}(${chInfo?.title || ''}) 音频合并失败:`, error);
         }
       }
     }
 
     ctx.body = {
       code: 0,
-      message: `音频合并任务已启动,共处理 ${Object.keys(groupedByParent).length} 个上级章节`,
+      message: `音频合并任务已启动,共处理 ${chapterIds.length} 个章`,
       data: {
-        processedParents: Object.keys(groupedByParent).length,
+        processedParents: chapterIds.length,
         totalLeafNodes: leafNodes.length,
       },
     };
@@ -2224,8 +2281,17 @@ async function mergeChapterVideos(parentChapter: any, childChapters: any[], user
     }
 
     if (videoPaths.length === 1) {
-      // 如果只有一个视频,直接复制即可
-      await fs.copyFile(videoPaths[0], outputPath);
+      // 如果只有一个视频,检查格式是否一致
+      const srcExt = videoPaths[0].split('.').pop()?.toLowerCase();
+      const dstExt = outputPath.split('.').pop()?.toLowerCase();
+      if (srcExt !== dstExt) {
+        // 格式不同,用 FFmpeg 转码
+        const cmd = `ffmpeg -i "${videoPaths[0]}" -c copy -y "${outputPath}"`;
+        console.log(`[Merge Video] 格式不同,转码: ${cmd}`);
+        await execPromise(cmd);
+      } else {
+        await fs.copyFile(videoPaths[0], outputPath);
+      }
       const relativePath = outputPath.replace(videoDir, '/uploads/video').replace(/\\/g, '/');
       return relativePath;
     }

+ 39 - 80
server/src/modules/member/member.service.ts

@@ -1,13 +1,9 @@
 import { prisma } from '../../models';
 import { MemberLevel, MEMBER_QUOTA } from '../../types';
+import { generateOrderNo, handlePaymentCallback } from '../payment/payment.service';
+import { safeParseInt } from '../../utils/safe-parse';
 
-// 会员价格
-export const MEMBER_PRICES = {
-  monthly: { price: 19.9, days: 30 },
-  yearly: { price: 199, days: 365 },
-};
-
-// 获取会员权益信息
+// 获取会员权益信息(新5级体系)
 export function getMemberBenefits() {
   return {
     levels: [
@@ -20,17 +16,31 @@ export function getMemberBenefits() {
       },
       {
         level: 1,
-        name: '月度会员',
+        name: '入门版',
         price: MEMBER_PRICES.monthly.price,
         quota: MEMBER_QUOTA[1],
-        features: ['每天20次生成', '每次最多50000字', '全部音色', '优先处理'],
+        features: ['每天10次生成', '每次最多10000字', '高清音质', '超出¥8/分钟'],
       },
       {
         level: 2,
-        name: '年度会员',
-        price: MEMBER_PRICES.yearly.price,
+        name: '专业版',
+        price: MEMBER_PRICES.monthly.price,
         quota: MEMBER_QUOTA[2],
-        features: ['无限次生成', '无字数限制', '全部音色', '优先处理', '专属客服'],
+        features: ['无限次生成', '每次最多10000字', '全部音色', '超出¥7/分钟'],
+      },
+      {
+        level: 3,
+        name: '旗舰版',
+        price: MEMBER_PRICES.monthly.price,
+        quota: MEMBER_QUOTA[3],
+        features: ['无限次生成', '无字数限制', '全部音色', 'VIP优先队列', '超出¥6/分钟'],
+      },
+      {
+        level: 4,
+        name: '企业版',
+        price: MEMBER_PRICES.monthly.price,
+        quota: MEMBER_QUOTA[4],
+        features: ['无限次生成', '无字数限制', '全部功能', '批量处理', '团队管理', '超出¥5/分钟'],
       },
     ],
   };
@@ -38,7 +48,7 @@ export function getMemberBenefits() {
 
 // 获取用户会员状态
 export async function getMemberStatus(userId: string) {
-  const user = await prisma.user.findUnique({ where: { id: parseInt(userId) } });
+  const user = await prisma.user.findUnique({ where: { id: safeParseInt(userId) } });
   if (!user) {
     throw new Error('用户不存在');
   }
@@ -47,9 +57,9 @@ export async function getMemberStatus(userId: string) {
   let dailyUsage = user.dailyUsage;
   const memberLevel = user.memberLevel;
   
-  // 如果 memberLevel 超出范围(测试用户等级99),使用无限额度
-  const safeLevel = (memberLevel in MEMBER_QUOTA) ? memberLevel : 2;
-  const quota = MEMBER_QUOTA[safeLevel as MemberLevel];
+  // 如果 memberLevel 超出范围,使用最高级别
+  const safeLevel = (memberLevel in MEMBER_QUOTA) ? (memberLevel as MemberLevel) : 4;
+  const quota = MEMBER_QUOTA[safeLevel];
 
   // 重置每日使用次数
   if (user.lastUsageDate !== today) {
@@ -57,10 +67,11 @@ export async function getMemberStatus(userId: string) {
   }
 
   const isValid = memberLevel > 0 && user.memberExpireAt && new Date() < user.memberExpireAt;
+  const levelNames = ['免费版', '入门版', '专业版', '旗舰版', '企业版'];
 
   return {
     level: memberLevel,
-    levelName: ['免费版', '月度会员', '年度会员'][memberLevel],
+    levelName: levelNames[memberLevel] || levelNames[4],
     expireAt: user.memberExpireAt,
     isValid,
     quota: {
@@ -72,7 +83,13 @@ export async function getMemberStatus(userId: string) {
   };
 }
 
-// 创建订单
+// 会员价格(与 subscription 套餐体系对齐)
+export const MEMBER_PRICES = {
+  monthly: { price: 19, days: 30 },
+  yearly: { price: 190, days: 365 },
+};
+
+// 创建订单(使用统一订单号生成)
 export async function createOrder(
   userId: string,
   productType: 'monthly' | 'yearly'
@@ -85,7 +102,7 @@ export async function createOrder(
 
   const order = await prisma.order.create({
     data: {
-      userId: parseInt(userId),
+      userId: safeParseInt(userId),
       orderNo,
       productType,
       amount: priceInfo.price,
@@ -99,64 +116,14 @@ export async function createOrder(
   };
 }
 
-// 模拟支付成功
+// 模拟支付成功(委托给新支付系统)
 export async function mockPaymentSuccess(orderNo: string, userId: string) {
-  const order = await prisma.order.findFirst({ 
-    where: { orderNo, userId: parseInt(userId) } 
-  });
-  if (!order) {
-    throw new Error('订单不存在');
-  }
-
-  if (order.status !== 'pending') {
-    throw new Error('订单状态不正确');
-  }
-
-  // 更新订单状态
-  await prisma.order.update({
-    where: { id: order.id },
-    data: {
-      status: 'paid',
-      paymentMethod: 'mock',
-      paidAt: new Date(),
-    },
-  });
-
-  // 更新用户会员状态
-  const user = await prisma.user.findUnique({ where: { id: parseInt(userId) } });
-  if (!user) {
-    throw new Error('用户不存在');
-  }
-
-  const days = MEMBER_PRICES[order.productType as keyof typeof MEMBER_PRICES].days;
-  const now = new Date();
-
-  // 如果当前是会员,从到期日延长;否则从现在开始
-  const startDate = user.memberExpireAt && user.memberExpireAt > now
-    ? user.memberExpireAt
-    : now;
-
-  const memberLevel = order.productType === 'yearly' ? 2 : 1;
-  const memberExpireAt = new Date(startDate.getTime() + days * 24 * 60 * 60 * 1000);
-
-  await prisma.user.update({
-    where: { id: parseInt(userId) },
-    data: {
-      memberLevel,
-      memberExpireAt,
-    },
-  });
-
-  return {
-    success: true,
-    memberLevel,
-    memberExpireAt,
-  };
+  return handlePaymentCallback(orderNo, 'MOCK_' + Date.now(), 'success');
 }
 
 // 获取订单列表
 export async function getOrders(userId: string, page: number = 1, pageSize: number = 10) {
-  const uid = parseInt(userId);
+  const uid = safeParseInt(userId);
   const total = await prisma.order.count({ where: { userId: uid } });
   const list = await prisma.order.findMany({
     where: { userId: uid },
@@ -172,12 +139,4 @@ export async function getOrders(userId: string, page: number = 1, pageSize: numb
     pageSize,
     totalPages: Math.ceil(total / pageSize),
   };
-}
-
-// 生成订单号
-function generateOrderNo(): string {
-  const now = new Date();
-  const dateStr = now.toISOString().slice(0, 10).replace(/-/g, '');
-  const random = Math.random().toString(36).substring(2, 8).toUpperCase();
-  return `ORD${dateStr}${random}`;
 }

+ 2 - 2
server/src/modules/notifications/notifications.controller.ts

@@ -6,7 +6,7 @@ import { prisma } from '../../models';
 const router = new Router({ prefix: '/api/notifications' });
 
 // 测试用户ID
-const TEST_USER_ID = 'test-user';
+const TEST_USER_ID = '1';
 
 /**
  * 获取通知列表
@@ -58,7 +58,7 @@ router.post('/read', optionalAuth, async (ctx) => {
 router.put('/read-all', optionalAuth, async (ctx) => {
   const userId = ctx.state.user?.userId || TEST_USER_ID;
   await prisma.notification.updateMany({
-    where: { userId: String(userId), isRead: false },
+    where: { userId: parseInt(String(userId)), isRead: false },
     data: { isRead: true },
   });
   ctx.body = { code: 0, message: 'success' };

+ 3 - 1
server/src/modules/notifications/notifications.service.ts

@@ -8,8 +8,10 @@ export class NotificationsService {
    * 获取用户通知列表
    */
   async getNotifications(userId: string) {
+    const uid = parseInt(userId);
+    if (isNaN(uid)) return [];
     return await prisma.notification.findMany({
-      where: { userId },
+      where: { userId: uid },
       orderBy: { createdAt: 'desc' },
       take: 50,
     });

+ 9 - 8
server/src/modules/payment/payment.controller.ts

@@ -3,15 +3,18 @@ import { Context } from 'koa';
 import * as PaymentService from './payment.service';
 import { BadRequestError } from '../../middleware/errorHandler';
 import { authMiddleware } from '../../middleware/auth';
+import { prisma } from '../../models';
+import { safeParseInt } from '../../utils/safe-parse';
 
 const router = new Router();
 
 // 创建支付订单
 router.post('/create', authMiddleware, async (ctx: Context) => {
-  const userId = parseInt(ctx.state.user.userId);
-  const { planId, paymentMethod, returnUrl } = ctx.request.body as {
+  const userId = safeParseInt(ctx.state.user.userId);
+  const { planId, paymentMethod, period = 'monthly', returnUrl } = ctx.request.body as {
     planId: number;
     paymentMethod: 'alipay' | 'wechat' | 'mock';
+    period?: 'monthly' | 'yearly';
     returnUrl?: string;
   };
 
@@ -23,7 +26,7 @@ router.post('/create', authMiddleware, async (ctx: Context) => {
     throw new BadRequestError('请选择支付方式');
   }
 
-  const result = await PaymentService.createPaymentOrder(userId, planId, paymentMethod, returnUrl);
+  const result = await PaymentService.createPaymentOrder(userId, planId, paymentMethod, period, returnUrl);
 
   ctx.body = {
     code: 0,
@@ -38,7 +41,7 @@ router.post('/mock', authMiddleware, async (ctx: Context) => {
     throw new BadRequestError('生产环境不可用');
   }
 
-  const userId = parseInt(ctx.state.user.userId);
+  const userId = safeParseInt(ctx.state.user.userId);
   const { orderNo } = ctx.request.body as { orderNo: string };
 
   if (!orderNo) {
@@ -172,7 +175,7 @@ router.get('/wechat/query/:orderNo', authMiddleware, async (ctx: Context) => {
 
 // 获取订单列表
 router.get('/orders', authMiddleware, async (ctx: Context) => {
-  const userId = parseInt(ctx.state.user.userId);
+  const userId = safeParseInt(ctx.state.user.userId);
   const { page = '1', pageSize = '20' } = ctx.query as { page?: string; pageSize?: string };
 
   const result = await PaymentService.getOrderList(
@@ -190,7 +193,7 @@ router.get('/orders', authMiddleware, async (ctx: Context) => {
 
 // 获取订单详情
 router.get('/orders/:orderNo', authMiddleware, async (ctx: Context) => {
-  const userId = parseInt(ctx.state.user.userId);
+  const userId = safeParseInt(ctx.state.user.userId);
   const { orderNo } = ctx.params;
 
   const result = await PaymentService.getOrderDetail(orderNo, userId);
@@ -252,6 +255,4 @@ router.post('/alipay/qrcode', authMiddleware, async (ctx: Context) => {
   };
 });
 
-import { prisma } from '../../models';
-
 export default router;

+ 10 - 7
server/src/modules/payment/payment.service.ts

@@ -123,6 +123,7 @@ export async function createPaymentOrder(
   userId: number,
   planId: number,
   paymentMethod: 'alipay' | 'wechat' | 'mock',
+  period: 'monthly' | 'yearly' = 'monthly',
   returnUrl?: string
 ): Promise<{
   orderNo: string;
@@ -146,8 +147,8 @@ export async function createPaymentOrder(
   // 生成订单号
   const orderNo = generateOrderNo();
 
-  // 计算金额(月付价格,单位:元)
-  const amount = Number(plan.priceMonthly);
+  // 根据周期选择价格
+  const amount = period === 'yearly' ? Number(plan.priceYearly) : Number(plan.priceMonthly);
 
   // 创建订单记录
   await prisma.order.create({
@@ -155,7 +156,7 @@ export async function createPaymentOrder(
       userId,
       orderNo,
       planId,
-      productType: 'monthly',
+      productType: period,
       amount,
       status: 'pending',
       paymentMethod,
@@ -399,14 +400,14 @@ export async function handlePaymentCallback(
 
   // 激活订阅
   if (order.planId) {
-    await activateSubscription(order.userId, order.planId);
+    await activateSubscription(order.userId, order.planId, order.productType);
   }
 
   return { success: true, message: '支付成功' };
 }
 
 // 激活订阅
-export async function activateSubscription(userId: number, planId: number) {
+export async function activateSubscription(userId: number, planId: number, productType: string = 'monthly') {
   const plan = await prisma.subscriptionPlan.findUnique({
     where: { id: planId }
   });
@@ -416,7 +417,9 @@ export async function activateSubscription(userId: number, planId: number) {
   }
 
   const now = new Date();
-  const endDate = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
+  // 根据订单的产品类型确定有效天数
+  const days = productType === 'yearly' ? 365 : 30;
+  const endDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
 
   const existingSubscription = await prisma.subscription.findFirst({
     where: {
@@ -429,7 +432,7 @@ export async function activateSubscription(userId: number, planId: number) {
   let subscriptionEndDate = endDate;
 
   if (existingSubscription) {
-    subscriptionEndDate = new Date(existingSubscription.endDate.getTime() + 30 * 24 * 60 * 60 * 1000);
+    subscriptionEndDate = new Date(existingSubscription.endDate.getTime() + days * 24 * 60 * 60 * 1000);
     await prisma.subscription.update({
       where: { id: existingSubscription.id },
       data: {

+ 82 - 26
server/src/modules/player/player.service.ts

@@ -1,9 +1,7 @@
-import { PrismaClient } from '@prisma/client';
+import { prisma } from '../../models';
 import { AudioMerger } from '../tts/audio-merger';
 import path from 'path';
 
-const prisma = new PrismaClient();
-
 /**
  * 获取用户播放记录
  */
@@ -146,7 +144,7 @@ export async function getSingleProgress(userId: string, chapterId: number) {
  */
 export async function mergeChapterAudios(chapterId: number): Promise<string | null> {
   // 获取章节信息
-  const chapter = await prisma.bookChapter.findUnique({
+  let chapter = await prisma.bookChapter.findUnique({
     where: { id: chapterId },
   });
 
@@ -155,10 +153,39 @@ export async function mergeChapterAudios(chapterId: number): Promise<string | nu
     return null;
   }
 
-  // 只处理章(level=1)
+  // 如果不是1级章,向上追溯到1级章(兼容调用方传入节/section的ID)
+  let effectiveChapterId = chapterId;
   if (chapter.level !== 1) {
-    console.log(`⏭️ 只有章(level=1)需要合并,当前是 level=${chapter.level}`);
-    return chapter.audioUrl; // 直接返回原音频URL
+    console.log(`[MergeAudio] 当前节点 level=${chapter.level},向上追溯1级章...`);
+    let currentLevel = chapter.level;
+    let currentId = chapter.id;
+    let currentParentId = chapter.parentId;
+
+    while (currentLevel > 1 && currentParentId) {
+      const parent = await prisma.bookChapter.findUnique({
+        where: { id: currentParentId },
+        select: { id: true, level: true, parentId: true },
+      });
+      if (!parent) break;
+      currentLevel = parent.level;
+      currentId = parent.id;
+      currentParentId = parent.parentId;
+    }
+
+    if (currentLevel !== 1) {
+      console.log(`[MergeAudio] ⏭️ 无法找到1级章,当前追溯结果 level=${currentLevel}`);
+      return null;
+    }
+
+    console.log(`[MergeAudio] 追溯到1级章 id=${currentId},重新查询完整信息`);
+    chapter = await prisma.bookChapter.findUnique({
+      where: { id: currentId },
+    });
+    if (!chapter) {
+      console.error(`❌ 追溯到的1级章 ${currentId} 不存在`);
+      return null;
+    }
+    effectiveChapterId = chapter.id;
   }
 
   // 如果已经有合并后的音频(audioUrl存在且是合并后的),直接返回
@@ -170,17 +197,19 @@ export async function mergeChapterAudios(chapterId: number): Promise<string | nu
   // 1. 找到所有节(level=2,parentId=章ID)
   const sections = await prisma.bookChapter.findMany({
     where: {
-      parentId: chapterId,
+      parentId: effectiveChapterId,
       level: 2,
     },
+    orderBy: { number: 'asc' },
   });
 
   if (sections.length === 0) {
-    console.log(`⏭️ 章 ${chapterId} 没有子节`);
+    console.log(`⏭️ 章 ${effectiveChapterId} 没有子节`);
     return null;
   }
 
-  // 2. 收集所有小节的 audioUrl(level=3,parentId in 节IDs)
+  // 2. 收集待合并的音频URL
+  // 先尝试查找 level=3 小节(3层树:章→节→小节)
   const sectionIds = sections.map(s => s.id);
   const subsections = await prisma.bookChapter.findMany({
     where: {
@@ -191,44 +220,71 @@ export async function mergeChapterAudios(chapterId: number): Promise<string | nu
     orderBy: { number: 'asc' },
   });
 
-  const audioFiles = subsections
-    .map(s => s.audioUrl)
-    .filter((url): url is string => !!url);
+  let audioFiles: string[];
+  if (subsections.length > 0) {
+    // 3层树:合并所有 level=3 小节音频
+    audioFiles = subsections
+      .map(s => s.audioUrl)
+      .filter((url): url is string => !!url);
+    console.log(`🔄 合并章 ${effectiveChapterId} 下 ${audioFiles.length} 个小节(level=3)音频...`);
+  } else {
+    // 2层树:直接合并 level=2 的节音频
+    audioFiles = sections
+      .filter(s => s.audioUrl && s.audioUrl !== '')
+      .map(s => s.audioUrl!);
+    console.log(`🔄 合并章 ${effectiveChapterId} 下 ${audioFiles.length} 个节(level=2)音频...`);
+  }
 
   if (audioFiles.length === 0) {
-    console.log(`⏭️ 章 ${chapterId} 下没有小节音频可合并`);
+    console.log(`⏭️ 章 ${effectiveChapterId} 下没有音频可合并`);
     return null;
   }
 
-  console.log(`🔄 合并章节 ${chapterId} 的 ${audioFiles.length} 个小节音频...`);
-
   // 3. 生成合并后的输出路径
   const uploadsDir = path.join(process.cwd(), 'uploads');
-  const outputFileName = `chapter_${chapterId}_merged_${Date.now()}.mp3`;
+  const outputFileName = `chapter_${effectiveChapterId}_merged_${Date.now()}.mp3`;
   const outputPath = path.join(uploadsDir, outputFileName);
 
-  // 4. 转换相对路径为绝对路径
+  // 4. 转换路径:OSS远程URL保持原样,本地路径转为绝对路径
   const absoluteAudioFiles = audioFiles.map(f => {
-    // f 格式: /uploads/xxx/output.mp3
+    if (f.startsWith('http')) return f; // OSS URL 保持原样,AudioMerger 会检测并远程处理
     const relativePath = f.replace(/^\//, '');
     return path.join(process.cwd(), relativePath);
   });
 
   try {
-    // 5. 合并音频
-    await AudioMerger.merge(absoluteAudioFiles, outputPath);
+    // 5. 合并音频,捕获返回值(OSS模式返回OSS URL,本地模式返回本地路径)
+    const mergeResult = await AudioMerger.merge(absoluteAudioFiles, outputPath);
 
     // 6. 更新章节的 audioUrl
-    const mergedAudioUrl = `/uploads/${outputFileName}`;
+    // 本地合并返回绝对路径需转为相对URL,OSS合并直接返回远程URL
+    let mergedAudioUrl: string;
+    if (mergeResult.startsWith('http')) {
+      mergedAudioUrl = mergeResult; // OSS URL
+    } else {
+      // 本地绝对路径 → 相对URL
+      const cwd = process.cwd().replace(/\\/g, '/');
+      const normalized = mergeResult.replace(/\\/g, '/');
+      mergedAudioUrl = normalized.startsWith(cwd)
+        ? normalized.substring(cwd.length)
+        : `/uploads/${outputFileName}`;
+    }
+
+    // 获取合并后音频的时长
+    const mergedDuration = await AudioMerger.getDuration(mergeResult);
+
     await prisma.bookChapter.update({
-      where: { id: chapterId },
-      data: { audioUrl: mergedAudioUrl },
+      where: { id: effectiveChapterId },
+      data: {
+        audioUrl: mergedAudioUrl,
+        audioDuration: mergedDuration || 0,
+      },
     });
 
-    console.log(`✅ 章节 ${chapterId} 音频合并完成: ${mergedAudioUrl}`);
+    console.log(`✅ 章节 ${effectiveChapterId} 音频合并完成: ${mergedAudioUrl}, 时长: ${mergedDuration}s`);
     return mergedAudioUrl;
   } catch (error) {
-    console.error(`❌ 章节 ${chapterId} 音频合并失败:`, error);
+    console.error(`❌ 章节 ${effectiveChapterId} 音频合并失败:`, error);
     return null;
   }
 }

+ 68 - 57
server/src/modules/tts/aliyun.provider.ts

@@ -4,8 +4,24 @@ import path from 'path';
 import { config } from '../../config';
 import { VoiceParams } from '../../types';
 
-// 阿里云百炼 Qwen TTS Provider
-// 文档: https://help.aliyun.com/zh/model-studio/qwen-tts
+/**
+ * 阿里云百炼 Qwen-TTS Provider(同步模式)
+ *
+ * 接口限制参考:
+ *   - Qwen-TTS (multimodal-generation): 单次 20,000 字符(同步/流式)
+ *   - CosyVoice (SpeechSynthesizer): 单次 20,000 字符(同步/流式)
+ *   - 传统长文本合成: 80,000 字符(建议 40,000 以内)
+ *   - 传统基础合成: 300 字符(已弃用)
+ *
+ * 调用方式:
+ *   - 同步非流式: POST → 直接返回音频 URL
+ *   - SSE 流式: 加 X-DashScope-SSE: enable header
+ *   - ⚠️ 不支持 X-DashScope-Async 异步模式
+ *
+ * 当前策略: 使用同步模式 + 1000 字符分段,每段快速返回音频 URL
+ *
+ * 文档: https://help.aliyun.com/zh/model-studio/qwen-tts
+ */
 export class AliyunTtsProvider {
   private apiKey: string;
   private model: string;
@@ -18,21 +34,23 @@ export class AliyunTtsProvider {
     this.voice = config.dashscope.voice;
   }
 
-  // 语音合成(带重试机制)
+  /**
+   * 语音合成(同步模式:直接返回音频 URL → 下载)
+   */
   async synthesize(
     text: string,
     voiceId: string,
     params: VoiceParams,
     outputPath: string,
     retries: number = 3,
-    modelOverride?: string
+    modelOverride?: string,
   ): Promise<string> {
     const activeModel = modelOverride || this.model;
     let lastError: Error | null = null;
 
     for (let attempt = 1; attempt <= retries; attempt++) {
       try {
-        // 构建请求
+        // 构建请求
         const requestBody: any = {
           model: activeModel,
           input: {
@@ -42,11 +60,6 @@ export class AliyunTtsProvider {
           },
         };
 
-        console.log(`📤 [Aliyun TTS] 尝试 ${attempt}/${retries}`);
-        console.log(`   model: ${activeModel}, voice: ${voiceId || this.voice}`);
-        console.log(`   text length: ${text.length}`);
-        console.log(`   params: ${JSON.stringify(params)}`);
-
         // 使用 instruct 模型时支持指令控制
         if (activeModel.includes('instruct')) {
           const instructions: string[] = [];
@@ -59,13 +72,13 @@ export class AliyunTtsProvider {
             instructions.push(`音调${pitchDesc}`);
           }
           if (instructions.length > 0) {
-            requestBody.parameters = {
-              instructions: instructions.join(','),
-            };
+            requestBody.input.instructions = instructions.join(',');
           }
         }
 
-        // 发送请求
+        console.log(`📤 [Aliyun TTS] 尝试 ${attempt}/${retries}, model: ${activeModel}, voice: ${voiceId || this.voice}, text length: ${text.length}`);
+
+        // 同步调用(不加 X-DashScope-Async,Qwen-TTS 不支持异步模式)
         const response = await axios.post(this.baseUrl, requestBody, {
           headers: {
             'Authorization': `Bearer ${this.apiKey}`,
@@ -76,43 +89,28 @@ export class AliyunTtsProvider {
 
         // 检查响应
         if (response.status !== 200) {
-          throw new Error(`Qwen TTS 请求失败: ${response.status}`);
+          throw new Error(`Aliyun TTS 请求失败: HTTP ${response.status}`);
         }
 
         const data = response.data;
         if (data.code) {
-          throw new Error(`Qwen TTS 错误: ${data.message || JSON.stringify(data)}`);
+          throw new Error(`Aliyun TTS 错误: ${data.message || JSON.stringify(data)}`);
         }
 
-        // 获取音频 URL
+        // 获取音频 URL(同步模式直接在 output.audio.url 中返回)
         const audioUrl = data.output?.audio?.url;
-        console.log('🔗 音频 URL:', audioUrl);
         if (!audioUrl) {
-          throw new Error('Qwen TTS 未返回音频 URL');
+          throw new Error(`Aliyun TTS 未返回音频 URL: ${JSON.stringify(data).substring(0, 200)}`);
         }
 
-        // 尝试下载音频文件,如果失败则返回 URL
-        try {
-          const audioResponse = await axios.get(audioUrl, {
-            responseType: 'arraybuffer',
-            timeout: 60000,
-          });
-
-          // 保存文件
-          const dir = path.dirname(outputPath);
-          if (!fs.existsSync(dir)) {
-            fs.mkdirSync(dir, { recursive: true });
-          }
-
-          // 确保输出路径以 .wav 结尾
-          const finalPath = outputPath.endsWith('.wav') ? outputPath : outputPath.replace(/\.[^.]+$/, '.wav');
-          fs.writeFileSync(finalPath, audioResponse.data);
-          console.log(`✅ Qwen TTS 生成成功: ${finalPath}`);
+        console.log(`🔗 [Aliyun TTS] 获取音频 URL: ${audioUrl.substring(0, 80)}...`);
 
-          return finalPath;
+        // 下载音频文件
+        try {
+          return await this.downloadAudio(audioUrl, outputPath);
         } catch (downloadError: any) {
-          console.warn('⚠️ 音频下载失败,返回云端 URL:', downloadError.message);
-          // 返回一个特殊的路径标记,表示使用云端 URL
+          console.warn(`⚠️ [Aliyun TTS] 音频下载失败: ${downloadError.message}`);
+          // 下载失败,返回 cloud: URL 标记(后续会处理)
           return `cloud:${audioUrl}`;
         }
       } catch (error: any) {
@@ -120,32 +118,45 @@ export class AliyunTtsProvider {
         const isRateLimit = error.response?.status === 429 || errorDetails?.code === 'Throttling.RateQuota';
         const isServerError = error.response?.status >= 500;
 
-        console.error(`❌ Qwen TTS 调用失败 (尝试 ${attempt}/${retries}):`, JSON.stringify(errorDetails, null, 2));
-
-        if (isRateLimit && attempt < retries) {
-          // 速率限制:等待后重试(指数退避)
-          const waitTime = Math.pow(2, attempt) * 1000;
-          console.warn(`⏳ 速率限制,等待 ${waitTime}ms 后重试...`);
-          await new Promise(resolve => setTimeout(resolve, waitTime));
-          lastError = new Error(`Qwen TTS 速率限制: ${error.message}`);
-          continue;
-        }
+        console.error(`❌ [Aliyun TTS] 失败 (尝试 ${attempt}/${retries}):`, error.message);
 
-        if (isServerError && attempt < retries) {
-          // 服务器错误:等待后重试
+        if ((isRateLimit || isServerError) && attempt < retries) {
           const waitTime = Math.pow(2, attempt) * 1000;
-          console.warn(`⏳ 服务器错误,等待 ${waitTime}ms 后重试...`);
+          console.warn(`⏳ 等待 ${waitTime}ms 后重试...`);
           await new Promise(resolve => setTimeout(resolve, waitTime));
-          lastError = new Error(`Qwen TTS 服务器错误: ${error.message}`);
+          lastError = new Error(`Aliyun TTS 临时错误: ${error.message}`);
           continue;
         }
 
-        // 达到最大重试次数或不可重试的错误
-        throw new Error(`Qwen TTS 服务调用失败: ${error.message}, 详情: ${JSON.stringify(errorDetails)}`);
+        lastError = new Error(`Aliyun TTS 调用失败: ${error.message}`);
       }
     }
 
-    // 理论上不会到达这里,但为了类型安全
-    throw lastError || new Error('Qwen TTS 服务调用失败');
+    throw lastError || new Error('Aliyun TTS 服务调用失败');
+  }
+
+  /**
+   * 下载音频文件到本地
+   */
+  private async downloadAudio(audioUrl: string, outputPath: string): Promise<string> {
+    console.log(`⬇️ [Aliyun TTS] 下载音频: ${audioUrl.substring(0, 80)}...`);
+
+    const response = await axios.get(audioUrl, {
+      responseType: 'arraybuffer',
+      timeout: 120000,
+    });
+
+    const dir = path.dirname(outputPath);
+    if (!fs.existsSync(dir)) {
+      fs.mkdirSync(dir, { recursive: true });
+    }
+
+    // 确保输出路径以 .wav 结尾(Qwen-TTS 返回 wav 格式)
+    const finalPath = outputPath.endsWith('.wav') ? outputPath : outputPath.replace(/\.[^.]+$/, '.wav');
+    fs.writeFileSync(finalPath, response.data);
+    const stats = fs.statSync(finalPath);
+    console.log(`✅ [Aliyun TTS] 下载完成: ${finalPath} (${stats.size} bytes)`);
+
+    return finalPath;
   }
 }

+ 41 - 10
server/src/modules/tts/audio-merger.ts

@@ -14,10 +14,31 @@ export class AudioMerger {
     }
 
     if (inputFiles.length === 1) {
-      // 单个文件也要复制到outputPath,保持文件名一致
-      const fs = require('fs');
-      if (inputFiles[0] !== outputPath) {
-        fs.copyFileSync(inputFiles[0], outputPath);
+      // 单个文件:远程URL直接用FFmpegProcessor上传
+      if (inputFiles[0].startsWith('http')) {
+        const outputExt = outputPath.split('.').pop() || 'mp3';
+        return await FFmpegProcessor.mergeAudio(inputFiles, outputExt);
+      }
+
+      // 本地单文件:检查是否需要格式转换
+      const inputExt = inputFiles[0].split('.').pop()?.toLowerCase();
+      const outputExt = outputPath.split('.').pop()?.toLowerCase();
+
+      if (inputExt !== outputExt) {
+        // 格式不同(如 WAV→MP3),用 FFmpeg 转码
+        let cmd: string;
+        if (outputExt === 'mp3') {
+          cmd = `ffmpeg -i "${inputFiles[0]}" -c:a libmp3lame -b:a 192k -y "${outputPath}"`;
+        } else {
+          cmd = `ffmpeg -i "${inputFiles[0]}" -c copy -y "${outputPath}"`;
+        }
+        await execAsync(cmd, { timeout: 120000 });
+        console.log(`✅ 音频格式转换成功: ${inputExt} → ${outputExt}`);
+      } else {
+        // 格式相同,直接复制
+        if (inputFiles[0] !== outputPath) {
+          fs.copyFileSync(inputFiles[0], outputPath);
+        }
       }
       return outputPath;
     }
@@ -38,15 +59,21 @@ export class AudioMerger {
   // 合并本地音频文件(原有逻辑)
   private static async mergeLocalFiles(inputFiles: string[], outputPath: string): Promise<string> {
     try {
-      // 创建文件列表
-      const listContent = inputFiles.map(f => `file '${f}'`).join('\n');
-      const listFile = '/tmp/ffmpeg_list.txt';
+      // 在输出文件同目录创建临时列表文件(避免 Windows /tmp 路径问题)
+      const outputDir = path.dirname(outputPath);
+      const listFile = path.join(outputDir, 'ffmpeg_concat_list.txt');
+
+      // 使用相对路径避免 Windows 反斜杠问题
+      const listContent = inputFiles.map(f => {
+        const relativePath = path.relative(outputDir, f).replace(/\\/g, '/');
+        return `file '${relativePath}'`;
+      }).join('\n');
       fs.writeFileSync(listFile, listContent);
 
       // 检查输出格式
       const outputExt = outputPath.split('.').pop()?.toLowerCase();
 
-      // 构建 FFmpeg 命令
+      // 构建 FFmpeg 命令(在工作目录中执行,使相对路径生效)
       let cmd: string;
       if (outputExt === 'mp3') {
         // MP3 需要转码(WAV/PCM -> MP3)
@@ -56,7 +83,10 @@ export class AudioMerger {
         cmd = `ffmpeg -f concat -safe 0 -i "${listFile}" -c copy -y "${outputPath}"`;
       }
 
-      await execAsync(cmd, { timeout: 120000 });
+      await execAsync(cmd, { timeout: 300000, cwd: outputDir });
+
+      // 清理临时列表文件
+      try { fs.unlinkSync(listFile); } catch {}
 
       console.log(`✅ 音频合并成功: ${outputPath}`);
       return outputPath;
@@ -77,7 +107,8 @@ export class AudioMerger {
     try {
       const cmd = `ffprobe -i "${filePath}" -show_entries format=duration -v quiet -of csv="p=0"`;
       const { stdout } = await execAsync(cmd, { timeout: 10000 });
-      return Math.round(parseFloat(stdout.trim()) || 0);
+      // 保留一位小数精度(不再 Math.round 丢失精度)
+      return Math.round((parseFloat(stdout.trim()) || 0) * 10) / 10;
     } catch (error) {
       console.error('❌ 获取音频时长失败:', error);
       return 0;

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

@@ -2,6 +2,10 @@
  * 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)

+ 394 - 188
server/src/modules/tts/tts.service.ts

@@ -1,7 +1,6 @@
 import path from 'path';
 import fs from 'fs';
 import { v4 as uuidv4 } from 'uuid';
-import { execSync } from 'child_process';
 import { config } from '../../config';
 import { prisma } from '../../models';
 import { VoiceParams, Voice } from '../../types';
@@ -78,7 +77,7 @@ async function getOrCreateDefaultBook(userId: string): Promise<number> {
       title: bookTitle,
       description: '我的语音合成音频收藏',
       userId: userIdNum,
-      status: 'completed',
+      genStage: 'content_completed',
       bookScale: 'short',
       totalChapters: 0,
       estimatedWords: 0,
@@ -96,71 +95,132 @@ export function shouldUseLongText(text: string): boolean {
   return false; // 强制返回 false,禁用 realtime 模式
 }
 
-// 文本分段 - 阿里云 TTS 限制 600 字符,增加到 550 留安全余量
-export function splitText(text: string, maxLength: number = 550): string[] {
+// 文本分段 - 统一 1000 字符上限,优先在标点处断开
+// 策略:按自然句子累加,接近上限时在最后一个标点处截断,避免在字中间硬切
+// 好处:TTS 在句末韵律自然,拼接后听感流畅
+//
+// 1000 字的依据(主流 TTS 接口上限,大多以千为单位):
+//   MiniMax 同步: 1万  |  阿里云 CosyVoice: 2万  |  讯飞 流式: ~4000字
+//   百度 短文本: 5120字  |  OpenAI TTS: 4096字  |  Google Cloud: 5000字节
+//   ElevenLabs: 3000~5000字  |  Azure: ~3000~5000字
+//   不支持1000的接口(应走异步长文本API,而非短文本接口):
+//     火山引擎 非流式: 300字 | 腾讯云 基础: 150字 | 百度 短文本: 60字
+const SEGMENT_MAX_LENGTH = 1000;
+// 句末标点(中文 + 英文),用于寻找断点
+const SENTENCE_END_RE = /[。!?;\n.!?;]/;
+
+export function splitText(text: string, maxLength: number = SEGMENT_MAX_LENGTH): string[] {
   const segments: string[] = [];
-  let current = '';
 
-  // 清理文本,移除可能导致问题的字符
+  // 清理文本
   const cleanText = text.replace(/\r/g, '');
 
-  // 按段落分割
+  // 第一步:按换行拆成段落,再按句末标点拆成句子
+  // 这样保留了段落边界,又不会在一个句子中间断开
+  const rawSentences: string[] = [];
   const paragraphs = cleanText.split(/\n+/);
 
   for (const para of paragraphs) {
-    if ((current + para).length <= maxLength) {
-      current += (current ? '\n' : '') + para;
-    } else {
-      if (current) segments.push(current);
+    if (para.length === 0) continue;
+
+    // 按句末标点拆分,保留标点
+    const sentences = para.match(/[^。!?;.!?;]+[。!?;.!?;]?/g) || [para];
+    for (const s of sentences) {
+      if (s.trim().length > 0) {
+        rawSentences.push(s);
+      }
+    }
+  }
 
-      // 如果段落本身超长,按句子分割
-      if (para.length > maxLength) {
-        // 使用更安全的分割方式
-        const sentences = para.match(/[^。!?;]+[。!?;]?/g) || [para];
-        current = '';
+  // 第二步:将句子累加成段,接近上限时在最后一个标点处截断
+  let current = '';
 
-        for (const sentence of sentences) {
-          if (sentence.length === 0) continue;
+  for (const sentence of rawSentences) {
+    // 当前段 + 这句还能放下
+    if ((current + sentence).length <= maxLength) {
+      current += sentence;
+      continue;
+    }
 
-          if ((current + sentence).length <= maxLength) {
-            current += sentence;
-          } else {
-            if (current) segments.push(current);
-            // 如果句子本身超长,强制分割
-            if (sentence.length > maxLength) {
-              for (let i = 0; i < sentence.length; i += maxLength) {
-                segments.push(sentence.slice(i, i + maxLength));
-              }
-              current = '';
-            } else {
-              current = sentence;
+    // 放不下了,先把当前段输出
+    if (current) {
+      segments.push(current);
+      current = '';
+    }
+
+    // 如果这句话本身就超长,需要在句内找标点断点
+    if (sentence.length > maxLength) {
+      let remaining = sentence;
+      while (remaining.length > 0) {
+        if (remaining.length <= maxLength) {
+          current = remaining;
+          break;
+        }
+        // 在上限附近向前找最后一个标点作为断点
+        let breakPos = -1;
+        for (let i = maxLength; i > Math.max(0, maxLength - 100); i--) {
+          if (SENTENCE_END_RE.test(remaining[i])) {
+            breakPos = i + 1; // 标点后一位
+            break;
+          }
+        }
+        // 找不到标点,尝试逗号/顿号等次级标点
+        if (breakPos === -1) {
+          for (let i = maxLength; i > Math.max(0, maxLength - 100); i--) {
+            if (/[,、,]/.test(remaining[i])) {
+              breakPos = i + 1;
+              break;
             }
           }
         }
-      } else {
-        current = para;
+        // 实在找不到标点,只能在上限处硬切(最后手段)
+        if (breakPos === -1) {
+          breakPos = maxLength;
+        }
+        segments.push(remaining.slice(0, breakPos));
+        remaining = remaining.slice(breakPos);
       }
+    } else {
+      current = sentence;
     }
   }
 
   if (current) segments.push(current);
 
-  // 验证每段长度不超过限制(安全检查)
-  const safeLimit = 550;
-  const validatedSegments = segments.map((seg, idx) => {
-    if (seg.length > safeLimit) {
-      console.warn(`⚠️ 段落 ${idx + 1} 长度 ${seg.length} 超过限制,强制截断`);
-      return seg.substring(0, safeLimit);
-    }
-    return seg;
-  });
-
-  return validatedSegments;
+  return segments;
 }
 
 // TTS Provider 工厂
-// 支持: aliyun (阿里云百炼), minimax (MiniMax), mock (模拟)
-// 默认使用 MiniMax speech-2.8-hd
+// MiniMax: 异步模式(提交任务 → 轮询 → 下载)
+// 阿里云 Qwen-TTS: 同步模式(直接返回音频 URL → 下载),不支持 X-DashScope-Async
+//
+// 各家 TTS API 限制参考(2026年5月):
+// ┌──────────────┬────────────────────┬───────────┬──────────────────────┐
+// │ 服务商        │ 接口类型            │ 最大长度   │ 备注                 │
+// ├──────────────┼────────────────────┼───────────┼──────────────────────┤
+// │ MiniMax      │ 异步长文本          │ 1,000,000 │ 业界最长             │
+// │ MiniMax      │ 同步               │ 10,000    │ >3000推荐异步        │
+// │ 阿里云       │ 非流式/单向流式     │ 20,000    │ CosyVoice            │
+// │ 阿里云       │ WebSocket 流式     │ 20,000    │ 累计200,000           │
+// │ 阿里云       │ 传统长文本          │ 80,000    │ 建议40,000以内       │
+// │ 火山引擎/豆包 │ 异步长文本          │ 100,000   │ 音频保存7天          │
+// │ 火山引擎/豆包 │ 非流式             │ 1,024字节 │ 建议<300字符          │
+// │ 百度         │ 长文本异步          │ 100,000   │ 一次性合成            │
+// │ 百度         │ 短文本             │ 5,120     │ ~10240字节           │
+// │ 讯飞         │ 长文本TTS          │ 100,000   │ 万字级别快速合成      │
+// │ 讯飞         │ 流式在线           │ 8,000字节 │ ~4000汉字            │
+// │ 腾讯云       │ 长文本语音合成      │ 10,000+   │ 每个speak标签≤150字  │
+// │ 腾讯云       │ 基础语音合成       │ 150       │ 中文限制严格          │
+// │ OpenAI       │ TTS-1/HD           │ 4,096     │ 隐藏限制             │
+// │ Google Cloud │ Text-to-Speech     │ 5,000字节 │ SSML也计入           │
+// │ Azure        │ 实时合成           │ ~10分钟   │ 按音频时长限制        │
+// │ ElevenLabs   │ Flash/Turbo        │ 5,000     │ 付费计划             │
+// │ ElevenLabs   │ eleven_v3          │ 3,000     │ 表现力最强但限制低    │
+// │ Fish Audio   │ Fish Speech        │ ~8,192    │ tokens限制           │
+// └──────────────┴────────────────────┴───────────┴──────────────────────┘
+//
+// 当前分段上限 1000 字符,覆盖所有主流接口的短文本限制
+// 不满足1000字的接口(火山300/腾讯150/百度60)应走各自的异步长文本API
 function getTtsProvider(text: string, voiceId: string, providerType?: string) {
   // 如果指定了 provider 类型,直接使用
   if (providerType === 'minimax') {
@@ -266,6 +326,15 @@ export async function generateAudio(
     return result;
   } catch (error: any) {
     console.error('❌ 音频生成失败:', error.message);
+    // 更新 AudioRecord 状态为失败
+    try {
+      await prisma.audioRecord.update({
+        where: { audioId },
+        data: { status: 'failed', errorMsg: error.message },
+      });
+    } catch (dbError) {
+      console.error('❌ 更新 AudioRecord 失败状态失败:', dbError);
+    }
     // 创建失败标记文件
     const failedMarker = path.join(audioDir, 'failed');
     fs.writeFileSync(failedMarker, error.message);
@@ -328,16 +397,12 @@ async function processAudioGeneration(
       const selectedModel = (type !== 'mock' && type !== 'minimax') ? getRandomModel() : undefined;
       console.log(`🎲 selectedModel: ${selectedModel}, voiceName: ${voiceName}`);
 
-      // 根据 Provider 类型决定分段策略
-      let segments: string[];
-      if (type === 'minimax') {
-        // MiniMax 异步支持长文本,无需分段
-        segments = [text];
-        console.log(`📝 使用 MiniMax TTS,文本长度 ${text.length} 字符(不需分段)`);
-      } else {
-        segments = splitText(text);
-        console.log(`📝 文本已分段: ${segments.length} 段`);
-      }
+      // 统一分段策略:所有 Provider 使用 550 字符/段
+      // 理由:1) 小段通用,兼容任何 TTS 模型(新加模型无需改代码)
+      //       2) 失败只需重试某段,不需要整篇重来
+      //       3) 所有 Provider 走同一套合并逻辑,行为一致
+      const segments = splitText(text);
+      console.log(`📝 文本已分段: ${segments.length} 段 (Provider: ${type})`);
 
       segments.forEach((seg, i) => {
         console.log(`   段落 ${i + 1}: ${seg.length} 字符`);
@@ -389,9 +454,9 @@ async function processAudioGeneration(
       let duration = 0;
       let size = 0;
 
-      if (cloudUrls.length > 0) {
-        // 云端 URL:下载后统一通过 storageService 上传到 OSS
-        console.log('☁️ 下载云端音频并上传到 OSS...');
+      if (cloudUrls.length > 0 && audioFiles.length === 0 && cloudUrls.length === 1) {
+        // 只有1个云端URL且没有本地文件:直接下载并上传
+        console.log('☁️ 下载单个云端音频并上传到 OSS...');
         try {
           const cloudUrl = cloudUrls[0];
           const response = await axios.get(cloudUrl, { responseType: 'arraybuffer', timeout: 60000 });
@@ -413,28 +478,41 @@ async function processAudioGeneration(
           const stats = fs.statSync(tempPath);
           size = stats.size;
         } catch (uploadError: any) {
-          console.error('❌ 云端音频上传失败,使用本地文件:', uploadError.message);
-          // 降级到本地文件处理
-          if (audioFiles.length > 0) {
-            const outputPath = path.join(audioDir, 'output.mp3');
-            const mergedFile = await AudioMerger.merge(audioFiles, outputPath);
-            const stats = fs.statSync(mergedFile);
-            size = stats.size;
-            duration = await AudioMerger.getDuration(mergedFile);
-            audioUrl = await storageService.uploadAudio(mergedFile, audioId);
-          } else {
-            throw new Error('云端音频上传失败且无本地文件降级');
+          console.error('❌ 云端音频上传失败:', uploadError.message);
+          throw new Error('云端音频下载/上传失败');
+        }
+      } else {
+        // 有多个文件需要合并(混合云端+本地,或多个云端,或多个本地)
+        // 先下载所有云端URL到本地
+        const allLocalFiles = [...audioFiles];
+        if (cloudUrls.length > 0) {
+          console.log(`☁️ 下载 ${cloudUrls.length} 个云端音频文件...`);
+          for (let ci = 0; ci < cloudUrls.length; ci++) {
+            try {
+              const response = await axios.get(cloudUrls[ci], { responseType: 'arraybuffer', timeout: 60000 });
+              const localPath = path.join(audioDir, `cloud_segment_${ci}.mp3`);
+              fs.writeFileSync(localPath, Buffer.from(response.data));
+              allLocalFiles.push(localPath);
+              console.log(`☁️ 云端音频 ${ci + 1}/${cloudUrls.length} 已下载`);
+            } catch (dlErr: any) {
+              console.error(`❌ 云端音频 ${ci + 1} 下载失败:`, dlErr.message);
+            }
           }
         }
-      } else if (audioFiles.length > 0) {
+
+        if (allLocalFiles.length === 0) {
+          throw new Error('所有音频文件下载失败,无法生成音频');
+        }
+
+        // 合并所有文件
         const outputPath = path.join(audioDir, 'output.mp3');
-        const mergedFile = await AudioMerger.merge(audioFiles, outputPath);
+        console.log(`📁 合并 ${allLocalFiles.length} 个音频文件...`);
+        const mergedFile = await AudioMerger.merge(allLocalFiles, outputPath);
         const stats = fs.statSync(mergedFile);
         size = stats.size;
         duration = await AudioMerger.getDuration(mergedFile);
-        // 统一通过 storageService 上传
         audioUrl = await storageService.uploadAudio(mergedFile, audioId);
-        console.log('📁 本地音频已上传:', audioUrl);
+        console.log(`📁 音频已合并并上传 (${duration}s):`, audioUrl);
       }
 
       // 使用 AI 生成标题、摘要和标签
@@ -449,10 +527,24 @@ async function processAudioGeneration(
       const finalAudioUrl = audioUrl;
 
       // 生成 LRC 格式歌词时间轴(优先使用 FFmpeg 精确停顿检测)
-      const localAudioPath = path.join(audioDir, 'output.mp3');
-      const lrcLyrics = generateLrc(text, duration, localAudioPath);
+      let lrcLyrics = '';
+      try {
+        const localAudioPath = path.join(audioDir, 'output.mp3');
+        const fileExists = fs.existsSync(localAudioPath);
+        logToFile(`🎵 LRC 开始: 本地文件=${localAudioPath}, 存在=${fileExists}, duration=${duration}, textLen=${text.length}`);
+        console.log(`🎵 开始生成 LRC, 本地文件: ${localAudioPath}, 存在: ${fileExists}`);
+        lrcLyrics = generateLrc(text, duration, localAudioPath);
+        logToFile(`🎵 LRC 结果: length=${lrcLyrics.length}, lines=${lrcLyrics ? lrcLyrics.split('\n').length : 0}`);
+      } catch (lrcErr: any) {
+        logToFile(`❌ LRC 异常: ${lrcErr.message}`);
+        console.error(`❌ LRC 生成异常:`, lrcErr.message);
+      }
       if (lrcLyrics) {
         console.log(`🎵 LRC 歌词已生成: ${lrcLyrics.split('\n').length} 句`);
+        logToFile(`🎵 LRC 保存: ${lrcLyrics.split('\n').length} 句, 首行=${lrcLyrics.split('\n')[0]}`);
+      } else {
+        console.warn(`⚠️ LRC 生成结果为空`);
+        logToFile(`⚠️ LRC 为空`);
       }
 
       // 保存到书籍章节(所有音频必须属于书籍)
@@ -461,21 +553,24 @@ async function processAudioGeneration(
 
       if (targetBookId && targetChapterId) {
         try {
+          logToFile(`💾 写入章 chapterId=${targetChapterId}, audioUrl=${finalAudioUrl?.substring(0,40)}..., dur=${duration}, lrcLen=${lrcLyrics?.length || 0}`);
           await prisma.bookChapter.update({
             where: { id: targetChapterId },
             data: {
               audioUrl: finalAudioUrl,
               audioDuration: duration,
               lrcLyrics: lrcLyrics || null,
-              status: 'completed',
               generatedAt: new Date(),
             },
           });
+          logToFile(`✅ 章更新成功 chapterId=${targetChapterId}`);
           console.log(`✅ 已更新书籍章节音频 (chapterId: ${targetChapterId}), URL: ${finalAudioUrl}`);
         } catch (error) {
+          logToFile(`❌ 章更新失败 chapterId=${targetChapterId}: ${error}`);
           console.error('❌ 保存到书籍章节失败:', error);
         }
       } else {
+        logToFile(`⚠️ 跳过章保存: bookId=${targetBookId}, chapterId=${targetChapterId}`);
         console.warn(`⚠️ 未指定 bookId 或 chapterId,不能保存音频到书籍章节`);
       }
 
@@ -603,142 +698,255 @@ export function getVoices(): Voice[] {
   return VOICES;
 }
 
-// ============ FFmpeg 静音检测 + 精确 LRC 生成 ============
+// ============ LRC 歌词生成 ============
+
+/** 格式化秒数为 [MM:SS.XX] */
+function formatLrcTimestamp(seconds: number): string {
+  const m = Math.floor(seconds / 60);
+  const s = seconds % 60;
+  const cs = Math.round((s - Math.floor(s)) * 100);
+  const sec = Math.floor(s);
+  return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}.${cs.toString().padStart(2, '0')}`;
+}
+
+/** 清洗文本中的换行符(LRC每行只能有一个时间戳,文本中不能含换行) */
+function sanitizeLrcText(s: string): string {
+  return s.replace(/\n+/g, ' ').trim();
+}
+
+/** 统计可见字符数(去除空白) */
+function countVisible(s: string): number {
+  return s.replace(/\s/g, '').length;
+}
+
+/** 最大每行字符数(超过则进一步拆分) */
+const MAX_CHARS_PER_LINE = 50;
 
 /**
- * 用 FFmpeg 检测音频中的静音间隙,返回真实的停顿时间点(秒)
- * silencedetect 参数:-30dB 阈值,0.3s 最小静音时长
+ * 将文本拆分为适合 LRC 显示的小句子
+ * 策略:
+ * 1. 先按 Markdown 标题(#/##)切分,标题独立一行
+ * 2. 按句末标点(。!?)切分
+ * 3. 按分号/冒号(;:)切分
+ * 4. 按逗号(,,)切分
+ * 5. 按换行切分
+ * 6. 兜底:按固定字符数切分
+ * 每行不超过 MAX_CHARS_PER_LINE 字符
  */
-export function detectAudioSilence(audioPath: string): number[] {
-  try {
-    const result = execSync(
-      `ffmpeg -i "${audioPath}" -af silencedetect=n=-30dB:d=0.3 -f null - 2>&1`,
-      { timeout: 30000, encoding: 'utf-8' }
-    );
-    const silenceEnds: number[] = [];
-    const regex = /silence_end:\s*([\d.]+)/g;
-    let match;
-    while ((match = regex.exec(result)) !== null) {
-      silenceEnds.push(parseFloat(match[1]));
+function splitIntoLrcSentences(text: string): string[] {
+  const result: string[] = [];
+  const maxChars = MAX_CHARS_PER_LINE;
+
+  // 第一步:按 Markdown 标题切分,标题独立成行
+  const titleParts = text.split(/(?=^#{1,3}\s)/m);
+  
+  for (const part of titleParts) {
+    const trimmed = part.trim();
+    if (!trimmed) continue;
+
+    // 如果是标题行(单独一行)
+    const titleMatch = trimmed.match(/^(#{1,3}\s+.+?)(\n|$)/);
+    if (titleMatch) {
+      const titleLine = titleMatch[1].trim();
+      const rest = trimmed.substring(titleMatch[0].length).trim();
+      
+      // 标题单独一行
+      result.push(titleLine);
+      
+      // 处理标题后面的正文
+      if (rest) {
+        result.push(...splitBodyIntoSentences(rest, maxChars));
+      }
+    } else {
+      // 没有标题,直接处理正文
+      result.push(...splitBodyIntoSentences(trimmed, maxChars));
     }
-    console.log(`🔇 FFmpeg 检测到 ${silenceEnds.length} 个静音点`);
-    return silenceEnds;
-  } catch (err: any) {
-    console.warn(`⚠️ FFmpeg 静音检测失败,降级到估算模式:`, err.message);
-    return [];
   }
+
+  return result.filter(s => countVisible(s) > 0);
 }
 
-/** 按 Markdown 结构分段(优先级:# 标题 > ## 小节 > 空行分段 > 。!?断句) */
-function splitByMarkdownSections(text: string): string[] {
-  const sections: string[] = [];
-
-  // 先按一级标题分割
-  const h1Parts = text.split(/(?=^# )/m);
-  for (const h1Part of h1Parts) {
-    if (!h1Part.trim()) continue;
-    // 再按二级标题分割
-    const h2Parts = h1Part.split(/(?=^## )/m);
-    for (const h2Part of h2Parts) {
-      const trimmed = h2Part.trim();
-      if (!trimmed) continue;
-      // 标题行占比少,作为一个独立段落
-      const isHeading = /^#{1,2}\s/.test(trimmed);
-      if (isHeading) {
-        sections.push(trimmed);
-      } else {
-        // 正文段落:按空行分割
-        const paras = trimmed.split(/\n{2,}/).filter(p => p.trim());
-        for (const para of paras) {
-          sections.push(para.trim());
-        }
-      }
+/**
+ * 将正文拆分为小句子
+ * 优先级:句号 > 分号/冒号 > 逗号 > 换行 > 固定长度
+ */
+function splitBodyIntoSentences(text: string, maxChars: number): string[] {
+  const result: string[] = [];
+
+  // 先按段落(空行/换行)粗分
+  const paragraphs = text.split(/\n+/).filter(p => p.trim());
+  
+  for (const para of paragraphs) {
+    const trimmed = para.trim();
+    if (!trimmed) continue;
+
+    // 如果整段就小于 maxChars,直接加入
+    if (countVisible(trimmed) <= maxChars) {
+      result.push(trimmed);
+      continue;
     }
+
+    // 按句末标点切分(。!?)
+    let sentences = splitByPunctuation(trimmed, /[。!?]+/, maxChars);
+    
+    // 如果切分后仍有超长句子,按分号/冒号再切
+    sentences = furtherSplit(sentences, /[;:]/, maxChars);
+    
+    // 如果仍有超长句子,按逗号再切
+    sentences = furtherSplit(sentences, /[,,]/, maxChars);
+    
+    // 兜底:强制按字符数切分
+    sentences = forceSplitByCharCount(sentences, maxChars);
+    
+    result.push(...sentences);
   }
-  // 如果没有 markdown 结构,退回到按句号分段
-  if (sections.length <= 1) {
-    return text.split(/(?<=[。!?;])\s*/).filter(s => s.trim());
+
+  return result;
+}
+
+/** 按指定标点切分,标点附在前面那句末尾 */
+function splitByPunctuation(text: string, punctRegex: RegExp, maxChars: number): string[] {
+  const parts: string[] = [];
+  let remaining = text;
+
+  while (remaining.length > 0) {
+    // 查找下一个标点位置
+    const match = remaining.match(punctRegex);
+    if (!match || match.index === undefined) {
+      // 没有更多标点,剩余部分整体加入
+      parts.push(remaining);
+      break;
+    }
+
+    const cutPos = match.index + match[0].length;
+    const sentence = remaining.substring(0, cutPos).trim();
+    
+    if (sentence) {
+      parts.push(sentence);
+    }
+    remaining = remaining.substring(cutPos).trim();
   }
-  return sections;
+
+  return parts;
 }
 
-/** 格式化秒数为 [MM:SS.XX] */
-function formatLrcTimestamp(seconds: number): string {
-  const m = Math.floor(seconds / 60);
-  const s = seconds % 60;
-  const cs = Math.round((s - Math.floor(s)) * 100);
-  const sec = Math.floor(s);
-  return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}.${cs.toString().padStart(2, '0')}`;
+/** 对已切分的句子,如果某些句子仍然超长,用更细粒度的标点再切 */
+function furtherSplit(sentences: string[], punctRegex: RegExp, maxChars: number): string[] {
+  const result: string[] = [];
+  for (const s of sentences) {
+    if (countVisible(s) <= maxChars) {
+      result.push(s);
+      continue;
+    }
+    // 用更细粒度标点再切
+    const subParts = splitByPunctuation(s, punctRegex, maxChars);
+    result.push(...subParts);
+  }
+  return result;
+}
+
+/** 兜底:强制按字符数切分超长句子 */
+function forceSplitByCharCount(sentences: string[], maxChars: number): string[] {
+  const result: string[] = [];
+  for (const s of sentences) {
+    if (countVisible(s) <= maxChars) {
+      result.push(s);
+      continue;
+    }
+    // 按可见字符数强制切分
+    let buf = '';
+    let visibleCount = 0;
+    for (const ch of s) {
+      buf += ch;
+      if (!/\s/.test(ch)) visibleCount++;
+      if (visibleCount >= maxChars) {
+        result.push(buf.trim());
+        buf = '';
+        visibleCount = 0;
+      }
+    }
+    if (buf.trim()) result.push(buf.trim());
+  }
+  return result;
 }
 
 /**
- * 基于真实停顿点 + Markdown 段落 生成精确 LRC 歌词
- * 优先使用 FFmpeg 静音检测,失败退回到智能分段估算
+ * 生成 LRC 歌词时间轴
+ * 
+ * 核心算法:**按句子拆分 + 均匀语速分配**
+ * 
+ * 原理:
+ * - TTS 生成的音频语速相对均匀
+ * - 用 总时长/总字数 得到真实平均语速(秒/字)
+ * - 每句时长 = 该句字数 × 平均语速
+ * - 这样保证所有句子时间之和 = 总时长,且短句少分时间、长句多分时间
  */
 export function generateLrc(text: string, duration: number, audioPath?: string): string {
   if (!text || duration <= 0) return '';
 
-  const sections = splitByMarkdownSections(text);
-  if (sections.length === 0) return '';
+  try {
+    const sentences = splitIntoLrcSentences(text);
+    if (sentences.length === 0) return '';
 
-  // 尝试 FFmpeg 静音检测获取真实停顿点
-  let silencePoints: number[] = [];
-  if (audioPath && fs.existsSync(audioPath)) {
-    silencePoints = detectAudioSilence(audioPath);
-  }
+    logToFile(`🎵 LRC 拆分: ${sentences.length} 句, 总时长=${duration}s`);
 
-  // 如果有足够多的真实停顿点(至少比段落数少1),直接映射
-  if (silencePoints.length >= sections.length - 1) {
-    console.log(`✅ 使用 FFmpeg 精确停顿点生成 LRC (${sections.length} 个段落)`);
-    return buildLrcFromSilencePoints(sections, silencePoints, duration);
-  }
+    // 计算总可见字符数
+    const totalChars = sentences.reduce((sum, s) => sum + countVisible(s), 0);
+    if (totalChars === 0) return '';
 
-  // 降级:智能分段估算(标题停顿长、正文停顿短)
-  console.log(`📝 FFmpeg 数据不足,使用智能估算生成 LRC (${sections.length} 个段落)`);
-  return buildLrcByEstimation(sections, duration);
-}
+    // 核心:均匀语速 = 总时长 / 总字数
+    const speechRate = duration / totalChars; // 秒/字
+    logToFile(`🎵 语速: ${speechRate.toFixed(3)}s/字, 总字数=${totalChars}`);
 
-/** 用真实停顿点构建 LRC */
-function buildLrcFromSilencePoints(sections: string[], silencePoints: number[], totalDuration: number): string {
-  const lines: string[] = [];
-  for (let i = 0; i < sections.length; i++) {
-    const startTime = i === 0 ? 0 : silencePoints[i - 1];
-    const endTime = i < silencePoints.length ? silencePoints[i] : totalDuration;
-    const correctedStart = Math.min(startTime, totalDuration - 0.5);
-    lines.push(`[${formatLrcTimestamp(correctedStart)}] ${sections[i]}`);
+    const lines: string[] = [];
+    let currentTime = 0;
+
+    for (let i = 0; i < sentences.length; i++) {
+      const charCount = countVisible(sentences[i]);
+      const lineDuration = charCount * speechRate;
+
+      lines.push(`[${formatLrcTimestamp(currentTime)}] ${sanitizeLrcText(sentences[i])}`);
+      
+      logToFile(`  [${formatLrcTimestamp(currentTime)}] ${charCount}字 ${sentences[i].substring(0, 30)}...`);
+      
+      currentTime += lineDuration;
+    }
+
+    // 确保最后一行不超过总时长
+    logToFile(`🎵 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);
+    return buildSimpleLrc(text, duration);
   }
-  return lines.join('\n');
 }
 
-/** 智能估算构建 LRC(降级方案) */
-function buildLrcByEstimation(sections: string[], totalDuration: number): string {
-  const lines: string[] = [];
+/** 最简降级:按句号或换行分割,再不行按固定长度切分 */
+function buildSimpleLrc(text: string, duration: number): string {
+  let sentences = text.split(/(?<=[。!?;])\s*/).filter(s => s.trim());
+  if (sentences.length <= 1) {
+    sentences = text.split(/\n+/).filter(s => s.trim());
+  }
+  if (sentences.length <= 1 && text.length > 20) {
+    const chunkSize = 40;
+    sentences = [];
+    for (let i = 0; i < text.length; i += chunkSize) {
+      sentences.push(text.substring(i, i + chunkSize));
+    }
+  }
+  if (sentences.length === 0) return '';
 
-  // 分配时长:标题权重 0.3,正文权重 1.0,标题间加 0.5s 停顿
-  const weights = sections.map((s) => {
-    const isHeading = /^#{1,2}\s/.test(s);
-    return isHeading ? 0.3 : 1.0;
-  });
+  const totalChars = sentences.reduce((sum, s) => sum + countVisible(s), 0);
+  if (totalChars === 0) return '';
 
-  // 按字符加权分配
-  const weightedChars = sections.map((s, i) => {
-    const chars = (s.match(/[一-龥a-zA-Z0-9]/g) || []).length;
-    return chars * weights[i];
-  });
-  const totalWeighted = weightedChars.reduce((a, b) => a + b, 0);
-  if (totalWeighted === 0) return '';
-
-  let currentTime = 0;
-  for (let i = 0; i < sections.length; i++) {
-    const ratio = weightedChars[i] / totalWeighted;
-    const segmentDuration = ratio * totalDuration;
-
-    lines.push(`[${formatLrcTimestamp(currentTime)}] ${sections[i]}`);
-    currentTime += segmentDuration;
-    // 标题后加小停顿
-    if (i < sections.length - 1 && /^#{1,2}\s/.test(sections[i])) {
-      currentTime = Math.min(currentTime + 0.5, totalDuration);
-    }
+  const speechRate = duration / totalChars;
+  const lines: string[] = [];
+  let t = 0;
+  for (const s of sentences) {
+    lines.push(`[${formatLrcTimestamp(t)}] ${sanitizeLrcText(s)}`);
+    t += countVisible(s) * speechRate;
   }
   return lines.join('\n');
 }
@@ -813,10 +1021,8 @@ export async function generatePreview(
       };
     }
 
-    return {
-      audioId,
-      audioUrl: '',
-    };
+    // 所有分段都失败,抛出明确错误
+    throw new Error('预览生成失败:所有音频分段生成均失败');
   } catch (error: any) {
     console.error('❌ 预览生成失败:', error);
     throw error;

+ 1 - 3
server/src/modules/video-generator/video-generator.service.ts

@@ -3,7 +3,7 @@
  * 处理视频项目的增删改查和生成逻辑
  */
 
-import { PrismaClient } from '@prisma/client';
+import { prisma } from '../../models';
 import path from 'path';
 import { v4 as uuidv4 } from 'uuid';
 import {
@@ -23,8 +23,6 @@ import {
 } from './video-generator.types';
 import { generateVideo, generateVideoWithBgm, generateSlideshow } from './video-generator.ffmpeg';
 
-const prisma = new PrismaClient();
-
 // ============ 视频项目管理 ============
 
 /**

+ 3 - 3
server/src/services/ffmpeg.processor.ts

@@ -89,9 +89,9 @@ export class FFmpegProcessor {
         tempFiles.push(localFile);
       }
 
-      // 2. 创建 FFmpeg 文件列表
+      // 2. 创建 FFmpeg 文件列表(路径中的反斜杠转为正斜杠,避免 Windows 下 FFmpeg 解析失败)
       const listFile = path.join(TEMP_DIR, `${uuidv4()}_list.txt`);
-      const listContent = tempFiles.map(f => `file '${f}'`).join('\n');
+      const listContent = tempFiles.map(f => `file '${f.replace(/\\/g, '/')}'`).join('\n');
       fs.writeFileSync(listFile, listContent);
 
       // 3. 执行 FFmpeg 合并
@@ -198,7 +198,7 @@ export class FFmpegProcessor {
       // 获取时长
       const cmd = `ffprobe -i "${localFile}" -show_entries format=duration -v quiet -of csv="p=0"`;
       const { stdout } = await execAsync(cmd, { timeout: 10000 });
-      const duration = Math.round(parseFloat(stdout.trim()) || 0);
+      const duration = Math.round((parseFloat(stdout.trim()) || 0) * 10) / 10;
       
       console.log(`[FFmpeg] 音频时长: ${duration}秒`);
       return duration;

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

@@ -15,7 +15,7 @@ export interface IUser {
   updatedAt: Date;
 }
 
-export type MemberLevel = 0 | 1 | 2; // 0免费 1月度 2年度
+export type MemberLevel = 0 | 1 | 2 | 3 | 4; // 0免费 1入门 2专业 3旗舰 4企业
 
 // 音频相关类型
 export interface IAudio {
@@ -119,6 +119,8 @@ export interface MemberQuota {
 
 export const MEMBER_QUOTA: Record<MemberLevel, MemberQuota> = {
   0: { dailyLimit: 3, wordLimit: 5000 },
-  1: { dailyLimit: 20, wordLimit: 50000 },
-  2: { dailyLimit: -1, wordLimit: -1 },
+  1: { dailyLimit: 10, wordLimit: 10000 },
+  2: { dailyLimit: -1, wordLimit: 10000 },
+  3: { dailyLimit: -1, wordLimit: -1 },
+  4: { dailyLimit: -1, wordLimit: -1 },
 };

+ 17 - 0
server/src/utils/safe-parse.ts

@@ -0,0 +1,17 @@
+/**
+ * 安全数字转换工具
+ */
+
+/** 安全地将字符串转为整数,NaN 时返回默认值或抛出错误 */
+export function safeParseInt(value: string | number | undefined | null, defaultValue?: number): number {
+  if (value === undefined || value === null) {
+    if (defaultValue !== undefined) return defaultValue;
+    throw new Error('值不能为空');
+  }
+  const num = typeof value === 'number' ? Math.floor(value) : parseInt(value, 10);
+  if (isNaN(num)) {
+    if (defaultValue !== undefined) return defaultValue;
+    throw new Error(`无法解析为数字: ${value}`);
+  }
+  return num;
+}

+ 0 - 62
server/test-split.js

@@ -1,62 +0,0 @@
-const http = require('http');
-
-const testData = {
-  title: 'Python编程入门',
-  description: '适合初学者的Python编程教程,要有代码示例和练习题',
-  bookScale: '小册子'
-};
-
-console.log('步骤1: 创建书籍...');
-
-const req = http.request({
-  hostname: 'localhost',
-  port: 3000,
-  path: '/api/book-generator/langgraph/books',
-  method: 'POST',
-  headers: {
-    'Content-Type': 'application/json',
-  }
-}, (res) => {
-  let data = '';
-  res.on('data', (chunk) => data += chunk);
-  res.on('end', () => {
-    const result = JSON.parse(data);
-    console.log('创建书籍响应:', res.statusCode);
-    console.log('书籍ID:', result.data?.id);
-    
-    if (result.data?.id) {
-      const bookId = result.data.id;
-      console.log('\n步骤2: 触发生成...');
-      
-      const genReq = http.request({
-        hostname: 'localhost',
-        port: 3000,
-        path: `/api/book-generator/langgraph/books/${bookId}/generate`,
-        method: 'POST',
-        headers: {
-          'Content-Type': 'application/json',
-        }
-      }, (genRes) => {
-        let genData = '';
-        genRes.on('data', (chunk) => genData += chunk);
-        genRes.on('end', () => {
-          console.log('生成响应:', genRes.statusCode);
-          console.log('生成数据:', genData);
-        });
-      });
-      
-      genReq.on('error', (e) => {
-        console.error('生成请求失败:', e.message);
-      });
-      
-      genReq.end();
-    }
-  });
-});
-
-req.on('error', (e) => {
-  console.error('创建请求失败:', e.message);
-});
-
-req.write(JSON.stringify(testData));
-req.end();

+ 0 - 46
server/test-tts.js

@@ -1,46 +0,0 @@
-const axios = require('axios');
-
-const BASE_URL = 'http://localhost:3000';
-
-async function testTTSGenerate() {
-  console.log('=== 测试 TTS 音频生成 ===\n');
-
-  try {
-    // 1. 先登录获取 token
-    console.log('1️⃣  登录获取 Token...');
-    const login = await axios.post(`${BASE_URL}/api/auth/login`, {
-      phone: '13800138000',
-      code: '123456'
-    });
-    const token = login.data.data.token;
-    console.log('✅ 登录成功,用户ID:', login.data.data.user.id);
-
-    // 2. 生成音频
-    console.log('\n2️⃣  生成音频...');
-    const result = await axios.post(`${BASE_URL}/api/tts/generate`, {
-      text: '你好',
-      voiceId: 'cherry',
-      voiceParams: {
-        speed: 1,
-        pitch: 0,
-        volume: 50
-      }
-    }, {
-      headers: {
-        Authorization: `Bearer ${token}`
-      },
-      timeout: 60000 // 60秒超时
-    });
-
-    console.log('✅ 音频生成成功!');
-    console.log('📊 返回数据:', JSON.stringify(result.data, null, 2));
-
-  } catch (error) {
-    console.error('❌ 测试失败:', error.message);
-    if (error.response) {
-      console.error('📋 错误响应:', JSON.stringify(error.response.data, null, 2));
-    }
-  }
-}
-
-testTTSGenerate();