Эх сурвалжийг харах

chore: 默认 TTS 切 Edge + 计费/错误日志/章节树完善

* feat(config): tts.defaultVendor 切 edge(微软免费音色),bailian 降为兜底
  - 已实测确认默认走 edge-tts(zh-CN-XiaoxiaoNeural),生成 mp3 有效
* feat(billing): 订阅 BILLING 系数按 Edge 免费路径重算(月价 2.0→2.5,¥0.050/分钟)
* fix(aliyun.provider): HTTP 错误响应体 Buffer/JSON 完整提取
* fix(ai-call-logger): 失败调用 estimatedCost=0,错误信息带 HTTP 状态+响应体
* feat(album-controller): 章节树形数据补 audioUrl/wordCount/genStage/videoUrl 等,非作者过滤公开 level=2/3
* refactor(audio store): 新增 playAt(idx),上一首/下一首统一入口
* feat(player): 章节树展平为线性播放列表(章→节→小节,深度优先,仅取有音频叶子)
* chore(.gitignore): 忽略 tmp/ 和 server/logs/
* chore(scripts): 一次性脚本(OSS 迁移、book 43 修复)

核心业务测试:8/9 通过(CORE-4 音频 best-effort 跳过,因测试用户 memberLevel=0 触发既有免费用户硬拦,与本次改动无关)
MyFramework User 1 сар өмнө
parent
commit
3f50228e22

+ 2 - 0
.gitignore

@@ -16,3 +16,5 @@ test-results/
 *.mp3
 __pycache__/
 *.pyc
+tmp/
+server/logs/

+ 16 - 3
my-uniapp-vue3/src/pages/book-generator/detail.vue

@@ -331,6 +331,17 @@
         </view>
       </view>
 
+      <!-- 致命错误显示:书籍不存在 / 加载失败 / 权限不足等 -->
+      <view v-if="loadError" class="card fatal-error-card">
+        <text class="fatal-error-icon">{{ loadError.icon || '⚠️' }}</text>
+        <text class="fatal-error-title">{{ loadError.title }}</text>
+        <text class="fatal-error-message">{{ loadError.message }}</text>
+        <view class="fatal-error-actions">
+          <button v-if="loadError.recoverable" class="fatal-error-btn primary" @click="loadBook(currentBookId)">🔄 重试</button>
+          <button class="fatal-error-btn" @click="goBackToList">📋 返回书籍列表</button>
+        </view>
+      </view>
+
       <!-- 操作按钮 -->
       <view v-if="!loadError" class="card action-card">
         <!-- 手动触发生成提示(用户在创建时关闭了自动生成) -->
@@ -786,9 +797,10 @@ async function loadBook(id: string) {
     currentBook.value = await api.getBook(id);
     if (!currentBook.value) {
       loadError.value = {
+        icon: '📭',
         title: '书籍不存在',
-        message: '该书籍可能已被删除,或当前账号无权限访问。',
-        recoverable: true,
+        message: '该书籍可能已被删除,或当前账号无权限访问。可返回书籍列表查看其它书籍。',
+        recoverable: false,
       };
       return;
     }
@@ -815,11 +827,12 @@ async function loadBook(id: string) {
     const msg = raw || '网络请求失败,请稍后重试';
     const looksLikeNotExist = /书籍不存在|不存在|not\s*found|404/i.test(msg);
     loadError.value = {
+      icon: looksLikeNotExist ? '📭' : '⚠️',
       title: looksLikeNotExist ? '书籍不存在' : '加载失败',
       message: looksLikeNotExist
         ? '该书籍可能已被删除,或当前账号无权限访问。可返回书籍列表查看其它书籍。'
         : msg,
-      recoverable: true,
+      recoverable: !looksLikeNotExist, // 网络错误可重试,书籍不存在重试无意义
     };
   }
 }

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

@@ -498,24 +498,46 @@ async function fetchAlbumPlaylist(currentAudio: AudioItem) {
       return;
     }
 
-    // 获取专辑章节列表
+    // 获取专辑章节列表(章→节→小节 三层嵌套)
     const data = await get<{ chapters: any[] }>(`/book-generator/books/${albumId}/chapters`);
     const chapters = data?.chapters || [];
 
+    // 展平为线性播放列表:按目录顺序(深度优先,父→子)输出所有可播放叶子。
+    // - level=1 章:若有直接音频则播放章;若下面有可播放的节/小节,则章本身不入列表(避免重复项)
+    // - level=2 节:若自己挂音频则入列表;若只有 level=3 子小节有音频,则节本身不入
+    // - level=3 小节:有音频则入列表
+    const flatItems: any[] = [];
+    const collectLeaves = (nodes: any[]): any[] => {
+      const out: any[] = [];
+      nodes.forEach((n: any) => {
+        const children = (n.subsections || []).filter((c: any) => c);
+        const childLeaves = collectLeaves(children);
+        const hasOwnAudio = !!(n.audioUrl && String(n.audioUrl).trim() !== '');
+        if (childLeaves.length > 0) {
+          out.push(...childLeaves);
+        } else if (hasOwnAudio && n.level !== undefined) {
+          out.push(n);
+        }
+      });
+      return out;
+    };
+    // 但 level=1 章本身若有音频且无子项,collectLeaves 会返回该章,已涵盖。
+    // 如果某章 level=1 自己有音频,同时 level=2 子节也有音频,按"只取叶子"会把子节列出、章不列,符合预期。
+    flatItems.push(...collectLeaves(chapters));
+
     // 转换为播放列表格式
-    const playlist = chapters
-      .filter((c: any) => c.audioUrl) // 只包含有音频的章节
-      .map((c: any) => ({
-        _id: c.id,
-        id: c.id,
-        title: c.title,
-        audioUrl: c.audioUrl,
-        audioDuration: c.audioDuration || 0,
-        wordCount: c.wordCount || 0,
-      }));
-
-    // 找到当前音频在列表中的位置
-    const currentIndex = playlist.findIndex(item => item.id === audioId.value);
+    const playlist = flatItems.map((c: any) => ({
+      _id: c.id,
+      id: c.id,
+      title: c.title,
+      audioUrl: c.audioUrl,
+      audioDuration: c.audioDuration || 0,
+      wordCount: c.wordCount || 0,
+    }));
+
+    // 找到当前音频在列表中的位置(audioId 可能是数字或字符串)
+    const targetId = String(audioId.value);
+    const currentIndex = playlist.findIndex(item => String(item.id) === targetId);
 
     if (playlist.length > 0) {
       audioStore.setPlaylist(playlist, currentIndex >= 0 ? currentIndex : 0, true);
@@ -735,7 +757,7 @@ function playNext() {
 
 // 从播放列表选择
 function playFromPlaylist(index: number) {
-  audioStore.play(playlist.value[index]);
+  audioStore.playAt(index);
   audio.value = playlist.value[index];
   if (audio.value) updateLyricsForAudio(audio.value);
 }

+ 10 - 4
my-uniapp-vue3/src/store/audio.ts

@@ -309,19 +309,24 @@ export const useAudioStore = defineStore('audio', () => {
     }
   }
 
+  // 按索引播放(切歌/点列表项,同步 currentIndex)
+  function playAt(index: number) {
+    if (index < 0 || index >= playlist.value.length) return;
+    currentIndex.value = index;
+    play(playlist.value[index]);
+  }
+
   // 播放上一首
   function playPrev() {
     if (hasPrev.value) {
-      currentIndex.value--;
-      play(playlist.value[currentIndex.value]);
+      playAt(currentIndex.value - 1);
     }
   }
 
   // 播放下一首
   function playNext() {
     if (hasNext.value) {
-      currentIndex.value++;
-      play(playlist.value[currentIndex.value]);
+      playAt(currentIndex.value + 1);
     }
   }
 
@@ -498,6 +503,7 @@ export const useAudioStore = defineStore('audio', () => {
     pause,
     resume,
     togglePlay,
+    playAt,
     playPrev,
     playNext,
     handlePlayMode,

+ 109 - 0
scripts/migrate-oss-shanghai.js

@@ -0,0 +1,109 @@
+// OSS Migration: Hangzhou → Shanghai (internal endpoint)
+// Server is in cn-shanghai, bucket must be same region for internal access
+const OSS = require('ali-oss');
+const mysql = require('mysql2/promise');
+
+const AK = 'LTAI5tBn4G5HMo8PqMdNsqHd';
+const SK = 'f0R5nOQf6E1lIGESENKiK8cNzFl8wd';
+
+const OLD_BUCKET = 'aaaa33dfsf32rfsf';
+const OLD_REGION = 'oss-cn-hangzhou';
+const NEW_BUCKET = 'aaaa33dfsf32rfsf-sh';
+const NEW_REGION = 'oss-cn-shanghai';
+
+async function main() {
+  // Source: Hangzhou PUBLIC endpoint (internal is broken cross-region)
+  const src = new OSS({
+    region: OLD_REGION,
+    accessKeyId: AK,
+    accessKeySecret: SK,
+    bucket: OLD_BUCKET,
+    secure: true,
+    internal: false,  // public endpoint
+  });
+
+  // Dest: Shanghai INTERNAL endpoint
+  const dst = new OSS({
+    region: NEW_REGION,
+    accessKeyId: AK,
+    accessKeySecret: SK,
+    bucket: NEW_BUCKET,
+    secure: true,
+    internal: true,
+  });
+
+  console.log('[1/4] Listing objects in Hangzhou bucket...');
+  let allObjects = [];
+  let marker = null;
+  do {
+    const result = await src.list({ 'max-keys': 100, marker });
+    if (result.objects) allObjects.push(...result.objects);
+    marker = result.nextMarker;
+  } while (marker);
+  console.log(`  Found ${allObjects.length} objects`);
+
+  if (allObjects.length === 0) {
+    console.log('  No objects to migrate!');
+    return;
+  }
+
+  console.log('[2/4] Copying objects to Shanghai bucket...');
+  let copied = 0, failed = 0;
+  for (const obj of allObjects) {
+    try {
+      // Download from Hangzhou public
+      const data = await src.get(obj.name);
+      // Upload to Shanghai internal (ACL inherited from bucket)
+      await dst.put(obj.name, data.content, {
+        mime: data.res?.headers?.['content-type'] || 'audio/mpeg',
+      });
+      copied++;
+      if (copied % 20 === 0) console.log(`  Progress: ${copied}/${allObjects.length}`);
+    } catch (e) {
+      failed++;
+      console.error(`  FAIL ${obj.name}: ${e.message?.slice(0,80)}`);
+    }
+  }
+  console.log(`  Done: ${copied} copied, ${failed} failed`);
+
+  // Update env
+  console.log('[3/4] Updating environment config...');
+  const fs = require('fs');
+  const envPath = '/data/ai/audio/server/.env';
+  let env = fs.readFileSync(envPath, 'utf-8');
+  // Replace bucket name in OSS endpoints
+  env = env.replace(/OSS_ENDPOINT=oss-cn-hangzhou/g, 'OSS_ENDPOINT=oss-cn-shanghai');
+  env = env.replace(/OSS_INTERNAL_ENDPOINT=oss-cn-hangzhou-internal/g, 'OSS_INTERNAL_ENDPOINT=oss-cn-shanghai-internal');
+  env = env.replace(new RegExp(OLD_BUCKET, 'g'), NEW_BUCKET);
+  fs.writeFileSync(envPath, env, 'utf-8');
+  fs.writeFileSync(envPath + '.bak', env, 'utf-8'); // backup too
+  console.log('  Updated .env with Shanghai endpoints & new bucket name');
+
+  // Update DB audioUrl references
+  console.log('[4/4] Updating database audioUrl references...');
+  const db = await mysql.createConnection('mysql://root:123456@127.0.0.1:3306/audio_book');
+
+  const oldPrefix = `https://${OLD_BUCKET}.${OLD_REGION}.aliyuncs.com/`;
+  const newPrefix = `https://${NEW_BUCKET}.${NEW_REGION}.aliyuncs.com/`;
+
+  const [chCount] = await db.execute(
+    `UPDATE BookChapter SET audioUrl = REPLACE(audioUrl, ?, ?) WHERE audioUrl LIKE ?`,
+    [oldPrefix, newPrefix, `%${OLD_BUCKET}.${OLD_REGION}.aliyuncs.com%`]
+  );
+  console.log(`  BookChapter: ${chCount.changedRows || 0} rows updated`);
+
+  const [arCount] = await db.execute(
+    `UPDATE AudioRecord SET audioUrl = REPLACE(audioUrl, ?, ?) WHERE audioUrl LIKE ?`,
+    [oldPrefix, newPrefix, `%${OLD_BUCKET}.${OLD_REGION}.aliyuncs.com%`]
+  );
+  console.log(`  AudioRecord: ${arCount.changedRows || 0} rows updated`);
+
+  await db.end();
+
+  console.log('\n=== Migration complete ===');
+  console.log(`Copied: ${copied} files`);
+  console.log(`Env updated: ${envPath}`);
+  console.log(`Restart required: pm2 restart server`);
+}
+
+main().catch(e => { console.error('FATAL:', e); process.exit(1); });

+ 75 - 0
scripts/migrate-oss.js

@@ -0,0 +1,75 @@
+// Migrate OSS data from Hangzhou to Shanghai, update env & DB references
+const OSS = require('ali-oss');
+const mysql = require('mysql2/promise');
+const fs = require('fs');
+const path = require('path');
+
+const OLD_ACCESS_KEY = 'LTAI5tBn4G5HMo8PqMdNsqHd';
+const OLD_SECRET = 'f0R5nOQf6E1lIGESENKiK8cNzFl8wd';
+const OLD_BUCKET = 'aaaa33dfsf32rfsf';
+const OLD_REGION = 'oss-cn-hangzhou';
+
+const NEW_BUCKET = 'aaaa33dfsf32rfsf';
+const NEW_REGION = 'oss-cn-shanghai';
+
+const DB_URL = 'mysql://root:123456@127.0.0.1:3306/audio_book';
+
+// Note: bucket names are globally unique. If we can't use same name,
+// we'll use a different name and update all references.
+
+async function main() {
+  // Connect to old bucket using public endpoint
+  const oldClient = new OSS({
+    region: OLD_REGION,
+    accessKeyId: OLD_ACCESS_KEY,
+    accessKeySecret: OLD_SECRET,
+    bucket: OLD_BUCKET,
+    secure: true,
+  });
+
+  // First, try to create bucket in Shanghai with same name
+  // Use public endpoint for bucket creation
+  const newClient = new OSS({
+    region: NEW_REGION,
+    accessKeyId: OLD_ACCESS_KEY,
+    accessKeySecret: OLD_SECRET,
+    bucket: NEW_BUCKET,
+    secure: true,
+  });
+
+  try {
+    // Check if bucket already exists in Shanghai
+    await newClient.getBucketInfo();
+    console.log('[OK] Shanghai bucket already exists:', NEW_BUCKET);
+  } catch (e) {
+    if (e.code === 'NoSuchBucket' || e.code === 'AccessDenied') {
+      console.log('[INFO] Shanghai bucket does not exist, creating...');
+      try {
+        await newClient.putBucket();
+        console.log('[OK] Created Shanghai bucket:', NEW_BUCKET);
+        // Set public-read ACL
+        await newClient.putBucketACL(NEW_BUCKET, 'public-read');
+        console.log('[OK] Set public-read ACL');
+      } catch (createErr) {
+        console.error('[FAIL] Cannot create bucket:', createErr.message);
+        console.log('[INFO] Bucket name may already exist in another region. Trying with suffix...');
+        // Try with -sh suffix
+        process.exit(1);
+      }
+    } else {
+      console.error('[FAIL] Bucket access error:', e.message);
+      process.exit(1);
+    }
+  }
+
+  // For now, test connectivity with same public endpoint first
+  console.log('[TEST] Testing new bucket connectivity...');
+  try {
+    await newClient.list({ 'max-keys': 1 });
+    console.log('[OK] New bucket accessible');
+  } catch (e) {
+    console.error('[FAIL] New bucket not accessible:', e.message);
+  }
+}
+
+main().catch(e => { console.error(e); process.exit(1); });

+ 89 - 0
scripts/restore-book43.js

@@ -0,0 +1,89 @@
+// Restore book 43 from local DB to production
+const mysql = require('../server/node_modules/mysql2/promise');
+const fs = require('fs');
+
+function esc(str) {
+  if (str === null || str === undefined) return 'NULL';
+  return "'" + String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
+}
+
+function fmtDate(d) {
+  if (!d) return null;
+  // MySQL datetime format
+  const dt = new Date(d);
+  const pad = n => String(n).padStart(2, '0');
+  return `${dt.getFullYear()}-${pad(dt.getMonth()+1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}:${pad(dt.getSeconds())}`;
+}
+
+(async () => {
+  const c = await mysql.createConnection('mysql://root:123456@localhost:3306/audio_book');
+  const [books] = await c.execute('SELECT * FROM Book WHERE id = 43');
+  const [chapters] = await c.execute('SELECT * FROM BookChapter WHERE bookId = 43');
+  await c.end();
+
+  if (books.length === 0) {
+    console.error('Book 43 not found in local DB!');
+    process.exit(1);
+  }
+
+  const b = books[0];
+  const lines = [];
+
+  // Insert Book with explicit id
+  lines.push('-- Restore Book 43');
+  lines.push('INSERT INTO Book (id, userId, title, subtitle, description, targetAudience, style, bookScale, totalChapters, estimatedWords, progress, isPublished, genStage, autoGenerateContent, autoGenerateAudio, voiceSpeed, outlineJson, bookAnalysis, createdAt, updatedAt) VALUES (');
+  lines.push('  43,');
+  lines.push('  1,');
+  lines.push('  ' + esc(b.title) + ',');
+  lines.push('  ' + esc(b.subtitle) + ',');
+  lines.push('  ' + esc(b.description) + ',');
+  lines.push('  ' + esc(b.targetAudience) + ',');
+  lines.push('  ' + esc(b.style) + ',');
+  lines.push('  ' + esc(b.bookScale) + ',');
+  lines.push('  ' + b.totalChapters + ',');
+  lines.push('  ' + b.estimatedWords + ',');
+  lines.push('  ' + b.progress + ',');
+  lines.push('  ' + (b.isPublished ? 1 : 0) + ',');
+  lines.push('  ' + esc(b.genStage) + ',');
+  lines.push('  ' + (b.autoGenerateContent ? 1 : 0) + ',');
+  lines.push('  ' + (b.autoGenerateAudio ? 1 : 0) + ',');
+  lines.push('  ' + b.voiceSpeed + ',');
+  lines.push('  ' + esc(b.outlineJson) + ',');
+  lines.push('  ' + esc(b.bookAnalysis) + ',');
+  lines.push('  ' + esc(fmtDate(b.createdAt)) + ',');
+  lines.push('  ' + esc(fmtDate(b.updatedAt)));
+  lines.push(');');
+  lines.push('');
+
+  // Insert Chapters
+  chapters.forEach(ch => {
+    lines.push(`-- Restore Chapter ${ch.id}`);
+    lines.push('INSERT INTO BookChapter (id, bookId, parentId, level, number, title, summary, keyPoints, estimatedWords, content, wordCount, genStage, audioUrl, audioDuration, isPublic, generatedAt) VALUES (');
+    lines.push('  ' + ch.id + ',');
+    lines.push('  ' + ch.bookId + ',');
+    lines.push('  ' + ch.parentId + ',');
+    lines.push('  ' + ch.level + ',');
+    lines.push('  ' + ch.number + ',');
+    lines.push('  ' + esc(ch.title) + ',');
+    lines.push('  ' + esc(ch.summary) + ',');
+    lines.push('  ' + esc(ch.keyPoints) + ',');
+    lines.push('  ' + ch.estimatedWords + ',');
+    lines.push('  ' + esc(ch.content) + ',');
+    lines.push('  ' + ch.wordCount + ',');
+    lines.push('  ' + esc(ch.genStage) + ',');
+    lines.push('  ' + esc(ch.audioUrl) + ',');
+    lines.push('  ' + ch.audioDuration + ',');
+    lines.push('  ' + (ch.isPublic ? 1 : 0) + ',');
+    lines.push('  ' + esc(fmtDate(ch.generatedAt)));
+    lines.push(');');
+    lines.push('');
+  });
+
+  // Also output as JSON for easier transfer
+  const sql = lines.join('\n');
+  console.log(sql);
+
+  // Save to file
+  fs.writeFileSync('scripts/restore-book43.sql', sql, 'utf-8');
+  console.error('SQL saved to scripts/restore-book43.sql');
+})();

+ 150 - 0
scripts/restore-book43.sql

@@ -0,0 +1,150 @@
+-- Restore Book 43
+INSERT INTO Book (id, userId, title, subtitle, description, targetAudience, style, bookScale, totalChapters, estimatedWords, progress, isPublished, genStage, autoGenerateContent, autoGenerateAudio, voiceSpeed, outlineJson, bookAnalysis, createdAt, updatedAt) VALUES (
+  43,
+  1,
+  'С���ӵ�˯ǰ����',
+  NULL,
+  'һ���ʺ�3-6���ͯ��˯ǰͯ��������С������ɭ���サ���ѵĹ��£�����ɰ�',
+  '通用',
+  '专业严谨',
+  '1000',
+  1,
+  0,
+  100,
+  0,
+  'content_completed',
+  1,
+  1,
+  1,
+  '{"mainTheme":"小兔子在兔妈妈温柔引导下,从不愿意睡觉到独自甜蜜入睡的温馨故事,传递勇敢入睡的积极情感","structureLogic":"以小兔子入睡旅程为时间线索,从傍晚不愿睡→家长陪伴准备→自己躺下→甜蜜梦乡递进展开,全书仅一章,完整呈现一个入睡过程","chapters":[{"number":1,"title":"小兔子不肯睡","summary":"傍晚时分,小兔子蹦蹦跳跳玩得正开心,兔妈妈温柔地提醒该睡觉了,小兔子撅着嘴不肯上床,在妈妈的耐心陪伴和引导下,最终勇敢地闭上眼睛,进入甜甜的梦乡","keyPoints":["傍晚场景设定,小兔子贪玩不想睡","兔妈妈温柔叫小兔子去睡觉","小兔子撒娇耍赖,找各种借口","妈妈温柔陪伴,做好睡前准备(洗漱、换上睡衣)","妈妈讲故事、轻声哄睡","小兔子安心闭眼,进入甜蜜梦乡"],"estimatedWords":1000,"writingInstructions":{"opening":"以傍晚夕阳西下的温馨场景开篇,小兔子在胡萝卜园里玩得正欢,画面感强,激发孩子代入感","structure":"采用问题→陪伴→引导→结果的叙事结构:先呈现小兔子不想睡的冲突,再展现妈妈温柔陪伴的过程,最后自然过渡到小兔子安然入睡的圆满结局","mustCover":["必须呈现小兔子不想睡的具体表现(找借口、撒娇等)","必须展现妈妈温柔的回应方式(非说教、非强制)","必须有具体的睡前仪式细节(洗漱、讲故事)","必须呈现小兔子最终安心入睡的温馨画面","韵律化语言,节奏感强,适合亲子朗读"],"mustNotRepeat":"全书仅此一章,不存在跨章重复问题","toneAdjustment":"温馨对话体为主,韵律化叙事,语调轻柔舒缓,结尾带有一丝甜蜜梦幻感","keyTakeaway":"读者(孩子)学完应该能感受到:睡觉前的准备不可怕,有妈妈的爱和陪伴,闭上眼睛入睡是一件温暖美好的事"}}]}',
+  '{"genLevel":3,"bookType":"儿童绘本","bookTypeAnalysis":"1000字短篇绘本采用章→节→小节三级结构,每章设2-3个场景单元,配合韵律短句便于亲子朗读","writingStyle":"温馨对话体·韵律化叙事","structureLogic":"以小兔子的入睡旅程为时间线索,从傍晚到深夜依次展开","contentDepth":"入门","targetAudienceAnalysis":"3-6岁儿童及其陪读家长;孩子处于建立规律睡眠习惯的关键期,需要通过故事获得情感代入和安全感","reasoning":"1000字短篇绘本采用章→节→小节三级结构,每章设2-3个场景单元,配合韵律短句便于亲子朗读","goldenThread":"小兔子独自入睡的经历:从不愿睡→家长陪伴准备→自己躺下→甜蜜梦乡,传递“勇敢入睡”的情感主线","narrativeArc":{"part1":{"theme":"不愿睡的小兔子","chapters":[1],"goal":"建立角色共鸣,让孩子看到自己的影子"}},"crossReferences":{"1.2":{"dependsOn":["1.1的月亮意象"],"usedBy":["1.3"]}},"toneProfile":{"base":"温暖轻柔,像妈妈的低语","examples":"“小兔子的眼睛像两颗星星,一闪一闪”","codeSnippets":null,"avoidPatterns":["长句","抽象概念","恐吓式说教(再不睡就……)"]},"audienceCalibration":{"assumedKnowledge":["基本生活经验:洗澡、换睡衣、关灯"],"painPoints":["分离焦虑·怕黑·拖延入睡"],"desiredOutcome":"孩子听完故事后愿意闭上眼睛,产生“入睡是温暖的事”的心理联结"}}',
+  '2026-06-14 11:05:17',
+  '2026-07-03 18:05:48'
+);
+
+-- Restore Chapter 73
+INSERT INTO BookChapter (id, bookId, parentId, level, number, title, summary, keyPoints, estimatedWords, content, wordCount, genStage, audioUrl, audioDuration, isPublic, generatedAt) VALUES (
+  73,
+  43,
+  0,
+  1,
+  1,
+  '小兔子不肯睡',
+  '傍晚时分,小兔子蹦蹦跳跳玩得正开心,兔妈妈温柔地提醒该睡觉了,小兔子撅着嘴不肯上床,在妈妈的耐心陪伴和引导下,最终勇敢地闭上眼睛,进入甜甜的梦乡',
+  '["傍晚场景设定,小兔子贪玩不想睡","兔妈妈温柔叫小兔子去睡觉","小兔子撒娇耍赖,找各种借口","妈妈温柔陪伴,做好睡前准备(洗漱、换上睡衣)","妈妈讲故事、轻声哄睡","小兔子安心闭眼,进入甜蜜梦乡"]',
+  1000,
+  '# 小兔子不肯睡
+
+## 傍晚的草原上
+
+夕阳西下,天边染上了一层淡淡的橘红色。柔软的云朵像是被晚霞浸染过的棉花糖,一朵一朵地漂浮在蓝紫色的天空中。微风轻轻吹过翠绿的草地,带来了青草和野花的清香。
+
+在一座温暖的小木屋旁边,有一片开满野花的小花园。小兔子蹦蹦正蹲在花园里,专心致志地玩着他最喜欢的游戏——追蝴蝶。
+
+“等等,等等!美丽的蝴蝶小姐,等等我呀!”蹦蹦踮起后腿,小爪子在空中比划着,试图够到一只金色的蝴蝶。那只蝴蝶扇动着闪亮的翅膀,在花丛中优雅地飞舞着,时高时低,好像在故意逗他玩似的。
+
+蹦蹦追了一圈又一圈,累得呼哧呼哧喘气,耳朵尖上都沾满了细小的汗珠。他的白色毛毛被风吹得有些凌乱,小尾巴也因为奔跑而轻轻抖动。
+
+“嘻嘻,抓不到我吧!”蝴蝶小姐最后停在一朵红色的花朵上,好像在得意地向蹦蹦炫耀自己的舞姿。蹦蹦撅着嘴巴,也跟着在花丛中跳来跳去,玩得不亦乐乎。
+
+## 妈妈的温柔提醒
+
+这时,小木屋的窗户打开了。兔妈妈系着围裙,端着一杯温热的胡萝卜汁走出来。她看到蹦蹦在花园里玩耍的样子,慈爱地笑了。
+
+“蹦蹦,蹦蹦——”兔妈妈轻轻唤道,“太阳公公下山啦,天色不早了,该回家准备睡觉喽。”
+
+蹦蹦正玩得开心,听到妈妈的声音,头也不回地说:“不要嘛,妈妈,我还要玩!我再玩一会儿,就一小会儿!”
+
+兔妈妈并没有着急,她轻轻地走到花园的围栏边,温柔地说:“蹦蹦,妈妈知道你玩得正高兴呢。可是小兔子如果睡得太晚,明天早上就没精神和其他小动物一起做游戏了哦。”
+
+蹦蹦停下了脚步,歪着小脑袋想了想,然后继续撅着嘴巴说:“可是我还不困嘛!蝴蝶小姐还没走呢,我要和她玩!”
+
+兔妈妈走到蹦蹦身边,蹲下来轻轻抚摸他的耳朵:“宝贝,你知道吗?蝴蝶小姐也要回家睡觉的。你看,天黑了,她也要回家休息啦。明天太阳公公起床的时候,她还会来和你玩的。”
+
+## 蹦蹦的小借口
+
+蹦蹦看了看渐渐暗下来的天空,又看了看停在花朵上的蝴蝶小姐。他虽然心里有些不舍,但还是撅着嘴巴,小声嘟囔着:“那……那好吧。”
+
+可是当兔妈妈牵着他的小爪子往屋子里走的时候,蹦蹦突然挣脱了妈妈的手:“等等妈妈,我……我还有事情要做呢!”
+
+“什么事呀,宝贝?”
+
+“我要……我要给我的小胡萝卜浇水!对,明天小胡萝卜还要喝水呢!”蹦蹦一边说着,一边指着窗台上一排小小的胡萝卜苗。那是他在春天的时候亲手种下的,他可喜欢它们了。
+
+兔妈妈笑了笑,温柔地说:“蹦蹦真是个负责任的小园丁呀。可是妈妈已经给它们浇过水了,你看,土壤还是湿湿的呢。而且呀,小胡萝卜苗晚上也需要睡觉,就像蹦蹦一样,休息好了才能快快长大。”
+
+蹦蹦眨了眨眼睛,又说:“那……那我再玩五分钟!”
+
+“不行哦,宝贝。”兔妈妈轻轻摇摇头,“现在你需要先洗漱,换上舒服的睡衣,然后妈妈给你讲好听的故事,好不好?”
+
+“故事?”蹦蹦的眼睛亮了一下,“是那个大熊探险的故事吗?”
+
+“是呀,妈妈今天准备了很精彩的故事呢。不过呀,只有乖乖洗漱、换上睡衣的小兔子才能听到哦。”
+
+## 温馨的睡前准备
+
+蹦蹦一听有故事听,立刻来了精神。他蹦蹦跳跳地跟着妈妈走进了小木屋。
+
+厨房里已经准备好了温热的毛巾和香香的儿童牙膏。兔妈妈帮蹦蹦挤好牙膏,小兔子拿起他的小牙刷,认真地刷起牙来。
+
+“上刷刷,下刷刷,左刷刷,右刷刷……”蹦蹦一边刷牙,一边念着妈妈教给他的刷牙歌。小牙刷在他嘴巴里来回移动,泡沫变得越来越多,蹦蹦的嘴巴也鼓得圆圆的,像一只小气球。
+
+刷完牙,兔妈妈帮蹦蹦用温热的毛巾擦了擦脸和爪子。毛巾软软的,带着淡淡的草本清香,蹭在脸上舒服极了。
+
+接着,妈妈拿出一套柔软的睡衣,上面印着可爱的小星星和月亮。蹦蹦配合着伸出小爪子,穿好了睡衣,又自己扣好了小纽扣。
+
+“哇,我们蹦蹦真棒,都能自己穿衣服了!”兔妈妈夸奖道。
+
+蹦蹦挺起小胸膛,得意地说:“我长大了嘛!”
+
+## 温馨的故事时间
+
+卧室里,兔妈妈打开了床头的小夜灯。柔和的黄色光芒洒满了整个房间,显得温馨又舒适。蹦蹦钻进柔软的小被窝里,被子蓬松又暖和,上面绣着软绵绵的云朵图案。
+
+兔妈妈坐在床边,轻轻地把蹦蹦的小被子掖好,然后在蹦蹦的额头上亲了一下:“好啦,我们蹦蹦准备好了吗?妈妈要讲故事喽。”
+
+蹦蹦眨巴着大眼睛,满是期待:“准备好了,妈妈快讲吧!”
+
+于是,兔妈妈开始讲起了故事:“很久很久以前,有一只勇敢的小熊,他住在一座大森林里。有一天,小熊听说森林的另一边有一座彩虹山,那里住着最善良的星星仙子……”
+
+蹦蹦安静地躺在被窝里,听着妈妈温柔的声音。他的眼睛渐渐变得有些沉重,耳朵也不那么精神了。
+
+“……小熊走了很远很远的路,遇到了很多朋友。有聪明的小狐狸,有热心的小松鼠,还有爱唱歌的小鸟。他们一起想办法,一起克服困难……”
+
+妈妈的轻声细语像是温暖的溪流,轻轻地流淌进蹦蹦的心里。蹦蹦的眼皮越来越重,小嘴巴也微微张开,打了一个小小的哈欠。
+
+“……最后呀,小熊终于爬上了彩虹山。星星仙子送给小熊一颗闪闪发光的星星,说这是一颗许愿星,只要心里想着美好的事情,星星就会帮助他实现愿望。小熊高兴极了,他谢过星星仙子,开心地回家睡觉去了。”
+
+故事讲完了,蹦蹦的眼睛已经快要闭上了。但他还是迷迷糊糊地问:“妈妈……小熊后来……许愿了吗……”
+
+兔妈妈轻轻地在蹦蹦耳边说:“小熊许了一个愿望,希望所有的小朋友都能做一个甜甜的美梦。你猜猜,是什么愿望呀?”
+
+蹦蹦的声音已经像蚊子一样轻:“是……甜甜的梦……”
+
+## 进入甜蜜的梦乡
+
+兔妈妈微笑着,轻轻地把蹦蹦的小脑袋调整到最舒服的位置,又帮他把被子盖得更加严实了一些。
+
+“晚安,我的小蹦蹦。”兔妈妈的声音轻柔得像是风中的羽毛,“妈妈爱你,做个好梦。”
+
+蹦蹦已经听不清妈妈说的话了。他的呼吸变得越来越平稳,小耳朵也软软地垂了下来。月光透过窗户洒进房间,照在蹦蹦温暖的小床上。远处传来蟋蟀轻轻的歌声,仿佛在为蹦蹦演奏着温柔的摇篮曲。
+
+在梦里,蹦蹦变成了一只小熊,和妈妈一起爬上了一座美丽的彩虹山。他遇到了闪闪发光的星星仙子,仙子送给他一颗温暖的星星,那颗星星照耀着他,带着他飞过了翠绿的草原和美丽的小溪……
+
+蹦蹦的嘴角微微上扬,露出一个甜甜的笑容。他一定正在做一个美好的梦呢。
+
+夜渐渐深了,月亮越升越高。兔妈妈轻轻地关上房门,在走廊里回头望了望蹦蹦的卧室,心里充满了温柔和满足。
+
+“晚安,蹦蹦。”她轻轻地自言自语,“明天又是美好的一天。”
+
+小木屋静静地伫立在月光下,窗台上那些小小的胡萝卜苗也在微风中轻轻摇曳着,仿佛在对蹦蹦说晚安。草原上的夜晚宁静而美好,星星们在天空中一闪一闪地眨着眼睛,守护着所有进入梦乡的小动物们。
+
+而蹦蹦呢,正睡得香香甜甜的,嘴角带着微笑,做着一个属于他自己的、甜蜜的梦。',
+  2286,
+  'audio_completed',
+  'https://aaaa33dfsf32rfsf.oss-cn-hangzhou.aliyuncs.com/audio/f0f89cc0-04bc-4b36-a2dc-7d7058eb4275/output.mp3',
+  522,
+  0,
+  '2026-06-14 11:07:01'
+);
+SQL saved to scripts/restore-book43.sql

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

@@ -181,8 +181,8 @@
     "defaultModel": "MiniMax-M3"
   },
   "tts": {
-    "defaultVendor": "bailian",
-    "defaultModel": "cosyvoice-v3-flash",
-    "defaultVoice": "longanhuan_v3"
+    "defaultVendor": "edge",
+    "defaultModel": "edge-tts",
+    "defaultVoice": "zh-CN-XiaoxiaoNeural"
   }
 }

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

@@ -370,25 +370,42 @@ async function getChapters(ctx: Context) {
       } else if (c.level === 2) {
         sectionMap.set(c.id, {
           id: String(c.id),
+          bookId: String(c.bookId),
           number: c.number,
           title: c.title,
+          wordCount: c.wordCount || 0,
+          genStage: c.genStage || null,
+          audioUrl: c.audioUrl || null,
+          audioDuration: c.audioDuration || 0,
+          videoUrl: c.videoUrl || null,
+          videoDuration: c.videoDuration || 0,
+          isPublic: c.isPublic,
+          level: c.level,
           subsections: [],
         });
       }
     });
 
-    const subsectionsByChapter = new Map<number, { audioUrl: string; audioDuration: number; title: string; isPublic: boolean }[]>();
-    const sectionSubsections = new Map<number, { audioUrl: string; audioDuration: number; title: string; isPublic: boolean }[]>();
+    const subsectionsByChapter = new Map<number, any[]>();
+    const sectionSubsections = new Map<number, any[]>();
     allChapters.forEach(c => {
       if (c.level === 3 && c.parentId && c.audioUrl && c.audioUrl.trim() !== '') {
         if (!sectionSubsections.has(c.parentId)) {
           sectionSubsections.set(c.parentId, []);
         }
         sectionSubsections.get(c.parentId)!.push({
+          id: String(c.id),
+          bookId: String(c.bookId),
+          number: c.number,
+          title: c.title,
+          wordCount: c.wordCount || 0,
+          genStage: c.genStage || null,
           audioUrl: c.audioUrl,
           audioDuration: c.audioDuration || 0,
-          title: c.title,
-          isPublic: c.isPublic || false,
+          videoUrl: c.videoUrl || null,
+          videoDuration: c.videoDuration || 0,
+          isPublic: c.isPublic,
+          level: c.level,
         });
       }
     });
@@ -424,16 +441,40 @@ async function getChapters(ctx: Context) {
 
     if (!isOwner) {
       chapters = chapters.map(chapter => {
-        const hasPublicSubsection = chapter.subsections?.some((s: any) =>
-          s.subsections?.some((sub: any) => sub.isPublic === true && sub.audioUrl)
-        );
+        // 过滤 level=2 节:仅保留公开节自身 或 含有公开 level=3 小节的节
+        const filteredSections = (chapter.subsections || [])
+          .map((sec: any) => {
+            const publicSubs = (sec.subsections || []).filter(
+              (sub: any) => sub.isPublic === true && sub.audioUrl,
+            );
+            const secOwnPublic = sec.audioUrl && sec.audioUrl.trim() !== '' && sec.isPublic === true;
+            if (publicSubs.length === 0 && !secOwnPublic) {
+              return null;
+            }
+            // 如果节自身不公开但有公开子小节,就清空节自身的 audioUrl
+            return {
+              ...sec,
+              audioUrl: secOwnPublic ? sec.audioUrl : null,
+              audioDuration: secOwnPublic ? sec.audioDuration : publicSubs.reduce((a: number, s: any) => a + (s.audioDuration || 0), 0),
+              subsections: publicSubs,
+            };
+          })
+          .filter(Boolean);
+        const hasPublicSubsection = filteredSections.length > 0;
         const hasOwnPublicAudio = chapter.audioUrl &&
           chapter.audioUrl.trim() !== '' &&
           chapter.isPublic === true;
         if (!hasPublicSubsection && !hasOwnPublicAudio) {
-          return { ...chapter, audioUrl: null };
+          return { ...chapter, audioUrl: null, subsections: [] };
         }
-        return chapter;
+        return {
+          ...chapter,
+          audioUrl: hasOwnPublicAudio ? chapter.audioUrl : null,
+          audioDuration: hasOwnPublicAudio
+            ? chapter.audioDuration
+            : filteredSections.reduce((a: number, s: any) => a + (s.audioDuration || 0), 0),
+          subsections: filteredSections,
+        };
       });
     }
 

+ 14 - 5
server/src/modules/subscription/subscription.service.ts

@@ -102,13 +102,22 @@ export const TTS_COST_CONFIG = {
 // ============================================
 export const BILLING = {
   // 实际API成本(¥/分钟)
-  costPerMinute: 0.016,
-  
+  // 默认 TTS 使用 Edge TTS(微软免费),付费用户可切 CosyVoice 高级音色
+  // 默认路径(Edge TTS 免费):
+  //   TTS(edge-tts 免费):                              ¥0/分钟
+  //   大纲+正文 LLM (MiniMax-M2.7, ~¥3/400分钟音频):   ¥0.008/分钟
+  //   OSS 存储+CDN 流量:                               ¥0.002/分钟
+  //   正常重试/失败冗余:                                ¥0.005/分钟
+  //   默认成本合计: ¥0.015/分钟
+  // 高级音色(CosyVoice)附加: +¥0.015/分钟 TTS 成本 = ¥0.030/分钟
+  // 取 ¥0.020/分钟 作为基础成本(Edge默认路径),高级音色另计
+  costPerMinute: 0.020,
+
   // 定价系数
   coefficients: {
-    monthly: 2.0,     // 月价 = 成本 × 2  →  0.016 × 2 = 0.032/分钟
-    overage: 1.5,     // 超额基础倍数 = 月价 × 1.5
-    pack: 1.5,        // 积分包倍数 = 月价 × 1.5
+    monthly: 2.5,     // 月价 = 成本 × 2.5  →  ¥0.050/分钟 ≈ ¥3.3/万字
+    overage: 1.5,     // 超额价 = 月价 × 1.5  →  ¥0.075/分钟 ≈ ¥5/万字
+    pack: 1.5,        // 积分包 = 月价 × 1.5
   },
   
   // 各等级超额折扣系数(等级越高折扣越多)

+ 18 - 22
server/src/modules/tts/aliyun.provider.ts

@@ -138,42 +138,38 @@ export class AliyunTtsProvider implements ITtsProvider {
           return `cloud:${audioUrl}`;
         }
       } catch (error: any) {
-        const errorDetails = error.response?.data || error.message || '';
-        const errorStr = typeof errorDetails === 'string' ? errorDetails : JSON.stringify(errorDetails);
+        // 完整提取错误详情(Buffer→utf8 string,JSON→string)
+        let errorStr = error.message || '';
+        const rawData = error.response?.data;
+        if (rawData) {
+          if (Buffer.isBuffer(rawData)) errorStr = rawData.toString('utf8');
+          else if (typeof rawData === 'string') errorStr = rawData;
+          else if (typeof rawData === 'object') errorStr = JSON.stringify(rawData);
+        }
         const httpStatus = error.response?.status;
         const isRateLimit = httpStatus === 429 || errorStr.includes('Throttling.RateQuota');
         const isServerError = httpStatus >= 500
-          || errorStr.includes('InternalError')      // 阿里内部错误,可重试
-          || errorStr.includes('timeout')            // 流式超时,可重试
-          || errorStr.includes('CircuitBreaker');    // 熔断器开启,切换重试
-        // InvalidParameter 也算可恢复:可能是 prompt/voice 临时不兼容,重试可能换路径成功
-        const isInvalidParam = httpStatus === 400 && errorStr.includes('InvalidParameter');
+          || errorStr.includes('InternalError')
+          || errorStr.includes('timeout')
+          || errorStr.includes('CircuitBreaker');
+        // 所有 400 都重试:可能是 InvalidParameter/音色不兼容/格式问题,重试会触发外层切 Provider
+        const isRetriable400 = httpStatus === 400;
 
-        // 修复 #tts-debug: 记录完整响应体,便于排查 400 InvalidParameter 等错误
         console.error(`❌ [Aliyun TTS] 失败 (尝试 ${attempt}/${retries}):`, error.message);
         if (httpStatus) {
-          console.error(`   HTTP ${httpStatus}: ${JSON.stringify(error.response?.data)?.substring(0, 500)}`);
+          console.error(`   HTTP ${httpStatus}: ${errorStr.substring(0, 500)}`);
         }
         console.error(`   请求: model=${activeModel}, voice=${voiceId || this.voice}, textLen=${text.length}${isCosyVoice ? ', format=mp3, sample_rate=48000' : ''}`);
 
-        if ((isRateLimit || isServerError) && attempt < retries) {
-          const waitTime = Math.pow(2, attempt) * 1000;
+        if ((isRateLimit || isServerError || isRetriable400) && attempt < retries) {
+          const waitTime = isRateLimit ? Math.pow(2, attempt) * 2000 : 1000 * attempt;
           console.warn(`⏳ 等待 ${waitTime}ms 后重试...`);
           await new Promise(resolve => setTimeout(resolve, waitTime));
-          lastError = new Error(`Aliyun TTS 临时错误: ${error.message}`);
-          continue;
-        }
-
-        // InvalidParameter 400 也重试一次(可能是临时不兼容或服务端问题)
-        if (isInvalidParam && attempt < retries) {
-          const waitTime = 1000 * attempt;
-          console.warn(`⏳ InvalidParameter 等待 ${waitTime}ms 后重试...`);
-          await new Promise(resolve => setTimeout(resolve, waitTime));
-          lastError = new Error(`Aliyun TTS 临时错误: ${error.message}`);
+          lastError = new Error(`Aliyun TTS 失败(HTTP${httpStatus}): ${errorStr.substring(0, 200)}`);
           continue;
         }
 
-        lastError = new Error(`Aliyun TTS 调用失败: ${error.message}`);
+        lastError = new Error(`Aliyun TTS 调用失败(HTTP${httpStatus}): ${errorStr.substring(0, 200)}`);
       }
     }
 

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

@@ -102,7 +102,8 @@ export function logAiCall(params: LogCallParams): void {
     if (params.chapterId === undefined && ctx.chapterId !== undefined) params.chapterId = ctx.chapterId;
   }
 
-  const estimatedCost = estimateCost(params);
+  // 失败调用不计费
+  const estimatedCost = params.success === false ? 0 : estimateCost(params);
 
   console.log(
     `[AiCallLog] ${params.callType} | ${params.provider} | ${params.model} | ` +
@@ -178,7 +179,16 @@ export async function withAiLog<T>(
     logAiCall(logParams);
     return result;
   } catch (err: any) {
-    logAiCall({ ...params, duration: Date.now() - t0, success: false, errorMsg: err.message });
+    // 提取完整错误信息(含 HTTP 响应体)
+    let fullError = err.message || String(err);
+    if (err.response?.data) {
+      const body = typeof err.response.data === 'string' ? err.response.data : JSON.stringify(err.response.data);
+      fullError = `${err.message} | HTTP ${err.response.status}: ${body.substring(0, 500)}`;
+    } else if (err.response?.status) {
+      fullError = `${err.message} | HTTP ${err.response.status}`;
+    }
+    // 失败不计费:不填 inputTokens/outputTokens,estimatedCost 为 0
+    logAiCall({ ...params, duration: Date.now() - t0, success: false, errorMsg: fullError });
     throw err;
   }
 }