Ver Fonte

feat: 分离大纲状态和内容状态 - 支持双阶段生成管理

问题:
- 原status字段混滑大纲状态和内容状态
- 无法区分'大纲已生成但内容未生成'的叶节点
- 断点续传逻辑混乱

解决:
- 新增contentStatus字段:null/pending/generating/completed/failed
- status字段专用于大纲状态:pending/completed/failed
- 新增contentError字段存储内容生成错误
- 叶节点判断:contentStatus !== 'completed'
- 断点续传:跳过contentStatus='completed'的节点

数据库迁移:
- 添加contentStatus VARCHAR(20) NULL
- 添加contentError TEXT NULL
- 迁移名:20260422105352_add_content_status

影响文件:
- prisma/schema.prisma: 新增字段
- book-generator.store.ts: 创建时设置status='completed', contentStatus=null
- langgraph-generator.ts: 使用contentStatus判断断点续传
- 更新日志更清晰:区分'大纲完成'和'内容完成'
MyFramework User há 4 meses atrás
pai
commit
cde91dfdd7

+ 512 - 0
server/prisma/migrations/20260422105352_add_content_status/migration.sql

@@ -0,0 +1,512 @@
+-- CreateTable
+CREATE TABLE `User` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `phone` VARCHAR(191) NULL,
+    `openid` VARCHAR(191) NULL,
+    `nickname` VARCHAR(191) NOT NULL DEFAULT '用户',
+    `avatar` VARCHAR(191) NOT NULL DEFAULT '',
+    `memberLevel` INTEGER NOT NULL DEFAULT 0,
+    `memberExpireAt` DATETIME(3) NULL,
+    `dailyUsage` INTEGER NOT NULL DEFAULT 0,
+    `lastUsageDate` VARCHAR(191) NOT NULL DEFAULT '',
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+    `usedAudioMinutes` INTEGER NOT NULL DEFAULT 0,
+    `subscriptionResetDate` DATETIME(3) NULL,
+
+    UNIQUE INDEX `User_phone_key`(`phone`),
+    UNIQUE INDEX `User_openid_key`(`openid`),
+    INDEX `User_phone_idx`(`phone`),
+    INDEX `User_openid_idx`(`openid`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `Order` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `orderNo` VARCHAR(191) NOT NULL,
+    `planId` INTEGER NULL,
+    `productType` VARCHAR(191) NOT NULL,
+    `amount` DECIMAL(10, 2) NOT NULL,
+    `status` VARCHAR(191) NOT NULL DEFAULT 'pending',
+    `paymentMethod` VARCHAR(191) NULL,
+    `paymentId` VARCHAR(191) NULL,
+    `paidAt` DATETIME(3) NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    UNIQUE INDEX `Order_orderNo_key`(`orderNo`),
+    INDEX `Order_userId_createdAt_idx`(`userId`, `createdAt`),
+    INDEX `Order_orderNo_idx`(`orderNo`),
+    INDEX `Order_status_idx`(`status`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `PlayRecord` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `chapterId` INTEGER NOT NULL,
+    `progress` DOUBLE NOT NULL DEFAULT 0,
+    `duration` DOUBLE NOT NULL DEFAULT 0,
+    `updatedAt` DATETIME(3) NOT NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+    INDEX `PlayRecord_userId_idx`(`userId`),
+    INDEX `PlayRecord_chapterId_idx`(`chapterId`),
+    UNIQUE INDEX `PlayRecord_userId_chapterId_key`(`userId`, `chapterId`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `UserPreference` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `playSpeed` DOUBLE NOT NULL DEFAULT 1.0,
+    `quality` VARCHAR(191) NOT NULL DEFAULT 'standard',
+    `theme` VARCHAR(191) NOT NULL DEFAULT 'light',
+    `defaultVoiceId` VARCHAR(191) NULL DEFAULT 'cherry',
+    `defaultVolume` INTEGER NOT NULL DEFAULT 50,
+    `autoPlayNext` BOOLEAN NOT NULL DEFAULT true,
+    `wifiOnlyDownload` BOOLEAN NOT NULL DEFAULT false,
+    `updatedAt` DATETIME(3) NOT NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+    UNIQUE INDEX `UserPreference_userId_key`(`userId`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `Favorite` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `bookId` INTEGER NOT NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+    INDEX `Favorite_userId_idx`(`userId`),
+    INDEX `Favorite_bookId_idx`(`bookId`),
+    UNIQUE INDEX `Favorite_userId_bookId_key`(`userId`, `bookId`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `Comment` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `chapterId` INTEGER NOT NULL,
+    `content` TEXT NOT NULL,
+    `rating` INTEGER NOT NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+    INDEX `Comment_chapterId_idx`(`chapterId`),
+    INDEX `Comment_userId_idx`(`userId`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `Notification` (
+    `id` VARCHAR(191) NOT NULL,
+    `userId` VARCHAR(191) NOT NULL,
+    `title` VARCHAR(191) NOT NULL,
+    `content` TEXT NOT NULL,
+    `isRead` BOOLEAN NOT NULL DEFAULT false,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `Book` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NULL,
+    `title` VARCHAR(191) NOT NULL,
+    `subtitle` VARCHAR(191) NULL,
+    `description` TEXT NOT NULL,
+    `coverUrl` VARCHAR(191) NULL,
+    `targetAudience` VARCHAR(191) NOT NULL DEFAULT '通用',
+    `style` VARCHAR(191) NOT NULL DEFAULT '专业严谨',
+    `bookScale` VARCHAR(191) NOT NULL DEFAULT '标准教程',
+    `totalChapters` INTEGER NOT NULL DEFAULT 10,
+    `estimatedWords` INTEGER NOT NULL DEFAULT 0,
+    `status` VARCHAR(191) NOT NULL DEFAULT 'draft',
+    `progress` INTEGER NOT NULL DEFAULT 0,
+    `isPublished` BOOLEAN NOT NULL DEFAULT false,
+    `outlineJson` LONGTEXT NULL,
+    `foreword` TEXT NULL,
+    `afterword` TEXT NULL,
+    `errorMsg` TEXT NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    INDEX `Book_userId_status_idx`(`userId`, `status`),
+    INDEX `Book_createdAt_idx`(`createdAt`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `BookChapter` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `bookId` INTEGER NOT NULL,
+    `parentId` INTEGER NULL,
+    `level` INTEGER NOT NULL DEFAULT 1,
+    `number` INTEGER NOT NULL,
+    `title` VARCHAR(191) NOT NULL,
+    `summary` TEXT NULL,
+    `keyPoints` TEXT NULL,
+    `estimatedWords` INTEGER NOT NULL DEFAULT 1000,
+    `content` LONGTEXT NULL,
+    `wordCount` INTEGER NOT NULL DEFAULT 0,
+    `status` VARCHAR(191) NOT NULL DEFAULT 'pending',
+    `contentStatus` VARCHAR(20) NULL,
+    `contentError` TEXT NULL,
+    `generatedAt` DATETIME(3) NULL,
+    `audioUrl` TEXT NULL,
+    `audioDuration` INTEGER NOT NULL DEFAULT 0,
+    `videoUrl` TEXT NULL,
+    `videoDuration` INTEGER NULL,
+    `isPublic` BOOLEAN NOT NULL DEFAULT false,
+
+    INDEX `BookChapter_bookId_idx`(`bookId`),
+    INDEX `BookChapter_bookId_parentId_idx`(`bookId`, `parentId`),
+    INDEX `BookChapter_bookId_level_idx`(`bookId`, `level`),
+    UNIQUE INDEX `BookChapter_bookId_parentId_level_number_key`(`bookId`, `parentId`, `level`, `number`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `VideoProject` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NULL,
+    `title` VARCHAR(191) NOT NULL,
+    `description` VARCHAR(191) NULL,
+    `coverUrl` VARCHAR(191) NULL,
+    `configJson` LONGTEXT NULL,
+    `outputUrl` VARCHAR(191) NULL,
+    `duration` INTEGER NULL,
+    `fileSize` INTEGER NULL,
+    `bookId` INTEGER NULL,
+    `chapterId` INTEGER NULL,
+    `status` VARCHAR(191) NOT NULL DEFAULT 'draft',
+    `progress` INTEGER NOT NULL DEFAULT 0,
+    `errorMsg` TEXT NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    INDEX `VideoProject_userId_status_idx`(`userId`, `status`),
+    INDEX `VideoProject_createdAt_idx`(`createdAt`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `SearchHistory` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `keyword` VARCHAR(191) NOT NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+    INDEX `SearchHistory_userId_createdAt_idx`(`userId`, `createdAt`),
+    INDEX `SearchHistory_userId_idx`(`userId`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `HotSearch` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `keyword` VARCHAR(191) NOT NULL,
+    `count` INTEGER NOT NULL DEFAULT 0,
+    `sort` INTEGER NOT NULL DEFAULT 0,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    INDEX `HotSearch_sort_idx`(`sort`),
+    INDEX `HotSearch_count_idx`(`count`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `SignRecord` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+    INDEX `SignRecord_userId_createdAt_idx`(`userId`, `createdAt`),
+    UNIQUE INDEX `SignRecord_userId_createdAt_key`(`userId`, `createdAt`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `SubscriptionPlan` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `name` VARCHAR(191) NOT NULL,
+    `level` INTEGER NOT NULL DEFAULT 0,
+    `priceMonthly` DECIMAL(10, 2) NOT NULL DEFAULT 0,
+    `priceYearly` DECIMAL(10, 2) NOT NULL DEFAULT 0,
+    `description` TEXT NULL,
+    `features` TEXT NULL,
+    `isRecommended` BOOLEAN NOT NULL DEFAULT false,
+    `isActive` BOOLEAN NOT NULL DEFAULT true,
+    `sortOrder` INTEGER NOT NULL DEFAULT 0,
+    `dailyGenerations` INTEGER NOT NULL DEFAULT 3,
+    `perGenerationLimit` INTEGER NOT NULL DEFAULT 2000,
+    `monthlyTokens` INTEGER NOT NULL DEFAULT 10000,
+    `monthlyMinutes` INTEGER NOT NULL DEFAULT 30,
+    `yearlyTokens` INTEGER NULL,
+    `voiceOptions` INTEGER NOT NULL DEFAULT 5,
+    `audioQuality` VARCHAR(191) NOT NULL DEFAULT 'standard',
+    `apiAccess` BOOLEAN NOT NULL DEFAULT false,
+    `batchProcessing` BOOLEAN NOT NULL DEFAULT false,
+    `teamManagement` BOOLEAN NOT NULL DEFAULT false,
+    `overageEnabled` BOOLEAN NOT NULL DEFAULT false,
+    `overagePrice` DECIMAL(10, 2) NOT NULL DEFAULT 0,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    INDEX `SubscriptionPlan_level_idx`(`level`),
+    INDEX `SubscriptionPlan_isActive_sortOrder_idx`(`isActive`, `sortOrder`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `Subscription` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `planId` INTEGER NOT NULL,
+    `startDate` DATETIME(3) NOT NULL,
+    `endDate` DATETIME(3) NOT NULL,
+    `status` VARCHAR(191) NOT NULL DEFAULT 'active',
+    `autoRenew` BOOLEAN NOT NULL DEFAULT false,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    INDEX `Subscription_userId_status_idx`(`userId`, `status`),
+    INDEX `Subscription_userId_endDate_idx`(`userId`, `endDate`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `TokenUsage` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `type` VARCHAR(191) NOT NULL,
+    `amount` INTEGER NOT NULL DEFAULT 0,
+    `contentLength` INTEGER NOT NULL DEFAULT 0,
+    `orderId` INTEGER NULL,
+    `description` TEXT NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+
+    INDEX `TokenUsage_userId_createdAt_idx`(`userId`, `createdAt`),
+    INDEX `TokenUsage_userId_type_idx`(`userId`, `type`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `TokenBalance` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `totalTokens` INTEGER NOT NULL DEFAULT 0,
+    `usedTokens` INTEGER NOT NULL DEFAULT 0,
+    `resetDate` DATETIME(3) NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    UNIQUE INDEX `TokenBalance_userId_key`(`userId`),
+    INDEX `TokenBalance_userId_idx`(`userId`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `VideoMaterial` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NULL,
+    `type` VARCHAR(191) NOT NULL,
+    `name` VARCHAR(191) NOT NULL,
+    `url` TEXT NOT NULL,
+    `thumbnail` VARCHAR(191) NULL,
+    `tags` TEXT NULL,
+    `category` VARCHAR(191) NULL,
+    `duration` INTEGER NULL,
+    `size` INTEGER NULL,
+    `width` INTEGER NULL,
+    `height` INTEGER NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    INDEX `VideoMaterial_userId_type_idx`(`userId`, `type`),
+    INDEX `VideoMaterial_type_category_idx`(`type`, `category`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `AudioRecord` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NULL,
+    `audioId` VARCHAR(191) NOT NULL,
+    `title` VARCHAR(191) NOT NULL DEFAULT '未命名音频',
+    `text` LONGTEXT NULL,
+    `wordCount` INTEGER NOT NULL DEFAULT 0,
+    `voiceId` VARCHAR(191) NOT NULL DEFAULT 'cherry',
+    `voiceParams` VARCHAR(191) NULL,
+    `audioUrl` TEXT NULL,
+    `audioDuration` INTEGER NOT NULL DEFAULT 0,
+    `audioSize` INTEGER NOT NULL DEFAULT 0,
+    `status` VARCHAR(191) NOT NULL DEFAULT 'processing',
+    `errorMsg` TEXT NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    UNIQUE INDEX `AudioRecord_audioId_key`(`audioId`),
+    INDEX `AudioRecord_userId_idx`(`userId`),
+    INDEX `AudioRecord_audioId_idx`(`audioId`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `PlatformAccount` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `platform` VARCHAR(191) NOT NULL,
+    `nickname` VARCHAR(191) NOT NULL DEFAULT '',
+    `avatar` VARCHAR(191) NOT NULL DEFAULT '',
+    `cookies` TEXT NOT NULL,
+    `headers` TEXT NULL,
+    `isValid` BOOLEAN NOT NULL DEFAULT true,
+    `expireTime` DATETIME(3) NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    INDEX `PlatformAccount_userId_idx`(`userId`),
+    INDEX `PlatformAccount_platform_idx`(`platform`),
+    UNIQUE INDEX `PlatformAccount_userId_platform_key`(`userId`, `platform`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `PublishTask` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `videoProjectId` INTEGER NOT NULL,
+    `platform` VARCHAR(191) NOT NULL,
+    `title` TEXT NOT NULL,
+    `description` TEXT NULL,
+    `tags` TEXT NULL,
+    `coverUrl` TEXT NULL,
+    `videoUrl` TEXT NULL,
+    `status` VARCHAR(191) NOT NULL DEFAULT 'pending',
+    `errorMsg` TEXT NULL,
+    `publishedUrl` TEXT NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    INDEX `PublishTask_userId_platform_idx`(`userId`, `platform`),
+    INDEX `PublishTask_status_idx`(`status`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `Draft` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `type` VARCHAR(191) NOT NULL,
+    `title` TEXT NULL,
+    `content` LONGTEXT NULL,
+    `metadata` TEXT NULL,
+    `autoSavedAt` DATETIME(3) NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    INDEX `Draft_userId_type_idx`(`userId`, `type`),
+    INDEX `Draft_userId_updatedAt_idx`(`userId`, `updatedAt`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `Playlist` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `userId` INTEGER NOT NULL,
+    `name` VARCHAR(191) NOT NULL,
+    `description` TEXT NULL,
+    `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
+    `updatedAt` DATETIME(3) NOT NULL,
+
+    INDEX `Playlist_userId_idx`(`userId`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- CreateTable
+CREATE TABLE `PlaylistItem` (
+    `id` INTEGER NOT NULL AUTO_INCREMENT,
+    `playlistId` INTEGER NOT NULL,
+    `chapterId` INTEGER NULL,
+    `audioId` VARCHAR(191) NULL,
+    `order` INTEGER NOT NULL DEFAULT 0,
+
+    INDEX `PlaylistItem_playlistId_order_idx`(`playlistId`, `order`),
+    PRIMARY KEY (`id`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+-- AddForeignKey
+ALTER TABLE `Order` ADD CONSTRAINT `Order_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `Order` ADD CONSTRAINT `Order_planId_fkey` FOREIGN KEY (`planId`) REFERENCES `SubscriptionPlan`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `PlayRecord` ADD CONSTRAINT `PlayRecord_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `PlayRecord` ADD CONSTRAINT `PlayRecord_chapterId_fkey` FOREIGN KEY (`chapterId`) REFERENCES `BookChapter`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `UserPreference` ADD CONSTRAINT `UserPreference_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `Favorite` ADD CONSTRAINT `Favorite_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `Favorite` ADD CONSTRAINT `Favorite_bookId_fkey` FOREIGN KEY (`bookId`) REFERENCES `Book`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `Comment` ADD CONSTRAINT `Comment_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `Comment` ADD CONSTRAINT `Comment_chapterId_fkey` FOREIGN KEY (`chapterId`) REFERENCES `BookChapter`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `BookChapter` ADD CONSTRAINT `BookChapter_bookId_fkey` FOREIGN KEY (`bookId`) REFERENCES `Book`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `VideoProject` ADD CONSTRAINT `VideoProject_bookId_fkey` FOREIGN KEY (`bookId`) REFERENCES `Book`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `VideoProject` ADD CONSTRAINT `VideoProject_chapterId_fkey` FOREIGN KEY (`chapterId`) REFERENCES `BookChapter`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `SignRecord` ADD CONSTRAINT `SignRecord_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `Subscription` ADD CONSTRAINT `Subscription_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `Subscription` ADD CONSTRAINT `Subscription_planId_fkey` FOREIGN KEY (`planId`) REFERENCES `SubscriptionPlan`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `TokenUsage` ADD CONSTRAINT `TokenUsage_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `TokenUsage` ADD CONSTRAINT `TokenUsage_orderId_fkey` FOREIGN KEY (`orderId`) REFERENCES `Order`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `TokenBalance` ADD CONSTRAINT `TokenBalance_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `Draft` ADD CONSTRAINT `Draft_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `Playlist` ADD CONSTRAINT `Playlist_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `PlaylistItem` ADD CONSTRAINT `PlaylistItem_playlistId_fkey` FOREIGN KEY (`playlistId`) REFERENCES `Playlist`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE `PlaylistItem` ADD CONSTRAINT `PlaylistItem_chapterId_fkey` FOREIGN KEY (`chapterId`) REFERENCES `BookChapter`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;

+ 3 - 0
server/prisma/migrations/migration_lock.toml

@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (e.g., Git)
+provider = "mysql"

+ 8 - 2
server/prisma/schema.prisma

@@ -199,8 +199,14 @@ model BookChapter {
   estimatedWords  Int       @default(1000) // 预估字数
   content         String?   @db.LongText // 正文内容(原始文本)
   wordCount       Int       @default(0) // 实际字数
-  status          String    @default("pending") // pending, generating, completed, failed
-  errorMsg        String?   @db.Text
+  
+  // 大纲状态(title, summary, keyPoints)
+  status          String    @default("pending") // pending, completed, failed
+  
+  // 内容生成状态(content, wordCount)
+  contentStatus   String?   @db.VarChar(20) // null=未开始, pending=待生成, generating=生成中, completed=已完成, failed=失败
+  contentError    String?   @db.Text // 内容生成错误信息
+  
   generatedAt     DateTime?
 
   // 3种表现形式

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

@@ -297,7 +297,8 @@ export class BookStore {
         summary: item.summary || '',
         keyPoints: item.keyPoints ? JSON.stringify(item.keyPoints) : null,
         estimatedWords: item.estimatedWords || 1000,
-        status: 'pending',
+        status: 'completed', // 大纲已完成
+        contentStatus: null, // 内容未开始生成
       },
     });
     return created.id;
@@ -368,6 +369,8 @@ export class BookStore {
     wordCount: number;
     status: ChapterStatus;
     errorMsg: string;
+    contentStatus: 'pending' | 'generating' | 'completed' | 'failed';
+    contentError: string;
   }>): Promise<Chapter | null> {
     const updated = await prisma.bookChapter.update({
       where: { id },

+ 18 - 20
server/src/modules/book-generator/langgraph-generator.ts

@@ -1169,35 +1169,26 @@ async function writeChaptersNode(state: typeof GraphState.State): Promise<Partia
 
   const bookIdNum = parseInt(state.bookId);
 
-  // 从数据库获取所有小节(level=3)
+  // 筛选出未完成内容的叶节点(支持断点续传)
+  // 叶节点判断:contentStatus !== 'completed' 表示内容未生成
   const allSubsections = await prisma.bookChapter.findMany({
     where: { bookId: bookIdNum, level: 3 },
     orderBy: [
-      { parentId: 'asc' },  // 先按父节点排序
+      { parentId: 'asc' },
       { number: 'asc' }
     ],
-    include: {
-      // 获取父节的信息
-      parent: {
-        include: {
-          // 获取父章的信息
-          parent: true
-        }
-      }
-    }
   });
 
-  // 筛选出未完成的小节(pending 或 failed),支持断点续传
-  const subsections = allSubsections.filter(s => s.status !== 'completed');
+  const subsections = allSubsections.filter((s: any) => s.contentStatus !== 'completed');
   const completedCount = allSubsections.length - subsections.length;
 
   if (completedCount > 0) {
-    console.log(`[LangGraph] ✅ 跳过 ${completedCount} 个已完成的小节,待生成 ${subsections.length} 个`);
+    console.log(`[LangGraph] ✅ 跳过 ${completedCount} 个已生成内容的小节,待生成 ${subsections.length} 个`);
   }
 
   if (subsections.length === 0) {
-    console.log('[LangGraph] ✅ 所有小节已完成,跳过内容生成');
-    return { finished: true, progress: 95 };
+    console.log('[LangGraph] ✅ 所有小节内容已完成,跳过内容生成');
+    return { finished: true, progress: PROGRESS.CONTENT_END };
   }
 
   // 构建父节点映射,便于获取上下文
@@ -1279,18 +1270,25 @@ async function writeChaptersNode(state: typeof GraphState.State): Promise<Partia
       completedSubsections++;
 
       // 保存小节内容
-      await bookStore.updateChapterById(subsection.id, { content, wordCount, status: 'completed' });
+      await bookStore.updateChapterById(subsection.id, { 
+        content, 
+        wordCount, 
+        contentStatus: 'completed' // 内容生成完成
+      });
 
       // 更新进度
       const progress = Math.round((completedSubsections / totalSubsections) * (PROGRESS.CONTENT_END - PROGRESS.CONTENT_START)) + PROGRESS.CONTENT_START;
       await bookStore.update(state.bookId, { progress, status: 'generating' });
-      console.log(`[LangGraph] 小节「${subsection.title}」完成 (${completedSubsections}/${totalSubsections}),累计${currentWordCount}字,进度${progress}%`);
+      console.log(`[LangGraph] 小节「${subsection.title}」内容完成 (${completedSubsections}/${totalSubsections}),累计${currentWordCount}字,进度${progress}%`);
 
     } catch (error) {
       const errorMsg = error instanceof Error ? error.message : '失败';
-      await bookStore.updateChapterById(subsection.id, { status: 'failed', errorMsg });
+      await bookStore.updateChapterById(subsection.id, { 
+        contentStatus: 'failed', 
+        contentError: errorMsg 
+      });
       failedChapters.push(subsection.number);
-      console.error(`[LangGraph] 小节「${subsection.title}」失败:`, errorMsg);
+      console.error(`[LangGraph] 小节「${subsection.title}」内容失败:`, errorMsg);
     }
   }