feat: 添加测试用户到种子数据, AI改写功能优化, 前端联调修复
This commit is contained in:
@@ -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改写中间章节,异步提交到草稿箱
|
* AI改写中间章节,异步提交到草稿箱
|
||||||
* @returns {Object|null} 成功返回草稿ID,失败返回 null
|
* @returns {Object|null} 成功返回草稿ID,失败返回 null
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export default class EndingScene extends BaseScene {
|
|||||||
this.rewritePrompt = '';
|
this.rewritePrompt = '';
|
||||||
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢'];
|
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢'];
|
||||||
this.selectedTag = -1;
|
this.selectedTag = -1;
|
||||||
|
// AI续写面板
|
||||||
|
this.showContinuePanel = false;
|
||||||
|
this.continuePrompt = '';
|
||||||
|
this.continueTags = ['故事未完', '新的冒险', '多年以后', '意外转折', '番外篇'];
|
||||||
|
this.selectedContinueTag = -1;
|
||||||
// 改写历史
|
// 改写历史
|
||||||
this.rewriteHistory = [];
|
this.rewriteHistory = [];
|
||||||
this.currentHistoryIndex = -1;
|
this.currentHistoryIndex = -1;
|
||||||
@@ -85,6 +90,10 @@ export default class EndingScene extends BaseScene {
|
|||||||
if (this.showRewritePanel) {
|
if (this.showRewritePanel) {
|
||||||
this.renderRewritePanel(ctx);
|
this.renderRewritePanel(ctx);
|
||||||
}
|
}
|
||||||
|
// AI续写面板
|
||||||
|
if (this.showContinuePanel) {
|
||||||
|
this.renderContinuePanel(ctx);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderBackground(ctx) {
|
renderBackground(ctx) {
|
||||||
@@ -257,15 +266,17 @@ export default class EndingScene extends BaseScene {
|
|||||||
const buttonHeight = 38;
|
const buttonHeight = 38;
|
||||||
const buttonMargin = 8;
|
const buttonMargin = 8;
|
||||||
const startY = this.screenHeight - 220;
|
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 remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased;
|
||||||
const aiBtnText = remaining > 0 ? '✨ AI改写结局' : '⚠️ 次数不足';
|
const rewriteBtnText = remaining > 0 ? '✨ AI改写' : '⚠️ 次数不足';
|
||||||
this.renderGradientButton(ctx, padding, startY, this.screenWidth - padding * 2, buttonHeight, aiBtnText, ['#a855f7', '#ec4899']);
|
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 row2Y = startY + buttonHeight + buttonMargin;
|
||||||
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
|
|
||||||
this.renderGradientButton(ctx, padding, row2Y, buttonWidth, buttonHeight, '分享结局', ['#ff6b6b', '#ffd700']);
|
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 };
|
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) {
|
roundRect(ctx, x, y, width, height, radius) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -610,6 +798,12 @@ export default class EndingScene extends BaseScene {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果续写面板打开,优先处理
|
||||||
|
if (this.showContinuePanel) {
|
||||||
|
this.handleContinuePanelTouch(x, y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.showButtons) return;
|
if (!this.showButtons) return;
|
||||||
|
|
||||||
const padding = 15;
|
const padding = 15;
|
||||||
@@ -618,12 +812,18 @@ export default class EndingScene extends BaseScene {
|
|||||||
const startY = this.screenHeight - 220;
|
const startY = this.screenHeight - 220;
|
||||||
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
|
const buttonWidth = (this.screenWidth - padding * 2 - buttonMargin) / 2;
|
||||||
|
|
||||||
// AI改写按钮
|
// AI改写按钮(左)
|
||||||
if (this.isInRect(x, y, padding, startY, this.screenWidth - padding * 2, buttonHeight)) {
|
if (this.isInRect(x, y, padding, startY, buttonWidth, buttonHeight)) {
|
||||||
this.handleAIRewrite();
|
this.handleAIRewrite();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI续写按钮(右)
|
||||||
|
if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, startY, buttonWidth, buttonHeight)) {
|
||||||
|
this.handleAIContinue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 分享按钮
|
// 分享按钮
|
||||||
const row2Y = startY + buttonHeight + buttonMargin;
|
const row2Y = startY + buttonHeight + buttonMargin;
|
||||||
if (this.isInRect(x, y, padding, row2Y, buttonWidth, buttonHeight)) {
|
if (this.isInRect(x, y, padding, row2Y, buttonWidth, buttonHeight)) {
|
||||||
@@ -696,6 +896,20 @@ export default class EndingScene extends BaseScene {
|
|||||||
this.selectedTag = -1;
|
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) {
|
handleRewritePanelTouch(x, y) {
|
||||||
// 点击标签
|
// 点击标签
|
||||||
if (this.tagRects) {
|
if (this.tagRects) {
|
||||||
@@ -734,6 +948,44 @@ export default class EndingScene extends BaseScene {
|
|||||||
return false;
|
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() {
|
showCustomInput() {
|
||||||
wx.showModal({
|
wx.showModal({
|
||||||
title: '输入改写想法',
|
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) {
|
async callAIRewrite(prompt) {
|
||||||
// 检查配额
|
// 检查配额
|
||||||
const remaining = this.aiQuota.daily - this.aiQuota.used + this.aiQuota.purchased;
|
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.isRewriting = true;
|
||||||
this.rewriteProgress = 0;
|
|
||||||
|
|
||||||
// 显示加载动画
|
// 显示加载动画
|
||||||
wx.showLoading({
|
wx.showLoading({
|
||||||
title: 'AI创作中...',
|
title: '提交中...',
|
||||||
mask: true
|
mask: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// 模拟进度条效果
|
|
||||||
const progressInterval = setInterval(() => {
|
|
||||||
this.rewriteProgress += Math.random() * 20;
|
|
||||||
if (this.rewriteProgress > 90) this.rewriteProgress = 90;
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
try {
|
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.storyId,
|
||||||
this.ending,
|
this.ending,
|
||||||
prompt
|
prompt,
|
||||||
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
|
||||||
this.rewriteProgress = 100;
|
|
||||||
wx.hideLoading();
|
wx.hideLoading();
|
||||||
|
|
||||||
if (result && result.content) {
|
if (result && result.draftId) {
|
||||||
// 记录改写历史
|
|
||||||
this.rewriteHistory.push({
|
|
||||||
prompt: prompt,
|
|
||||||
content: result.content,
|
|
||||||
timestamp: Date.now()
|
|
||||||
});
|
|
||||||
this.currentHistoryIndex = this.rewriteHistory.length - 1;
|
|
||||||
|
|
||||||
// 扣除配额
|
// 扣除配额
|
||||||
this.aiQuota.used += 1;
|
this.aiQuota.used += 1;
|
||||||
|
|
||||||
// 成功提示
|
// 提交成功提示
|
||||||
wx.showToast({
|
wx.showModal({
|
||||||
title: '改写成功!',
|
title: '提交成功',
|
||||||
icon: 'success',
|
content: 'AI正在后台生成新结局,完成后会通知您。\n您可以在草稿箱中查看。',
|
||||||
duration: 1500
|
showCancel: false,
|
||||||
|
confirmText: '知道了'
|
||||||
});
|
});
|
||||||
|
|
||||||
// 延迟跳转到故事场景播放新内容
|
|
||||||
setTimeout(() => {
|
|
||||||
this.main.sceneManager.switchScene('story', {
|
|
||||||
storyId: this.storyId,
|
|
||||||
aiContent: result
|
|
||||||
});
|
|
||||||
}, 1500);
|
|
||||||
} else {
|
} else {
|
||||||
wx.showToast({ title: '改写失败,请重试', icon: 'none' });
|
wx.showToast({ title: '提交失败,请重试', icon: 'none' });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
clearInterval(progressInterval);
|
|
||||||
wx.hideLoading();
|
wx.hideLoading();
|
||||||
console.error('改写失败:', error);
|
console.error('改写失败:', error);
|
||||||
wx.showToast({ title: error.message || '网络错误', icon: 'none' });
|
wx.showToast({ title: error.message || '网络错误', icon: 'none' });
|
||||||
} finally {
|
} finally {
|
||||||
this.isRewriting = false;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -218,7 +218,9 @@ export default class StoryScene extends BaseScene {
|
|||||||
|
|
||||||
const padding = 16;
|
const padding = 16;
|
||||||
let y = 100 - this.recapScrollY;
|
let y = 100 - this.recapScrollY;
|
||||||
const pathHistory = this.recapData?.pathHistory || [];
|
const rawPathHistory = this.recapData?.pathHistory;
|
||||||
|
// 兼容结局续写(pathHistory 是对象而非数组)
|
||||||
|
const pathHistory = Array.isArray(rawPathHistory) ? rawPathHistory : [];
|
||||||
|
|
||||||
// 保存卡片位置用于点击检测
|
// 保存卡片位置用于点击检测
|
||||||
this.recapCardRects = [];
|
this.recapCardRects = [];
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// API基础地址(开发环境)
|
// API基础地址(开发环境)
|
||||||
const BASE_URL = 'http://localhost:3000/api';
|
const BASE_URL = 'https://express-0a1p-230010-4-1408549115.sh.run.tcloudbase.com/api';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送HTTP请求
|
* 发送HTTP请求
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"appid": "wx27be06bc3365e84b",
|
"appid": "wx772e2f0fbc498020",
|
||||||
"compileType": "game",
|
"compileType": "game",
|
||||||
"projectname": "stardom-story",
|
"projectname": "stardom-story",
|
||||||
"setting": {
|
"setting": {
|
||||||
|
|||||||
@@ -32,6 +32,24 @@ class CreateDraftRequest(BaseModel):
|
|||||||
prompt: str
|
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):
|
class DraftResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
storyId: int
|
storyId: int
|
||||||
@@ -120,6 +138,174 @@ async def process_ai_rewrite(draft_id: int):
|
|||||||
pass
|
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 路由 ============
|
# ============ API 路由 ============
|
||||||
|
|
||||||
@router.post("")
|
@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("")
|
@router.get("")
|
||||||
async def get_drafts(
|
async def get_drafts(
|
||||||
userId: int,
|
userId: int,
|
||||||
|
|||||||
@@ -274,6 +274,139 @@ class AIService:
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return None
|
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]:
|
def _parse_branch_json(self, content: str) -> Optional[Dict]:
|
||||||
"""解析AI返回的分支JSON"""
|
"""解析AI返回的分支JSON"""
|
||||||
print(f"[_parse_branch_json] 开始解析,内容长度={len(content)}")
|
print(f"[_parse_branch_json] 开始解析,内容长度={len(content)}")
|
||||||
|
|||||||
29
server/sql/add_test_user.py
Normal file
29
server/sql/add_test_user.py
Normal file
@@ -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()
|
||||||
@@ -9,12 +9,25 @@ from pathlib import Path
|
|||||||
# 获取当前脚本所在目录
|
# 获取当前脚本所在目录
|
||||||
SQL_DIR = Path(__file__).parent
|
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 = {
|
DB_CONFIG = {
|
||||||
'host': os.getenv('DB_HOST', 'localhost'),
|
'host': env_config.get('DB_HOST', os.getenv('DB_HOST', 'localhost')),
|
||||||
'port': int(os.getenv('DB_PORT', 3306)),
|
'port': int(env_config.get('DB_PORT', os.getenv('DB_PORT', 3306))),
|
||||||
'user': os.getenv('DB_USER', 'root'),
|
'user': env_config.get('DB_USER', os.getenv('DB_USER', 'root')),
|
||||||
'password': os.getenv('DB_PASSWORD', '123456'),
|
'password': env_config.get('DB_PASSWORD', os.getenv('DB_PASSWORD', '')),
|
||||||
'charset': 'utf8mb4'
|
'charset': 'utf8mb4'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
74
server/sql/rebuild_db.py
Normal file
74
server/sql/rebuild_db.py
Normal file
@@ -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()
|
||||||
@@ -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;
|
USE stardom_story;
|
||||||
|
|
||||||
-- 故事主表
|
-- ============================================
|
||||||
CREATE TABLE IF NOT EXISTS stories (
|
-- 1. 用户表
|
||||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
-- ============================================
|
||||||
title VARCHAR(100) NOT NULL COMMENT '故事标题',
|
CREATE TABLE IF NOT EXISTS `users` (
|
||||||
cover_url VARCHAR(255) DEFAULT '' COMMENT '封面图URL',
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
description TEXT COMMENT '故事简介',
|
`openid` VARCHAR(100) NOT NULL COMMENT '微信openid',
|
||||||
author_id INT DEFAULT 0 COMMENT '作者ID,0表示官方',
|
`nickname` VARCHAR(100) DEFAULT '' COMMENT '昵称',
|
||||||
category VARCHAR(50) NOT NULL COMMENT '故事分类',
|
`avatar_url` VARCHAR(255) DEFAULT '' COMMENT '头像URL',
|
||||||
play_count INT DEFAULT 0 COMMENT '游玩次数',
|
`gender` TINYINT DEFAULT 0 COMMENT '性别:0未知 1男 2女',
|
||||||
like_count INT DEFAULT 0 COMMENT '点赞数',
|
`total_play_count` INT DEFAULT 0 COMMENT '总游玩次数',
|
||||||
is_featured BOOLEAN DEFAULT FALSE COMMENT '是否精选',
|
`total_endings` INT DEFAULT 0 COMMENT '解锁结局数',
|
||||||
status TINYINT DEFAULT 1 COMMENT '状态:0下架 1上架',
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
PRIMARY KEY (`id`),
|
||||||
INDEX idx_category (category),
|
UNIQUE KEY `openid` (`openid`),
|
||||||
INDEX idx_featured (is_featured),
|
KEY `idx_openid` (`openid`)
|
||||||
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)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||||
|
|
||||||
-- 用户进度表
|
-- ============================================
|
||||||
CREATE TABLE IF NOT EXISTS user_progress (
|
-- 2. 故事主表
|
||||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
-- ============================================
|
||||||
user_id INT NOT NULL COMMENT '用户ID',
|
CREATE TABLE IF NOT EXISTS `stories` (
|
||||||
story_id INT NOT NULL COMMENT '故事ID',
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
current_node_key VARCHAR(50) DEFAULT 'start' COMMENT '当前节点',
|
`title` VARCHAR(100) NOT NULL COMMENT '故事标题',
|
||||||
is_completed BOOLEAN DEFAULT FALSE COMMENT '是否完成',
|
`cover_url` VARCHAR(255) DEFAULT '' COMMENT '封面图URL',
|
||||||
ending_reached VARCHAR(100) DEFAULT '' COMMENT '达成的结局',
|
`description` TEXT COMMENT '故事简介',
|
||||||
is_liked BOOLEAN DEFAULT FALSE COMMENT '是否点赞',
|
`author_id` INT DEFAULT 0 COMMENT '作者ID,0表示官方',
|
||||||
is_collected BOOLEAN DEFAULT FALSE COMMENT '是否收藏',
|
`category` VARCHAR(50) NOT NULL COMMENT '故事分类',
|
||||||
play_count INT DEFAULT 1 COMMENT '游玩次数',
|
`play_count` INT DEFAULT 0 COMMENT '游玩次数',
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
`like_count` INT DEFAULT 0 COMMENT '点赞数',
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
`is_featured` TINYINT(1) DEFAULT 0 COMMENT '是否精选',
|
||||||
UNIQUE KEY uk_user_story (user_id, story_id),
|
`status` TINYINT DEFAULT 1 COMMENT '状态:0下架 1上架',
|
||||||
INDEX idx_user_id (user_id),
|
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
INDEX idx_story_id (story_id),
|
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
PRIMARY KEY (`id`),
|
||||||
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
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='用户进度表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户进度表';
|
||||||
|
|
||||||
-- 用户结局收集表
|
-- ============================================
|
||||||
CREATE TABLE IF NOT EXISTS user_endings (
|
-- 6. 用户结局收集表
|
||||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
-- ============================================
|
||||||
user_id INT NOT NULL,
|
CREATE TABLE IF NOT EXISTS `user_endings` (
|
||||||
story_id INT NOT NULL,
|
`id` INT NOT NULL AUTO_INCREMENT,
|
||||||
ending_name VARCHAR(100) NOT NULL,
|
`user_id` INT NOT NULL,
|
||||||
ending_score INT DEFAULT 0,
|
`story_id` INT NOT NULL,
|
||||||
unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
`ending_name` VARCHAR(100) NOT NULL,
|
||||||
UNIQUE KEY uk_user_ending (user_id, story_id, ending_name),
|
`ending_score` INT DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
`unlocked_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (story_id) REFERENCES stories(id) ON DELETE CASCADE
|
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='用户结局收集表';
|
) 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改写草稿表';
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
-- 10个种子故事数据
|
-- 种子数据:测试用户 + 10个故事
|
||||||
USE stardom_story;
|
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. 都市言情:《总裁的替身新娘》
|
-- 1. 都市言情:《总裁的替身新娘》
|
||||||
|
-- ============================================
|
||||||
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
INSERT INTO stories (id, title, cover_url, description, category, play_count, like_count, is_featured) VALUES
|
||||||
(1, '总裁的替身新娘', '', '一场阴差阳错的婚礼,让平凡的你成为了霸道总裁的替身新娘。他冷漠、神秘,却在深夜对你展现出不一样的温柔...', '都市言情', 15680, 3420, TRUE);
|
(1, '总裁的替身新娘', '', '一场阴差阳错的婚礼,让平凡的你成为了霸道总裁的替身新娘。他冷漠、神秘,却在深夜对你展现出不一样的温柔...', '都市言情', 15680, 3420, TRUE);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user