From 9948ccba8f980e34ac37abe01a973230a63252e9 Mon Sep 17 00:00:00 2001 From: liangguodong Date: Mon, 9 Mar 2026 23:00:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=88=B0=E7=A7=8D=E5=AD=90=E6=95=B0=E6=8D=AE?= =?UTF-8?q?,=20AI=E6=94=B9=E5=86=99=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96,?= =?UTF-8?q?=20=E5=89=8D=E7=AB=AF=E8=81=94=E8=B0=83=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/js/data/StoryManager.js | 52 ++++ client/js/scenes/EndingScene.js | 379 ++++++++++++++++++++++++++---- client/js/scenes/StoryScene.js | 4 +- client/js/utils/http.js | 2 +- client/project.config.json | 2 +- server/app/routers/drafts.py | 276 ++++++++++++++++++++++ server/app/services/ai.py | 133 +++++++++++ server/sql/add_test_user.py | 29 +++ server/sql/init_db.py | 23 +- server/sql/rebuild_db.py | 74 ++++++ server/sql/schema.sql | 249 ++++++++++++-------- server/sql/seed_stories_part1.sql | 10 +- 12 files changed, 1082 insertions(+), 151 deletions(-) create mode 100644 server/sql/add_test_user.py create mode 100644 server/sql/rebuild_db.py diff --git a/client/js/data/StoryManager.js b/client/js/data/StoryManager.js index 6341045..3d0062f 100644 --- a/client/js/data/StoryManager.js +++ b/client/js/data/StoryManager.js @@ -156,6 +156,58 @@ export default class StoryManager { } } + /** + * AI改写结局,异步提交到草稿箱 + */ + async rewriteEndingAsync(storyId, ending, prompt, userId) { + try { + // 先标记之前的未读草稿为已读 + await this.markAllDraftsRead(userId); + + const result = await post(`/drafts/ending`, { + userId: userId, + storyId: storyId, + endingName: ending?.name || '未知结局', + endingContent: ending?.content || '', + prompt: prompt + }, { timeout: 30000 }); + + if (result && result.draftId) { + return result; + } + return null; + } catch (error) { + console.error('AI改写结局提交失败:', error?.errMsg || error?.message || JSON.stringify(error)); + return null; + } + } + + /** + * AI续写结局,异步提交到草稿箱 + */ + async continueEndingAsync(storyId, ending, prompt, userId) { + try { + // 先标记之前的未读草稿为已读 + await this.markAllDraftsRead(userId); + + const result = await post(`/drafts/continue-ending`, { + userId: userId, + storyId: storyId, + endingName: ending?.name || '未知结局', + endingContent: ending?.content || '', + prompt: prompt + }, { timeout: 30000 }); + + if (result && result.draftId) { + return result; + } + return null; + } catch (error) { + console.error('AI续写结局提交失败:', error?.errMsg || error?.message || JSON.stringify(error)); + return null; + } + } + /** * AI改写中间章节,异步提交到草稿箱 * @returns {Object|null} 成功返回草稿ID,失败返回 null diff --git a/client/js/scenes/EndingScene.js b/client/js/scenes/EndingScene.js index 68a7026..dc79b74 100644 --- a/client/js/scenes/EndingScene.js +++ b/client/js/scenes/EndingScene.js @@ -20,6 +20,11 @@ export default class EndingScene extends BaseScene { this.rewritePrompt = ''; this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢']; this.selectedTag = -1; + // AI续写面板 + this.showContinuePanel = false; + this.continuePrompt = ''; + this.continueTags = ['故事未完', '新的冒险', '多年以后', '意外转折', '番外篇']; + this.selectedContinueTag = -1; // 改写历史 this.rewriteHistory = []; this.currentHistoryIndex = -1; @@ -85,6 +90,10 @@ export default class EndingScene extends BaseScene { if (this.showRewritePanel) { this.renderRewritePanel(ctx); } + // AI续写面板 + if (this.showContinuePanel) { + this.renderContinuePanel(ctx); + } } renderBackground(ctx) { @@ -257,15 +266,17 @@ export default class EndingScene extends BaseScene { const buttonHeight = 38; const buttonMargin = 8; const startY = this.screenHeight - 220; + const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2; - // AI改写按钮(带配额提示) + // AI改写按钮和AI续写按钮(第一行) const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased; - const aiBtnText = remaining > 0 ? '✨ AI改写结局' : '⚠️ 次数不足'; - this.renderGradientButton(ctx, padding, startY, this.screenWidth - padding * 2, buttonHeight, aiBtnText, ['#a855f7', '#ec4899']); + const rewriteBtnText = remaining > 0 ? '✨ AI改写' : '⚠️ 次数不足'; + const continueBtnText = remaining > 0 ? '📖 AI续写' : '⚠️ 次数不足'; + this.renderGradientButton(ctx, padding, startY, buttonWidth, buttonHeight, rewriteBtnText, ['#a855f7', '#ec4899']); + this.renderGradientButton(ctx, padding + buttonWidth + buttonMargin, startY, buttonWidth, buttonHeight, continueBtnText, ['#10b981', '#059669']); // 分享按钮 const row2Y = startY + buttonHeight + buttonMargin; - const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2; this.renderGradientButton(ctx, padding, row2Y, buttonWidth, buttonHeight, '分享结局', ['#ff6b6b', '#ffd700']); // 章节选择按钮 @@ -503,6 +514,183 @@ export default class EndingScene extends BaseScene { this.inputRect = { x: panelX + 15, y: inputY, width: panelWidth - 30, height: inputHeight }; } + renderContinuePanel(ctx) { + const padding = 20; + const panelWidth = this.screenWidth - padding * 2; + const panelHeight = 450; + const panelX = padding; + const panelY = (this.screenHeight - panelHeight) / 2; + + // 遮罩层 + ctx.fillStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.fillRect(0, 0, this.screenWidth, this.screenHeight); + + // 面板背景渐变 + const panelGradient = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelHeight); + panelGradient.addColorStop(0, '#0d2818'); + panelGradient.addColorStop(1, '#0a1a10'); + ctx.fillStyle = panelGradient; + this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20); + ctx.fill(); + + // 面板边框渐变 + const borderGradient = ctx.createLinearGradient(panelX, panelY, panelX + panelWidth, panelY); + borderGradient.addColorStop(0, '#10b981'); + borderGradient.addColorStop(1, '#059669'); + ctx.strokeStyle = borderGradient; + ctx.lineWidth = 2; + this.roundRect(ctx, panelX, panelY, panelWidth, panelHeight, 20); + ctx.stroke(); + + // 标题栏 + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 18px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('📖 AI续写结局', this.screenWidth / 2, panelY + 35); + + // 配额提示 + const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased; + ctx.fillStyle = remaining > 0 ? 'rgba(255,255,255,0.6)' : 'rgba(255,100,100,0.8)'; + ctx.font = '11px sans-serif'; + ctx.textAlign = 'right'; + ctx.fillText(`剩余次数:${remaining}`, panelX + panelWidth - 15, panelY + 35); + + // 副标题 + ctx.fillStyle = 'rgba(255,255,255,0.6)'; + ctx.font = '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('从当前结局出发,AI将为你续写新的剧情分支', this.screenWidth / 2, panelY + 58); + + // 分隔线 + const lineGradient = ctx.createLinearGradient(panelX + 20, panelY + 75, panelX + panelWidth - 20, panelY + 75); + lineGradient.addColorStop(0, 'transparent'); + lineGradient.addColorStop(0.5, 'rgba(16,185,129,0.5)'); + lineGradient.addColorStop(1, 'transparent'); + ctx.strokeStyle = lineGradient; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(panelX + 20, panelY + 75); + ctx.lineTo(panelX + panelWidth - 20, panelY + 75); + ctx.stroke(); + + // 快捷标签标题 + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.font = '13px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('快捷选择:', panelX + 15, panelY + 105); + + // 快捷标签 + const tagStartX = panelX + 15; + const tagY = panelY + 120; + const tagHeight = 32; + const tagGap = 8; + let currentX = tagStartX; + let currentY = tagY; + + this.continueTagRects = []; + this.continueTags.forEach((tag, index) => { + ctx.font = '12px sans-serif'; + const tagWidth = ctx.measureText(tag).width + 24; + + // 换行 + if (currentX + tagWidth > panelX + panelWidth - 15) { + currentX = tagStartX; + currentY += tagHeight + tagGap; + } + + // 标签背景 + const isSelected = index === this.selectedContinueTag; + if (isSelected) { + const tagGradient = ctx.createLinearGradient(currentX, currentY, currentX + tagWidth, currentY); + tagGradient.addColorStop(0, '#10b981'); + tagGradient.addColorStop(1, '#059669'); + ctx.fillStyle = tagGradient; + } else { + ctx.fillStyle = 'rgba(255,255,255,0.1)'; + } + this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16); + ctx.fill(); + + // 标签边框 + ctx.strokeStyle = isSelected ? 'transparent' : 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 1; + this.roundRect(ctx, currentX, currentY, tagWidth, tagHeight, 16); + ctx.stroke(); + + // 标签文字 + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.fillText(tag, currentX + tagWidth / 2, currentY + 21); + + // 存储标签位置 + this.continueTagRects.push({ x: currentX, y: currentY, width: tagWidth, height: tagHeight, index }); + + currentX += tagWidth + tagGap; + }); + + // 自定义输入提示 + ctx.fillStyle = 'rgba(255,255,255,0.8)'; + ctx.font = '13px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText('或自定义输入:', panelX + 15, panelY + 215); + + // 输入框背景 + const inputY = panelY + 230; + const inputHeight = 45; + ctx.fillStyle = 'rgba(255,255,255,0.08)'; + this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.2)'; + ctx.lineWidth = 1; + this.roundRect(ctx, panelX + 15, inputY, panelWidth - 30, inputHeight, 12); + ctx.stroke(); + + // 输入框文字或占位符 + ctx.font = '14px sans-serif'; + ctx.textAlign = 'left'; + if (this.continuePrompt) { + ctx.fillStyle = '#ffffff'; + ctx.fillText(this.continuePrompt, panelX + 28, inputY + 28); + } else { + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.fillText('点击输入你的续写想法...', panelX + 28, inputY + 28); + } + + // 按钮 + const btnY = panelY + panelHeight - 70; + const btnWidth = (panelWidth - 50) / 2; + const btnHeight = 44; + + // 取消按钮 + ctx.fillStyle = 'rgba(255,255,255,0.1)'; + this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 1; + this.roundRect(ctx, panelX + 15, btnY, btnWidth, btnHeight, 22); + ctx.stroke(); + ctx.fillStyle = '#ffffff'; + ctx.font = '14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('取消', panelX + 15 + btnWidth / 2, btnY + 28); + + // 确认按钮 + const confirmGradient = ctx.createLinearGradient(panelX + 35 + btnWidth, btnY, panelX + 35 + btnWidth * 2, btnY); + confirmGradient.addColorStop(0, '#10b981'); + confirmGradient.addColorStop(1, '#059669'); + ctx.fillStyle = confirmGradient; + this.roundRect(ctx, panelX + 35 + btnWidth, btnY, btnWidth, btnHeight, 22); + ctx.fill(); + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 14px sans-serif'; + ctx.fillText('📖 开始续写', panelX + 35 + btnWidth + btnWidth / 2, btnY + 28); + + // 存储按钮区域 + this.continueCancelBtnRect = { x: panelX + 15, y: btnY, width: btnWidth, height: btnHeight }; + this.continueConfirmBtnRect = { x: panelX + 35 + btnWidth, y: btnY, width: btnWidth, height: btnHeight }; + this.continueInputRect = { x: panelX + 15, y: inputY, width: panelWidth - 30, height: inputHeight }; + } + // 圆角矩形 roundRect(ctx, x, y, width, height, radius) { ctx.beginPath(); @@ -610,6 +798,12 @@ export default class EndingScene extends BaseScene { return; } + // 如果续写面板打开,优先处理 + if (this.showContinuePanel) { + this.handleContinuePanelTouch(x, y); + return; + } + if (!this.showButtons) return; const padding = 15; @@ -618,12 +812,18 @@ export default class EndingScene extends BaseScene { const startY = this.screenHeight - 220; const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2; - // AI改写按钮 - if (this.isInRect(x, y, padding, startY, this.screenWidth - padding * 2, buttonHeight)) { + // AI改写按钮(左) + if (this.isInRect(x, y, padding, startY, buttonWidth, buttonHeight)) { this.handleAIRewrite(); return; } + // AI续写按钮(右) + if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, startY, buttonWidth, buttonHeight)) { + this.handleAIContinue(); + return; + } + // 分享按钮 const row2Y = startY + buttonHeight + buttonMargin; if (this.isInRect(x, y, padding, row2Y, buttonWidth, buttonHeight)) { @@ -696,6 +896,20 @@ export default class EndingScene extends BaseScene { this.selectedTag = -1; } + handleAIContinue() { + // 检查配额 + const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased; + if (remaining <= 0) { + this.showQuotaModal(); + return; + } + + // 显示AI续写面板 + this.showContinuePanel = true; + this.continuePrompt = ''; + this.selectedContinueTag = -1; + } + handleRewritePanelTouch(x, y) { // 点击标签 if (this.tagRects) { @@ -734,6 +948,44 @@ export default class EndingScene extends BaseScene { return false; } + handleContinuePanelTouch(x, y) { + // 点击标签 + if (this.continueTagRects) { + for (const tag of this.continueTagRects) { + if (this.isInRect(x, y, tag.x, tag.y, tag.width, tag.height)) { + this.selectedContinueTag = tag.index; + this.continuePrompt = this.continueTags[tag.index]; + return true; + } + } + } + + // 点击输入框 + if (this.continueInputRect && this.isInRect(x, y, this.continueInputRect.x, this.continueInputRect.y, this.continueInputRect.width, this.continueInputRect.height)) { + this.showContinueInput(); + return true; + } + + // 点击取消 + if (this.continueCancelBtnRect && this.isInRect(x, y, this.continueCancelBtnRect.x, this.continueCancelBtnRect.y, this.continueCancelBtnRect.width, this.continueCancelBtnRect.height)) { + this.showContinuePanel = false; + return true; + } + + // 点击确认 + if (this.continueConfirmBtnRect && this.isInRect(x, y, this.continueConfirmBtnRect.x, this.continueConfirmBtnRect.y, this.continueConfirmBtnRect.width, this.continueConfirmBtnRect.height)) { + if (this.continuePrompt) { + this.showContinuePanel = false; + this.callAIContinue(this.continuePrompt); + } else { + wx.showToast({ title: '请选择或输入续写方向', icon: 'none' }); + } + return true; + } + + return false; + } + showCustomInput() { wx.showModal({ title: '输入改写想法', @@ -749,6 +1001,21 @@ export default class EndingScene extends BaseScene { }); } + showContinueInput() { + wx.showModal({ + title: '输入续写想法', + editable: true, + placeholderText: '例如:主角开启新的冒险', + content: this.continuePrompt, + success: (res) => { + if (res.confirm && res.content) { + this.continuePrompt = res.content; + this.selectedContinueTag = -1; + } + } + }); + } + async callAIRewrite(prompt) { // 检查配额 const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased; @@ -758,68 +1025,94 @@ export default class EndingScene extends BaseScene { } this.isRewriting = true; - this.rewriteProgress = 0; // 显示加载动画 wx.showLoading({ - title: 'AI创作中...', + title: '提交中...', mask: true }); - - // 模拟进度条效果 - const progressInterval = setInterval(() => { - this.rewriteProgress += Math.random() * 20; - if (this.rewriteProgress > 90) this.rewriteProgress = 90; - }, 500); try { - const result = await this.main.storyManager.rewriteEnding( + const userId = this.main.userManager.userId || 1; + const result = await this.main.storyManager.rewriteEndingAsync( this.storyId, this.ending, - prompt + prompt, + userId ); - clearInterval(progressInterval); - this.rewriteProgress = 100; wx.hideLoading(); - if (result && result.content) { - // 记录改写历史 - this.rewriteHistory.push({ - prompt: prompt, - content: result.content, - timestamp: Date.now() - }); - this.currentHistoryIndex = this.rewriteHistory.length - 1; - + if (result && result.draftId) { // 扣除配额 this.aiQuota.used += 1; - // 成功提示 - wx.showToast({ - title: '改写成功!', - icon: 'success', - duration: 1500 + // 提交成功提示 + wx.showModal({ + title: '提交成功', + content: 'AI正在后台生成新结局,完成后会通知您。\n您可以在草稿箱中查看。', + showCancel: false, + confirmText: '知道了' }); - - // 延迟跳转到故事场景播放新内容 - setTimeout(() => { - this.main.sceneManager.switchScene('story', { - storyId: this.storyId, - aiContent: result - }); - }, 1500); } else { - wx.showToast({ title: '改写失败,请重试', icon: 'none' }); + wx.showToast({ title: '提交失败,请重试', icon: 'none' }); } } catch (error) { - clearInterval(progressInterval); wx.hideLoading(); console.error('改写失败:', error); wx.showToast({ title: error.message || '网络错误', icon: 'none' }); } finally { this.isRewriting = false; - this.rewriteProgress = 0; + } + } + + async callAIContinue(prompt) { + // 检查配额 + const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased; + if (remaining <= 0) { + this.showQuotaModal(); + return; + } + + this.isRewriting = true; + + // 显示加载动画 + wx.showLoading({ + title: '提交中...', + mask: true + }); + + try { + const userId = this.main.userManager.userId || 1; + const result = await this.main.storyManager.continueEndingAsync( + this.storyId, + this.ending, + prompt, + userId + ); + + wx.hideLoading(); + + if (result && result.draftId) { + // 扣除配额 + this.aiQuota.used += 1; + + // 提交成功提示 + wx.showModal({ + title: '提交成功', + content: 'AI正在后台生成续写剧情,完成后会通知您。\n您可以在草稿箱中查看。', + showCancel: false, + confirmText: '知道了' + }); + } else { + wx.showToast({ title: '提交失败,请重试', icon: 'none' }); + } + } catch (error) { + wx.hideLoading(); + console.error('续写失败:', error); + wx.showToast({ title: error.message || '网络错误', icon: 'none' }); + } finally { + this.isRewriting = false; } } diff --git a/client/js/scenes/StoryScene.js b/client/js/scenes/StoryScene.js index 4e5adcf..dd73b9e 100644 --- a/client/js/scenes/StoryScene.js +++ b/client/js/scenes/StoryScene.js @@ -218,7 +218,9 @@ export default class StoryScene extends BaseScene { const padding = 16; let y = 100 - this.recapScrollY; - const pathHistory = this.recapData?.pathHistory || []; + const rawPathHistory = this.recapData?.pathHistory; + // 兼容结局续写(pathHistory 是对象而非数组) + const pathHistory = Array.isArray(rawPathHistory) ? rawPathHistory : []; // 保存卡片位置用于点击检测 this.recapCardRects = []; diff --git a/client/js/utils/http.js b/client/js/utils/http.js index 80adb34..7c2b4a0 100644 --- a/client/js/utils/http.js +++ b/client/js/utils/http.js @@ -3,7 +3,7 @@ */ // API基础地址(开发环境) -const BASE_URL = 'http://localhost:3000/api'; +const BASE_URL = 'https://express-0a1p-230010-4-1408549115.sh.run.tcloudbase.com/api'; /** * 发送HTTP请求 diff --git a/client/project.config.json b/client/project.config.json index 2e0aabf..fbf5901 100644 --- a/client/project.config.json +++ b/client/project.config.json @@ -1,5 +1,5 @@ { - "appid": "wx27be06bc3365e84b", + "appid": "wx772e2f0fbc498020", "compileType": "game", "projectname": "stardom-story", "setting": { diff --git a/server/app/routers/drafts.py b/server/app/routers/drafts.py index 21800d8..4192158 100644 --- a/server/app/routers/drafts.py +++ b/server/app/routers/drafts.py @@ -32,6 +32,24 @@ class CreateDraftRequest(BaseModel): prompt: str +class CreateEndingDraftRequest(BaseModel): + """结局改写请求""" + userId: int + storyId: int + endingName: str + endingContent: str + prompt: str + + +class ContinueEndingDraftRequest(BaseModel): + """结局续写请求""" + userId: int + storyId: int + endingName: str + endingContent: str + prompt: str + + class DraftResponse(BaseModel): id: int storyId: int @@ -120,6 +138,174 @@ async def process_ai_rewrite(draft_id: int): pass +async def process_ai_rewrite_ending(draft_id: int): + """后台异步处理AI改写结局""" + from app.database import async_session_factory + from app.services.ai import ai_service + import json + import re + + async with async_session_factory() as db: + try: + # 获取草稿 + result = await db.execute(select(StoryDraft).where(StoryDraft.id == draft_id)) + draft = result.scalar_one_or_none() + + if not draft: + return + + # 更新状态为处理中 + draft.status = DraftStatus.processing + await db.commit() + + # 获取故事信息 + story_result = await db.execute(select(Story).where(Story.id == draft.story_id)) + story = story_result.scalar_one_or_none() + + if not story: + draft.status = DraftStatus.failed + draft.error_message = "故事不存在" + draft.completed_at = datetime.now() + await db.commit() + return + + # 从 path_history 获取结局信息 + ending_info = draft.path_history or {} + ending_name = ending_info.get("endingName", "未知结局") + ending_content = ending_info.get("endingContent", "") + + # 调用AI服务改写结局 + ai_result = await ai_service.rewrite_ending( + story_title=story.title, + story_category=story.category or "未知", + ending_name=ending_name, + ending_content=ending_content, + user_prompt=draft.user_prompt + ) + + if ai_result and ai_result.get("content"): + content = ai_result["content"] + new_ending_name = f"{ending_name}(AI改写)" + + # 尝试解析 JSON 格式的返回 + try: + json_match = re.search(r'\{[^{}]*"ending_name"[^{}]*"content"[^{}]*\}', content, re.DOTALL) + if json_match: + parsed = json.loads(json_match.group()) + new_ending_name = parsed.get("ending_name", new_ending_name) + content = parsed.get("content", content) + else: + parsed = json.loads(content) + new_ending_name = parsed.get("ending_name", new_ending_name) + content = parsed.get("content", content) + except (json.JSONDecodeError, AttributeError): + pass + + # 成功 - 存储为单节点结局格式 + draft.status = DraftStatus.completed + draft.ai_nodes = [{ + "nodeKey": "ending_rewrite", + "content": content, + "speaker": "旁白", + "isEnding": True, + "endingName": new_ending_name, + "endingType": "rewrite" + }] + draft.entry_node_key = "ending_rewrite" + draft.tokens_used = ai_result.get("tokens_used", 0) + draft.title = f"{story.title}-{new_ending_name}" + else: + draft.status = DraftStatus.failed + draft.error_message = "AI服务暂时不可用" + + draft.completed_at = datetime.now() + await db.commit() + + except Exception as e: + print(f"[process_ai_rewrite_ending] 异常: {e}") + import traceback + traceback.print_exc() + + try: + draft.status = DraftStatus.failed + draft.error_message = str(e)[:500] + draft.completed_at = datetime.now() + await db.commit() + except: + pass + + +async def process_ai_continue_ending(draft_id: int): + """后台异步处理AI续写结局""" + from app.database import async_session_factory + from app.services.ai import ai_service + + async with async_session_factory() as db: + try: + # 获取草稿 + result = await db.execute(select(StoryDraft).where(StoryDraft.id == draft_id)) + draft = result.scalar_one_or_none() + + if not draft: + return + + # 更新状态为处理中 + draft.status = DraftStatus.processing + await db.commit() + + # 获取故事信息 + story_result = await db.execute(select(Story).where(Story.id == draft.story_id)) + story = story_result.scalar_one_or_none() + + if not story: + draft.status = DraftStatus.failed + draft.error_message = "故事不存在" + draft.completed_at = datetime.now() + await db.commit() + return + + # 从 path_history 获取结局信息 + ending_info = draft.path_history or {} + ending_name = ending_info.get("endingName", "未知结局") + ending_content = ending_info.get("endingContent", "") + + # 调用AI服务续写结局 + ai_result = await ai_service.continue_ending( + story_title=story.title, + story_category=story.category or "未知", + ending_name=ending_name, + ending_content=ending_content, + user_prompt=draft.user_prompt + ) + + if ai_result and ai_result.get("nodes"): + # 成功 - 存储多节点分支格式 + draft.status = DraftStatus.completed + draft.ai_nodes = ai_result["nodes"] + draft.entry_node_key = ai_result.get("entryNodeKey", "continue_1") + draft.tokens_used = ai_result.get("tokens_used", 0) + draft.title = f"{story.title}-{ending_name}续写" + else: + draft.status = DraftStatus.failed + draft.error_message = "AI服务暂时不可用" + + draft.completed_at = datetime.now() + await db.commit() + + except Exception as e: + print(f"[process_ai_continue_ending] 异常: {e}") + import traceback + traceback.print_exc() + + try: + draft.status = DraftStatus.failed + draft.error_message = str(e)[:500] + draft.completed_at = datetime.now() + await db.commit() + except: + pass + + # ============ API 路由 ============ @router.post("") @@ -173,6 +359,96 @@ async def create_draft( } +@router.post("/ending") +async def create_ending_draft( + request: CreateEndingDraftRequest, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db) +): + """提交AI改写结局任务(异步处理)""" + if not request.prompt: + raise HTTPException(status_code=400, detail="请输入改写指令") + + # 获取故事信息 + result = await db.execute(select(Story).where(Story.id == request.storyId)) + story = result.scalar_one_or_none() + + if not story: + raise HTTPException(status_code=404, detail="故事不存在") + + # 创建草稿记录,将结局信息存在 path_history + draft = StoryDraft( + user_id=request.userId, + story_id=request.storyId, + title=f"{story.title}-结局改写", + path_history={"endingName": request.endingName, "endingContent": request.endingContent}, + current_node_key="ending", + current_content=request.endingContent, + user_prompt=request.prompt, + status=DraftStatus.pending + ) + + db.add(draft) + await db.commit() + await db.refresh(draft) + + # 添加后台任务 + background_tasks.add_task(process_ai_rewrite_ending, draft.id) + + return { + "code": 0, + "data": { + "draftId": draft.id, + "message": "已提交,AI正在生成新结局..." + } + } + + +@router.post("/continue-ending") +async def create_continue_ending_draft( + request: ContinueEndingDraftRequest, + background_tasks: BackgroundTasks, + db: AsyncSession = Depends(get_db) +): + """提交AI续写结局任务(异步处理)""" + if not request.prompt: + raise HTTPException(status_code=400, detail="请输入续写指令") + + # 获取故事信息 + result = await db.execute(select(Story).where(Story.id == request.storyId)) + story = result.scalar_one_or_none() + + if not story: + raise HTTPException(status_code=404, detail="故事不存在") + + # 创建草稿记录,将结局信息存在 path_history + draft = StoryDraft( + user_id=request.userId, + story_id=request.storyId, + title=f"{story.title}-结局续写", + path_history={"endingName": request.endingName, "endingContent": request.endingContent}, + current_node_key="ending", + current_content=request.endingContent, + user_prompt=request.prompt, + status=DraftStatus.pending + ) + + db.add(draft) + await db.commit() + await db.refresh(draft) + + # 添加后台任务 + background_tasks.add_task(process_ai_continue_ending, draft.id) + + return { + "code": 0, + "data": { + "draftId": draft.id, + "message": "已提交,AI正在续写故事..." + } + } + + @router.get("") async def get_drafts( userId: int, diff --git a/server/app/services/ai.py b/server/app/services/ai.py index 0419766..c7513fb 100644 --- a/server/app/services/ai.py +++ b/server/app/services/ai.py @@ -274,6 +274,139 @@ class AIService: traceback.print_exc() return None + async def continue_ending( + self, + story_title: str, + story_category: str, + ending_name: str, + ending_content: str, + user_prompt: str + ) -> Optional[Dict[str, Any]]: + """ + AI续写结局,从结局开始续写新的剧情分支 + """ + print(f"\n[continue_ending] ========== 开始调用 ==========") + print(f"[continue_ending] story_title={story_title}, category={story_category}") + print(f"[continue_ending] ending_name={ending_name}") + print(f"[continue_ending] user_prompt={user_prompt}") + print(f"[continue_ending] enabled={self.enabled}, api_key存在={bool(self.api_key)}") + + if not self.enabled or not self.api_key: + print(f"[continue_ending] 服务未启用或API Key为空,返回None") + return None + + # 构建系统提示词 + system_prompt = """你是一个专业的互动故事续写专家。用户已经到达故事的某个结局,现在想要从这个结局继续故事发展。 + +【任务】 +请从这个结局开始,创作故事的延续。新剧情必须紧接结局内容,仿佛故事并没有在此结束,而是有了新的发展。 + +【写作要求】 +1. 第一个节点必须紧密衔接原结局,像是结局之后自然发生的事 +2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合) +3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\\n\\n分隔),包含:场景描写、人物对话、心理活动 +4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果 +5. 严格符合用户的续写意图,围绕用户指令展开剧情 +6. 保持原故事的人物性格、语言风格和世界观 +7. 对话要自然生动,描写要有画面感 + +【关于新结局 - 极其重要!】 +★★★ 每一条分支路径的尽头必须是新结局节点 ★★★ +- 结局节点必须设置 "is_ending": true +- 结局内容要 200-400 字,分 2-3 段,有情感冲击力 +- 结局名称 4-8 字,体现剧情走向 +- 如果有2个选项分支,最终必须有2个不同的结局 +- 每个结局必须有 "ending_score" 评分(0-100) + +【输出格式】(严格JSON,不要有任何额外文字) +{ + "nodes": { + "continue_1": { + "content": "续写剧情第一段(150-300字)...", + "speaker": "旁白", + "choices": [ + {"text": "选项A(5-15字)", "nextNodeKey": "continue_2a"}, + {"text": "选项B(5-15字)", "nextNodeKey": "continue_2b"} + ] + }, + "continue_2a": { + "content": "...", + "speaker": "旁白", + "choices": [ + {"text": "选项C", "nextNodeKey": "continue_ending_good"}, + {"text": "选项D", "nextNodeKey": "continue_ending_bad"} + ] + }, + "continue_ending_good": { + "content": "新好结局内容(200-400字)...\\n\\n【达成结局:xxx】", + "speaker": "旁白", + "is_ending": true, + "ending_name": "新结局名称", + "ending_type": "good", + "ending_score": 90 + }, + "continue_ending_bad": { + "content": "新坏结局内容...\\n\\n【达成结局:xxx】", + "speaker": "旁白", + "is_ending": true, + "ending_name": "新结局名称", + "ending_type": "bad", + "ending_score": 40 + } + }, + "entryNodeKey": "continue_1" +}""" + + # 构建用户提示词 + user_prompt_text = f"""【原故事信息】 +故事标题:{story_title} +故事分类:{story_category} + +【已达成的结局】 +结局名称:{ending_name} +结局内容:{ending_content[:800]} + +【用户续写指令】 +{user_prompt} + +请从这个结局开始续写新的剧情分支(输出JSON格式):""" + + print(f"[continue_ending] 提示词构建完成,开始调用AI...") + + try: + result = None + if self.provider == "openai": + result = await self._call_openai_long(system_prompt, user_prompt_text) + elif self.provider == "claude": + result = await self._call_claude(f"{system_prompt}\n\n{user_prompt_text}") + elif self.provider == "qwen": + result = await self._call_qwen_long(system_prompt, user_prompt_text) + elif self.provider == "deepseek": + result = await self._call_deepseek_long(system_prompt, user_prompt_text) + + print(f"[continue_ending] AI调用完成,result存在={result is not None}") + + if result and result.get("content"): + print(f"[continue_ending] AI返回内容长度={len(result.get('content', ''))}") + + # 解析JSON响应(复用 rewrite_branch 的解析方法) + parsed = self._parse_branch_json(result["content"]) + print(f"[continue_ending] JSON解析结果: parsed存在={parsed is not None}") + + if parsed: + parsed["tokens_used"] = result.get("tokens_used", 0) + print(f"[continue_ending] 成功! nodes数量={len(parsed.get('nodes', {}))}") + return parsed + else: + print(f"[continue_ending] JSON解析失败!") + + return None + except Exception as e: + print(f"[continue_ending] 异常: {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + return None + def _parse_branch_json(self, content: str) -> Optional[Dict]: """解析AI返回的分支JSON""" print(f"[_parse_branch_json] 开始解析,内容长度={len(content)}") diff --git a/server/sql/add_test_user.py b/server/sql/add_test_user.py new file mode 100644 index 0000000..fe91196 --- /dev/null +++ b/server/sql/add_test_user.py @@ -0,0 +1,29 @@ +"""添加测试用户""" +import os +import pymysql +from pathlib import Path + +SERVER_DIR = Path(__file__).parent.parent +env_file = SERVER_DIR / '.env' +if env_file.exists(): + with open(env_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + os.environ.setdefault(key.strip(), value.strip()) + +conn = pymysql.connect( + host=os.getenv('DB_HOST', 'localhost'), + port=int(os.getenv('DB_PORT', 3306)), + user=os.getenv('DB_USER', 'root'), + password=os.getenv('DB_PASSWORD', ''), + database='stardom_story', + charset='utf8mb4' +) +cur = conn.cursor() +cur.execute("INSERT IGNORE INTO users (id, openid, nickname) VALUES (1, 'test_user', '测试用户')") +conn.commit() +print('测试用户创建成功') +cur.close() +conn.close() diff --git a/server/sql/init_db.py b/server/sql/init_db.py index 47ed19d..dc37242 100644 --- a/server/sql/init_db.py +++ b/server/sql/init_db.py @@ -9,12 +9,25 @@ from pathlib import Path # 获取当前脚本所在目录 SQL_DIR = Path(__file__).parent -# 数据库配置(从环境变量或默认值) +# 从.env文件读取配置 +def load_env(): + env_file = SQL_DIR.parent / '.env' + config = {} + if env_file.exists(): + for line in env_file.read_text(encoding='utf-8').splitlines(): + if '=' in line and not line.startswith('#'): + k, v = line.split('=', 1) + config[k.strip()] = v.strip() + return config + +env_config = load_env() + +# 数据库配置(优先从.env读取) DB_CONFIG = { - 'host': os.getenv('DB_HOST', 'localhost'), - 'port': int(os.getenv('DB_PORT', 3306)), - 'user': os.getenv('DB_USER', 'root'), - 'password': os.getenv('DB_PASSWORD', '123456'), + 'host': env_config.get('DB_HOST', os.getenv('DB_HOST', 'localhost')), + 'port': int(env_config.get('DB_PORT', os.getenv('DB_PORT', 3306))), + 'user': env_config.get('DB_USER', os.getenv('DB_USER', 'root')), + 'password': env_config.get('DB_PASSWORD', os.getenv('DB_PASSWORD', '')), 'charset': 'utf8mb4' } diff --git a/server/sql/rebuild_db.py b/server/sql/rebuild_db.py new file mode 100644 index 0000000..c4a15da --- /dev/null +++ b/server/sql/rebuild_db.py @@ -0,0 +1,74 @@ +"""删库重建脚本""" +import os +import sys +import pymysql +from pathlib import Path + +SQL_DIR = Path(__file__).parent +SERVER_DIR = SQL_DIR.parent + +# 加载 .env 文件 +env_file = SERVER_DIR / '.env' +if env_file.exists(): + with open(env_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + os.environ.setdefault(key.strip(), value.strip()) + +DB_CONFIG = { + 'host': os.getenv('DB_HOST', 'localhost'), + 'port': int(os.getenv('DB_PORT', 3306)), + 'user': os.getenv('DB_USER', 'root'), + 'password': os.getenv('DB_PASSWORD', '123456'), + 'charset': 'utf8mb4' +} + +def read_sql_file(filename): + with open(SQL_DIR / filename, 'r', encoding='utf-8') as f: + return f.read() + +def execute_sql(cursor, sql, desc): + print(f'{desc}...') + for stmt in [s.strip() for s in sql.split(';') if s.strip()]: + try: + cursor.execute(stmt) + except pymysql.Error as e: + if e.args[0] not in [1007, 1050]: + print(f' 警告: {e.args[1]}') + print(f' {desc}完成!') + +def rebuild(): + print('=' * 50) + print('星域故事汇 - 删库重建') + print('=' * 50) + + conn = pymysql.connect(**DB_CONFIG) + cur = conn.cursor() + + # 删库 + print('删除旧数据库...') + cur.execute('DROP DATABASE IF EXISTS stardom_story') + conn.commit() + print(' 删除完成!') + + # 重建 + schema_sql = read_sql_file('schema.sql') + execute_sql(cur, schema_sql, '创建数据库表结构') + conn.commit() + + seed1 = read_sql_file('seed_stories_part1.sql') + execute_sql(cur, seed1, '导入种子数据(第1部分)') + conn.commit() + + seed2 = read_sql_file('seed_stories_part2.sql') + execute_sql(cur, seed2, '导入种子数据(第2部分)') + conn.commit() + + print('\n数据库重建完成!') + cur.close() + conn.close() + +if __name__ == '__main__': + rebuild() diff --git a/server/sql/schema.sql b/server/sql/schema.sql index eb6056a..43e8327 100644 --- a/server/sql/schema.sql +++ b/server/sql/schema.sql @@ -1,107 +1,158 @@ --- 星域故事汇数据库初始化脚本 --- 创建数据库 -CREATE DATABASE IF NOT EXISTS stardom_story DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +-- ============================================ +-- 星域故事汇数据库结构 +-- 基于实际数据库导出,共7张表 +-- ============================================ +CREATE DATABASE IF NOT EXISTS stardom_story DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE stardom_story; --- 故事主表 -CREATE TABLE IF NOT EXISTS stories ( - id INT PRIMARY KEY AUTO_INCREMENT, - title VARCHAR(100) NOT NULL COMMENT '故事标题', - cover_url VARCHAR(255) DEFAULT '' COMMENT '封面图URL', - description TEXT COMMENT '故事简介', - author_id INT DEFAULT 0 COMMENT '作者ID,0表示官方', - category VARCHAR(50) NOT NULL COMMENT '故事分类', - play_count INT DEFAULT 0 COMMENT '游玩次数', - like_count INT DEFAULT 0 COMMENT '点赞数', - is_featured BOOLEAN DEFAULT FALSE COMMENT '是否精选', - status TINYINT DEFAULT 1 COMMENT '状态:0下架 1上架', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_category (category), - INDEX idx_featured (is_featured), - INDEX idx_play_count (play_count) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事主表'; - --- 故事节点表 -CREATE TABLE IF NOT EXISTS story_nodes ( - id INT PRIMARY KEY AUTO_INCREMENT, - story_id INT NOT NULL COMMENT '故事ID', - node_key VARCHAR(50) NOT NULL COMMENT '节点唯一标识', - content TEXT NOT NULL COMMENT '节点内容文本', - speaker VARCHAR(50) DEFAULT '' COMMENT '说话角色名', - background_image VARCHAR(255) DEFAULT '' COMMENT '背景图URL', - character_image VARCHAR(255) DEFAULT '' COMMENT '角色立绘URL', - bgm VARCHAR(255) DEFAULT '' COMMENT '背景音乐', - is_ending BOOLEAN DEFAULT FALSE COMMENT '是否为结局节点', - ending_name VARCHAR(100) DEFAULT '' COMMENT '结局名称', - ending_score INT DEFAULT 0 COMMENT '结局评分', - ending_type VARCHAR(20) DEFAULT '' COMMENT '结局类型:good/bad/normal/hidden', - sort_order INT DEFAULT 0 COMMENT '排序', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - INDEX idx_story_id (story_id), - INDEX idx_node_key (story_id, node_key), - FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事节点表'; - --- 故事选项表 -CREATE TABLE IF NOT EXISTS story_choices ( - id INT PRIMARY KEY AUTO_INCREMENT, - node_id INT NOT NULL COMMENT '所属节点ID', - story_id INT NOT NULL COMMENT '故事ID(冗余,便于查询)', - text VARCHAR(200) NOT NULL COMMENT '选项文本', - next_node_key VARCHAR(50) NOT NULL COMMENT '下一个节点key', - sort_order INT DEFAULT 0 COMMENT '排序', - is_locked BOOLEAN DEFAULT FALSE COMMENT '是否锁定(需广告解锁)', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - INDEX idx_node_id (node_id), - INDEX idx_story_id (story_id), - FOREIGN KEY (node_id) REFERENCES story_nodes(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事选项表'; - --- 用户表 -CREATE TABLE IF NOT EXISTS users ( - id INT PRIMARY KEY AUTO_INCREMENT, - openid VARCHAR(100) UNIQUE NOT NULL COMMENT '微信openid', - nickname VARCHAR(100) DEFAULT '' COMMENT '昵称', - avatar_url VARCHAR(255) DEFAULT '' COMMENT '头像URL', - gender TINYINT DEFAULT 0 COMMENT '性别:0未知 1男 2女', - total_play_count INT DEFAULT 0 COMMENT '总游玩次数', - total_endings INT DEFAULT 0 COMMENT '解锁结局数', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_openid (openid) +-- ============================================ +-- 1. 用户表 +-- ============================================ +CREATE TABLE IF NOT EXISTS `users` ( + `id` INT NOT NULL AUTO_INCREMENT, + `openid` VARCHAR(100) NOT NULL COMMENT '微信openid', + `nickname` VARCHAR(100) DEFAULT '' COMMENT '昵称', + `avatar_url` VARCHAR(255) DEFAULT '' COMMENT '头像URL', + `gender` TINYINT DEFAULT 0 COMMENT '性别:0未知 1男 2女', + `total_play_count` INT DEFAULT 0 COMMENT '总游玩次数', + `total_endings` INT DEFAULT 0 COMMENT '解锁结局数', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `openid` (`openid`), + KEY `idx_openid` (`openid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; --- 用户进度表 -CREATE TABLE IF NOT EXISTS user_progress ( - id INT PRIMARY KEY AUTO_INCREMENT, - user_id INT NOT NULL COMMENT '用户ID', - story_id INT NOT NULL COMMENT '故事ID', - current_node_key VARCHAR(50) DEFAULT 'start' COMMENT '当前节点', - is_completed BOOLEAN DEFAULT FALSE COMMENT '是否完成', - ending_reached VARCHAR(100) DEFAULT '' COMMENT '达成的结局', - is_liked BOOLEAN DEFAULT FALSE COMMENT '是否点赞', - is_collected BOOLEAN DEFAULT FALSE COMMENT '是否收藏', - play_count INT DEFAULT 1 COMMENT '游玩次数', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY uk_user_story (user_id, story_id), - INDEX idx_user_id (user_id), - INDEX idx_story_id (story_id), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE +-- ============================================ +-- 2. 故事主表 +-- ============================================ +CREATE TABLE IF NOT EXISTS `stories` ( + `id` INT NOT NULL AUTO_INCREMENT, + `title` VARCHAR(100) NOT NULL COMMENT '故事标题', + `cover_url` VARCHAR(255) DEFAULT '' COMMENT '封面图URL', + `description` TEXT COMMENT '故事简介', + `author_id` INT DEFAULT 0 COMMENT '作者ID,0表示官方', + `category` VARCHAR(50) NOT NULL COMMENT '故事分类', + `play_count` INT DEFAULT 0 COMMENT '游玩次数', + `like_count` INT DEFAULT 0 COMMENT '点赞数', + `is_featured` TINYINT(1) DEFAULT 0 COMMENT '是否精选', + `status` TINYINT DEFAULT 1 COMMENT '状态:0下架 1上架', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_category` (`category`), + KEY `idx_featured` (`is_featured`), + KEY `idx_play_count` (`play_count`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事主表'; + +-- ============================================ +-- 3. 故事节点表 +-- ============================================ +CREATE TABLE IF NOT EXISTS `story_nodes` ( + `id` INT NOT NULL AUTO_INCREMENT, + `story_id` INT NOT NULL COMMENT '故事ID', + `node_key` VARCHAR(50) NOT NULL COMMENT '节点唯一标识', + `content` TEXT NOT NULL COMMENT '节点内容文本', + `speaker` VARCHAR(50) DEFAULT '' COMMENT '说话角色名', + `background_image` VARCHAR(255) DEFAULT '' COMMENT '背景图URL', + `character_image` VARCHAR(255) DEFAULT '' COMMENT '角色立绘URL', + `bgm` VARCHAR(255) DEFAULT '' COMMENT '背景音乐', + `is_ending` TINYINT(1) DEFAULT 0 COMMENT '是否为结局节点', + `ending_name` VARCHAR(100) DEFAULT '' COMMENT '结局名称', + `ending_score` INT DEFAULT 0 COMMENT '结局评分', + `ending_type` VARCHAR(20) DEFAULT '' COMMENT '结局类型:good/bad/normal/hidden', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_story_id` (`story_id`), + KEY `idx_node_key` (`story_id`, `node_key`), + CONSTRAINT `story_nodes_ibfk_1` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事节点表'; + +-- ============================================ +-- 4. 故事选项表 +-- ============================================ +CREATE TABLE IF NOT EXISTS `story_choices` ( + `id` INT NOT NULL AUTO_INCREMENT, + `node_id` INT NOT NULL COMMENT '所属节点ID', + `story_id` INT NOT NULL COMMENT '故事ID(冗余,便于查询)', + `text` VARCHAR(200) NOT NULL COMMENT '选项文本', + `next_node_key` VARCHAR(50) NOT NULL COMMENT '下一个节点key', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `is_locked` TINYINT(1) DEFAULT 0 COMMENT '是否锁定(需广告解锁)', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_node_id` (`node_id`), + KEY `idx_story_id` (`story_id`), + CONSTRAINT `story_choices_ibfk_1` FOREIGN KEY (`node_id`) REFERENCES `story_nodes` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事选项表'; + +-- ============================================ +-- 5. 用户进度表 +-- ============================================ +CREATE TABLE IF NOT EXISTS `user_progress` ( + `id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL COMMENT '用户ID', + `story_id` INT NOT NULL COMMENT '故事ID', + `current_node_key` VARCHAR(50) DEFAULT 'start' COMMENT '当前节点', + `is_completed` TINYINT(1) DEFAULT 0 COMMENT '是否完成', + `ending_reached` VARCHAR(100) DEFAULT '' COMMENT '达成的结局', + `is_liked` TINYINT(1) DEFAULT 0 COMMENT '是否点赞', + `is_collected` TINYINT(1) DEFAULT 0 COMMENT '是否收藏', + `play_count` INT DEFAULT 1 COMMENT '游玩次数', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_story` (`user_id`, `story_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_story_id` (`story_id`), + CONSTRAINT `user_progress_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `user_progress_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户进度表'; --- 用户结局收集表 -CREATE TABLE IF NOT EXISTS user_endings ( - id INT PRIMARY KEY AUTO_INCREMENT, - user_id INT NOT NULL, - story_id INT NOT NULL, - ending_name VARCHAR(100) NOT NULL, - ending_score INT DEFAULT 0, - unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uk_user_ending (user_id, story_id, ending_name), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE +-- ============================================ +-- 6. 用户结局收集表 +-- ============================================ +CREATE TABLE IF NOT EXISTS `user_endings` ( + `id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL, + `story_id` INT NOT NULL, + `ending_name` VARCHAR(100) NOT NULL, + `ending_score` INT DEFAULT 0, + `unlocked_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_ending` (`user_id`, `story_id`, `ending_name`), + KEY `story_id` (`story_id`), + CONSTRAINT `user_endings_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `user_endings_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户结局收集表'; + +-- ============================================ +-- 7. AI改写草稿表 +-- ============================================ +CREATE TABLE IF NOT EXISTS `story_drafts` ( + `id` INT NOT NULL AUTO_INCREMENT, + `user_id` INT NOT NULL COMMENT '用户ID', + `story_id` INT NOT NULL COMMENT '原故事ID', + `title` VARCHAR(100) DEFAULT '' COMMENT '草稿标题', + `path_history` JSON DEFAULT NULL COMMENT '用户之前的选择路径', + `current_node_key` VARCHAR(50) DEFAULT '' COMMENT '改写起始节点', + `current_content` TEXT COMMENT '当前节点内容', + `user_prompt` VARCHAR(500) NOT NULL COMMENT '用户改写指令', + `ai_nodes` JSON DEFAULT NULL COMMENT 'AI生成的新节点', + `entry_node_key` VARCHAR(50) DEFAULT '' COMMENT '入口节点', + `tokens_used` INT DEFAULT 0 COMMENT '消耗token数', + `status` ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending' COMMENT '状态', + `error_message` VARCHAR(500) DEFAULT '' COMMENT '失败原因', + `is_read` TINYINT(1) DEFAULT 0 COMMENT '用户是否已查看', + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + `completed_at` TIMESTAMP NULL DEFAULT NULL COMMENT '完成时间', + PRIMARY KEY (`id`), + KEY `idx_user` (`user_id`), + KEY `idx_story` (`story_id`), + KEY `idx_status` (`status`), + KEY `idx_user_unread` (`user_id`, `is_read`), + CONSTRAINT `story_drafts_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `story_drafts_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI改写草稿表'; diff --git a/server/sql/seed_stories_part1.sql b/server/sql/seed_stories_part1.sql index 29a9e0f..3764c28 100644 --- a/server/sql/seed_stories_part1.sql +++ b/server/sql/seed_stories_part1.sql @@ -1,7 +1,15 @@ --- 10个种子故事数据 +-- 种子数据:测试用户 + 10个故事 USE stardom_story; +-- ============================================ +-- 测试用户 +-- ============================================ +INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES +(1, 'test_user', '测试用户', '', 0, 0, 0); + +-- ============================================ -- 1. 都市言情:《总裁的替身新娘》 +-- ============================================ INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES (1, '总裁的替身新娘', '', '一场阴差阳错的婚礼,让平凡的你成为了霸道总裁的替身新娘。他冷漠、神秘,却在深夜对你展现出不一样的温柔...', '都市言情', 15680, 3420, TRUE);