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

fix(越权批3): langgraph-controller ~34 个端点加归属校验

用 assertBookAccess/assertChapterAccess 给所有 /books/:id/* 端点加归属校验:
- 只读接口(progress/video-status/audio-status/failed-chapters/full-content/
  workflow/interactive-plan/interactive-outline): assertBookAccess read
- 写/删/生成接口(publish/delete/generate/audio/cancel/resume/outline/chapters/
  foreword/afterword/videos/merge-audio/merge-video/interactive-*/batch-*): write
- 章节级接口(retry-chapter/chapters/:chapterId/content|regenerate|regenerate-audio|
  video): assertChapterAccess,校验章节属于该书且书属于本人
- DELETE/audio/cancel/resume 等原本缺 optionalAuth 的补上中间件
- POST /debug/trigger-audio 原本完全无认证,补 optionalAuth+归属校验

非本人且未公开的资源一律返回 404,与列表/详情隔离规则一致.
MyFramework User 2 месяцев назад
Родитель
Сommit
3cbc082824
1 измененных файлов с 272 добавлено и 30 удалено
  1. 272 30
      server/src/modules/book-generator/langgraph-controller.ts

+ 272 - 30
server/src/modules/book-generator/langgraph-controller.ts

@@ -7,6 +7,7 @@ import Router from '@koa/router';
 import { Context } from 'koa';
 import { langGraphGenerator, getScaleConfig, resolveGenLevel, mapBookTypeToGenLevel } from './index';
 import { bookStore, cancelAudioGeneration } from './book-generator.store';
+import { assertBookAccess, assertChapterAccess } from './access-control';
 import { prisma } from '../../models';
 import { estimateBookWords, estimateAudioMinutesFromWords, checkBookGenerationQuota, checkAudioQuota, atomicReserveQuota, releaseQuota, AUDIO_BILLING_CONFIG } from '../subscription/subscription.service';
 import { optionalAuth } from '../../middleware/auth';
@@ -793,6 +794,13 @@ router.get('/my-books', optionalAuth, async (ctx: Context) => {
 router.put('/books/:id/publish', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const newStatus = await bookStore.togglePublish(bookId);
     ctx.body = { 
       code: 0, 
@@ -850,9 +858,16 @@ router.get('/books/:id', optionalAuth, async (ctx: Context) => {
  * GET /api/book-generator/langgraph/books/:id/progress
  * 获取书籍生成进度
  */
-router.get('/books/:id/progress', async (ctx: Context) => {
+router.get('/books/:id/progress', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
 
     if (!book) {
@@ -902,9 +917,16 @@ router.get('/books/:id/progress', async (ctx: Context) => {
  * DELETE /api/book-generator/langgraph/books/:id
  * 删除书籍
  */
-router.delete('/books/:id', async (ctx: Context) => {
+router.delete('/books/:id', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     await bookStore.delete(bookId);
     ctx.body = { code: 0, message: '删除成功' };
   } catch (error) {
@@ -921,8 +943,15 @@ router.delete('/books/:id', async (ctx: Context) => {
 router.post('/books/:id/generate', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     console.log(`[LangGraph Generate] 收到请求 bookId=${bookId}`);
-    
+
     const body = (ctx.request.body || {}) as { bookScale?: string; genLevel?: number };
     const book = await bookStore.getById(bookId);
     console.log(`[LangGraph Generate] book对象:`, book ? '存在' : 'null');
@@ -975,22 +1004,22 @@ router.post('/books/:id/generate', optionalAuth, async (ctx: Context) => {
     const genLevel = resolveGenLevel(bookScale, body.genLevel, book.description);
 
     // ========== 生成前配额检查 ==========
-    const userId = ctx.state.user?.userId ? parseInt(ctx.state.user.userId as string) : (book.userId || parseInt(TEST_USER_ID));
+    const generationUserId = ctx.state.user?.userId ? parseInt(ctx.state.user.userId as string) : (book.userId || parseInt(TEST_USER_ID));
     const scaleConfig = getScaleConfig(bookScale);
     const totalWords = scaleConfig.totalWords;
-    
+
     // 风控预估
     const estimatedCost = AUDIO_BILLING_CONFIG.pricing.monthly * estimateAudioMinutesFromWords(totalWords) + 0.05;
-    const quotaOk = await atomicReserveQuota(userId, estimatedCost);
+    const quotaOk = await atomicReserveQuota(generationUserId, estimatedCost);
     if (!quotaOk) {
       ctx.status = 403;
       ctx.body = { code: 1, message: '月度额度不足,请升级套餐' };
       return;
     }
     
-    const audioQuota = await checkAudioQuota(userId, totalWords);
+    const audioQuota = await checkAudioQuota(generationUserId, totalWords);
     if (!audioQuota.allowed) {
-      await releaseQuota(userId, estimatedCost);
+      await releaseQuota(generationUserId, estimatedCost);
       ctx.status = 403;
       ctx.body = {
         code: 1,
@@ -1026,7 +1055,7 @@ router.post('/books/:id/generate', optionalAuth, async (ctx: Context) => {
     await bookStore.deleteAllChapters(bookId);
     await bookStore.update(bookId, { genStage: 'outlining', totalChapters: 0, progress: 0 });
 
-    langGraphGenerator.generate(bookId, book.description, bookScale, genLevel, userId, false)
+    langGraphGenerator.generate(bookId, book.description, bookScale, genLevel, generationUserId, false)
       .then(async () => {
         console.log(`[LangGraph] 生成完成: bookId=${bookId}`);
         // 根据实际章节状态更新书籍 genStage
@@ -1068,9 +1097,16 @@ router.post('/books/:id/generate', optionalAuth, async (ctx: Context) => {
  * POST /api/book-generator/langgraph/books/:id/audio
  * 批量生成书籍所有小节的音频
  */
-router.post('/books/:id/audio', async (ctx: Context) => {
+router.post('/books/:id/audio', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const { voiceId = 'longyingling_v3' } = ctx.request.body as { voiceId?: string };
 
     const book = await bookStore.getById(bookId);
@@ -1144,9 +1180,15 @@ router.post('/books/:id/audio', async (ctx: Context) => {
  * POST /api/book-generator/langgraph/books/:id/audio/cancel
  * 取消书籍所有章节的音频生成
  */
-router.post('/books/:id/audio/cancel', async (ctx: Context) => {
+router.post('/books/:id/audio/cancel', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
 
     const book = await bookStore.getById(bookId);
     if (!book) {
@@ -1184,9 +1226,16 @@ router.post('/books/:id/audio/cancel', async (ctx: Context) => {
  * GET /api/book-generator/langgraph/books/:id/video-status
  * 获取书籍视频生成状态(用于前端轮询)
  */
-router.get('/books/:id/video-status', async (ctx: Context) => {
+router.get('/books/:id/video-status', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
 
     if (!book) {
@@ -1233,9 +1282,16 @@ router.get('/books/:id/video-status', async (ctx: Context) => {
  * GET /api/book-generator/langgraph/books/:id/audio-status
  * 获取书籍音频生成状态(用于前端轮询)
  */
-router.get('/books/:id/audio-status', async (ctx: Context) => {
+router.get('/books/:id/audio-status', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
 
     if (!book) {
@@ -1282,9 +1338,16 @@ router.get('/books/:id/audio-status', async (ctx: Context) => {
  * GET /api/book-generator/langgraph/books/:id/failed-chapters
  * 获取生成失败的小节列表
  */
-router.get('/books/:id/failed-chapters', async (ctx: Context) => {
+router.get('/books/:id/failed-chapters', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
     if (!book) {
       ctx.status = 404;
@@ -1329,9 +1392,16 @@ router.get('/books/:id/failed-chapters', async (ctx: Context) => {
  * 逐个调用 generateSingleChapterContent(只补单章,不碰大纲),
  * 音频随后由 AudioScanner 兜底入队。
  */
-router.post('/books/:id/resume', async (ctx: Context) => {
+router.post('/books/:id/resume', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
     if (!book) {
       ctx.status = 404;
@@ -1422,7 +1492,7 @@ router.post('/books/:id/resume', async (ctx: Context) => {
  * POST /api/book-generator/langgraph/books/:id/retry-chapter
  * 单独重试某个失败的小节
  */
-router.post('/books/:id/retry-chapter', async (ctx: Context) => {
+router.post('/books/:id/retry-chapter', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
     const { chapterId } = ctx.request.body as { chapterId: number };
@@ -1433,6 +1503,13 @@ router.post('/books/:id/retry-chapter', async (ctx: Context) => {
       return;
     }
 
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertChapterAccess(bookId, chapterId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '章节不存在' };
+      return;
+    }
+
     // 获取所有章节查找指定的章节
     const chapters = await bookStore.getChapterTree(bookId);
     const chapter = chapters.find(c => c.id === chapterId);
@@ -1465,9 +1542,16 @@ router.post('/books/:id/retry-chapter', async (ctx: Context) => {
  * GET /api/book-generator/langgraph/books/:id/full-content
  * 获取完整书籍内容(所有章节内容合并)
  */
-router.get('/books/:id/full-content', async (ctx: Context) => {
+router.get('/books/:id/full-content', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
 
     if (!book) {
@@ -1524,9 +1608,16 @@ router.get('/books/:id/full-content', async (ctx: Context) => {
  * POST /api/book-generator/langgraph/books/:id/outline
  * 生成书籍大纲
  */
-router.post('/books/:id/outline', async (ctx: Context) => {
+router.post('/books/:id/outline', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
 
     if (!book) {
@@ -1572,9 +1663,16 @@ router.post('/books/:id/outline', async (ctx: Context) => {
  * 生成单个章节(或全部章节,取决于参数)
  * body: { chapterNumber?: number } - 如果不传chapterNumber,则生成全部
  */
-router.post('/books/:id/chapters', async (ctx: Context) => {
+router.post('/books/:id/chapters', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const body = ctx.request.body as { chapterNumber?: number };
     const book = await bookStore.getById(bookId);
 
@@ -1625,9 +1723,16 @@ router.post('/books/:id/chapters', async (ctx: Context) => {
  * POST /api/book-generator/langgraph/books/:id/foreword
  * 生成前言
  */
-router.post('/books/:id/foreword', async (ctx: Context) => {
+router.post('/books/:id/foreword', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
 
     if (!book) {
@@ -1682,9 +1787,16 @@ router.post('/books/:id/foreword', async (ctx: Context) => {
  * POST /api/book-generator/langgraph/books/:id/afterword
  * 生成后记
  */
-router.post('/books/:id/afterword', async (ctx: Context) => {
+router.post('/books/:id/afterword', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
 
     if (!book) {
@@ -1739,9 +1851,16 @@ router.post('/books/:id/afterword', async (ctx: Context) => {
  * GET /api/book-generator/langgraph/workflow/:bookId
  * 获取书籍生成工作流状态
  */
-router.get('/workflow/:bookId', async (ctx: Context) => {
+router.get('/workflow/:bookId', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.bookId as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
 
     if (!book) {
@@ -1800,6 +1919,12 @@ router.get('/books/:id/chapters/:chapterId/content', optionalAuth, async (ctx: C
   try {
     const { id: bookId, chapterId } = ctx.params as { id: string; chapterId: string };
     const chapterIdNum = parseInt(chapterId);
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertChapterAccess(bookId, chapterId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '章节不存在' };
+      return;
+    }
 
     // 获取书籍
     const book = await bookStore.getById(bookId);
@@ -1869,6 +1994,13 @@ router.post('/books/:id/chapters/:chapterId/content', optionalAuth, async (ctx:
       return;
     }
 
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertChapterAccess(bookId, chapterId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '章节不存在' };
+      return;
+    }
+
     // 获取章节
     const chapter = await prisma.bookChapter.findUnique({
       where: { id: chapterIdNum },
@@ -1922,6 +2054,13 @@ router.post('/books/:id/chapters/batch-regenerate', optionalAuth, async (ctx: Co
       return;
     }
 
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     // 获取书籍
     const book = await bookStore.getById(bookId);
     if (!book) {
@@ -1972,6 +2111,13 @@ router.post('/books/:id/chapters/batch-delete', optionalAuth, async (ctx: Contex
       return;
     }
 
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     // 获取书籍
     const book = await bookStore.getById(bookId);
     if (!book) {
@@ -2015,10 +2161,16 @@ router.post('/books/:id/chapters/batch-delete', optionalAuth, async (ctx: Contex
  * POST /api/book-generator/langgraph/books/:id/chapters/:chapterId/regenerate
  * 重新生成指定章节的内容(仅叶节点允许)
  */
-router.post('/books/:id/chapters/:chapterId/regenerate', async (ctx: Context) => {
+router.post('/books/:id/chapters/:chapterId/regenerate', optionalAuth, async (ctx: Context) => {
   try {
     const { id: bookId, chapterId } = ctx.params as { id: string; chapterId: string };
     const chapterIdNum = parseInt(chapterId);
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertChapterAccess(bookId, chapterId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '章节不存在' };
+      return;
+    }
 
     // 获取书籍
     const book = await bookStore.getById(bookId);
@@ -2077,10 +2229,16 @@ router.post('/books/:id/chapters/:chapterId/regenerate', async (ctx: Context) =>
  * POST /api/book-generator/langgraph/books/:id/chapters/:chapterId/regenerate-audio
  * 重新生成章节音频(仅叶节点)
  */
-router.post('/books/:id/chapters/:chapterId/regenerate-audio', async (ctx: Context) => {
+router.post('/books/:id/chapters/:chapterId/regenerate-audio', optionalAuth, async (ctx: Context) => {
   try {
     const { id: bookId, chapterId } = ctx.params as { id: string; chapterId: string };
     const chapterIdNum = parseInt(chapterId);
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertChapterAccess(bookId, chapterId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '章节不存在' };
+      return;
+    }
 
     // 获取书籍
     const book = await bookStore.getById(bookId);
@@ -2171,10 +2329,16 @@ router.post('/books/:id/chapters/:chapterId/regenerate-audio', async (ctx: Conte
  * POST /api/book-generator/langgraph/books/:id/chapters/:chapterId/video
  * 生成单个章节视频
  */
-router.post('/books/:id/chapters/:chapterId/video', async (ctx: Context) => {
+router.post('/books/:id/chapters/:chapterId/video', optionalAuth, async (ctx: Context) => {
   try {
     const { id: bookId, chapterId } = ctx.params as { id: string; chapterId: string };
     const chapterIdNum = parseInt(chapterId);
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertChapterAccess(bookId, chapterId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '章节不存在' };
+      return;
+    }
 
     const chapter = await prisma.bookChapter.findUnique({
       where: { id: chapterIdNum },
@@ -2242,9 +2406,16 @@ router.post('/books/:id/chapters/:chapterId/video', async (ctx: Context) => {
  * POST /api/book-generator/langgraph/books/:id/videos
  * 批量生成书籍所有章节视频
  */
-router.post('/books/:id/videos', async (ctx: Context) => {
+router.post('/books/:id/videos', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
 
     if (!book) {
@@ -2360,9 +2531,16 @@ router.post('/books/:id/videos', async (ctx: Context) => {
  * POST /api/book-generator/langgraph/books/:id/merge-audio
  * 合并叶节点音频到上级章节
  */
-router.post('/books/:id/merge-audio', async (ctx: Context) => {
+router.post('/books/:id/merge-audio', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
 
     if (!book) {
@@ -2485,9 +2663,16 @@ router.post('/books/:id/merge-audio', async (ctx: Context) => {
  * POST /api/book-generator/langgraph/books/:id/merge-video
  * 合并叶节点视频到上级章节
  */
-router.post('/books/:id/merge-video', async (ctx: Context) => {
+router.post('/books/:id/merge-video', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id as string;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await bookStore.getById(bookId);
 
     if (!book) {
@@ -2695,7 +2880,7 @@ async function mergeChapterVideos(parentChapter: any, childChapters: any[], user
  * DEBUG: 手动触发章节音频生成(强制从content_completed重新开始)
  * POST /api/book-generator/debug/trigger-audio
  */
-router.post('/debug/trigger-audio', async (ctx: Context) => {
+router.post('/debug/trigger-audio', optionalAuth, async (ctx: Context) => {
   try {
     const { chapterId } = ctx.request.body as { chapterId?: number };
     if (!chapterId) {
@@ -2704,8 +2889,16 @@ router.post('/debug/trigger-audio', async (ctx: Context) => {
       return;
     }
 
-    // 先检查chapter状态,如果是audio_generating但没有audioUrl,说明之前的生成卡住了
+    // 先查出章节所属书籍,再做归属校验
     const chapter = await prisma.bookChapter.findUnique({ where: { id: chapterId } });
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!chapter || !(await assertChapterAccess(chapter.bookId, chapterId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '章节不存在' };
+      return;
+    }
+
+    // 先检查chapter状态,如果是audio_generating但没有audioUrl,说明之前的生成卡住了
     if (chapter?.genStage === 'audio_generating' && !chapter?.audioUrl) {
       console.log(`[DEBUG] 章节${chapterId}卡在audio_generating无audioUrl,重置到content_completed重新生成`);
       await prisma.bookChapter.update({ where: { id: chapterId }, data: { genStage: 'content_completed' } });
@@ -2737,6 +2930,13 @@ router.post('/debug/trigger-audio', async (ctx: Context) => {
 router.post('/books/:id/interactive/plan', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const idNum = parseInt(bookId);
     const book = await prisma.book.findUnique({ where: { id: idNum } });
     if (!book) {
@@ -2807,6 +3007,13 @@ router.post('/books/:id/interactive/plan', optionalAuth, async (ctx: Context) =>
 router.get('/books/:id/interactive/plan', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await prisma.book.findUnique({ where: { id: parseInt(bookId) } });
     if (!book) {
       ctx.status = 404;
@@ -2865,6 +3072,13 @@ router.put('/books/:id/interactive/plan', optionalAuth, async (ctx: Context) =>
       return;
     }
 
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     await prisma.book.update({
       where: { id: parseInt(bookId) },
       data: { bookAnalysis: plan },
@@ -2885,6 +3099,13 @@ router.put('/books/:id/interactive/plan', optionalAuth, async (ctx: Context) =>
 router.post('/books/:id/interactive/outline', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const idNum = parseInt(bookId);
     const book = await prisma.book.findUnique({ where: { id: idNum } });
     if (!book) {
@@ -2953,6 +3174,13 @@ router.post('/books/:id/interactive/outline', optionalAuth, async (ctx: Context)
 router.get('/books/:id/interactive/outline', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id;
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'read'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await prisma.book.findUnique({ where: { id: parseInt(bookId) } });
     if (!book) {
       ctx.status = 404;
@@ -3009,6 +3237,13 @@ router.put('/books/:id/interactive/outline', optionalAuth, async (ctx: Context)
       return;
     }
 
+    const userId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, userId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const outlineStr = typeof outline === 'string' ? outline : JSON.stringify(outline);
 
     await prisma.book.update({
@@ -3077,6 +3312,13 @@ router.put('/books/:id/interactive/outline', optionalAuth, async (ctx: Context)
 router.post('/books/:id/interactive/generate', optionalAuth, async (ctx: Context) => {
   try {
     const bookId = ctx.params.id;
+    const accessUserId = ctx.state.user?.userId || TEST_USER_ID;
+    if (!(await assertBookAccess(bookId, accessUserId, 'write'))) {
+      ctx.status = 404;
+      ctx.body = { code: 1, message: '书籍不存在' };
+      return;
+    }
+
     const book = await prisma.book.findUnique({ where: { id: parseInt(bookId) } });
     if (!book) {
       ctx.status = 404;