|
|
@@ -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;
|