16 Commits

Author SHA1 Message Date
wangwuww111
411110ce0c feat: 创作中心改造 - 我的改写/续写Tab展示已发布作品 2026-03-11 23:17:18 +08:00
wangwuww111
e101e8721b feat: 恢复http.js的Token认证和params处理功能 2026-03-11 22:50:53 +08:00
4ac47c8474 feat: AI改写功能集成角色数据 + UI优化
- 新增story_characters表和seed_characters.sql种子数据(27个角色)
- AI改写/续写功能注入角色信息(性别/年龄/外貌/性格)
- 首页UI下移避让微信退出按钮
- 个人中心页面布局重构
2026-03-11 18:41:56 +08:00
2470cea7e4 fix: 修复微信登录SSL验证失败问题,添加诊断日志 2026-03-11 16:24:37 +08:00
c82c2ec8df feat: 添加PyJWT依赖,保留本地http双环境配置 2026-03-11 13:38:11 +08:00
wangwuww111
eac6b2fd1f feat: 添加微信授权登录和修改昵称功能 2026-03-11 12:10:19 +08:00
906b5649f7 feat: 支持本地/云托管双模式切换,添加测试用户2-19 2026-03-11 10:07:07 +08:00
wangwuww111
2b941cc4e0 feat: 结局AI改写支持pathHistory回放和完成通知 2026-03-10 14:19:13 +08:00
wangwuww111
aa23db8a89 Merge branch 'master' of http://8.141.6.23:3000/yunqueai/ai_game 2026-03-10 12:58:25 +08:00
wangwuww111
baf7dd1e2b feat: 游玩记录多版本功能 - 支持多版本记录存储和回放 - 相同路径自动去重只保留最新 - 版本列表支持删除功能 - AI草稿箱游玩不记录历史 - iOS日期格式兼容修复 2026-03-10 12:58:17 +08:00
b973c8bdbb fix: 前端配置调整 2026-03-10 10:14:55 +08:00
9948ccba8f feat: 添加测试用户到种子数据, AI改写功能优化, 前端联调修复 2026-03-09 23:00:15 +08:00
5e931424ab Merge remote master 2026-03-09 18:08:46 +08:00
c960f9fa79 fix: AI改写按钮位置优化 + 返回首页清除故事状态 2026-03-09 18:01:41 +08:00
wangwuww111
18db6a8cc6 feat: 完善AI改写草稿箱功能 - 修复重头游玩、评分、数据刷新等问题 2026-03-09 14:15:00 +08:00
wangwuww111
bbdccfa843 feat: AI中间章节改写功能 + 滚动优化 2026-03-06 13:16:54 +08:00
46 changed files with 5718 additions and 414 deletions

BIN
.gitignore vendored

Binary file not shown.

4
.idea/misc.xml generated
View File

@@ -1,9 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MavenRunner">
<option name="jreName" value="1.8" />
<option name="vmOptions" value="-DarchetypeCatalog=internal" />
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="corretto-17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>

View File

@@ -1,7 +1,7 @@
/**
* 故事数据管理器
*/
import { get, post } from '../utils/http';
import { get, post, request } from '../utils/http';
export default class StoryManager {
constructor() {
@@ -9,6 +9,7 @@ export default class StoryManager {
this.currentStory = null;
this.currentNodeKey = 'start';
this.categories = [];
this.pathHistory = []; // 记录用户走过的路径
}
/**
@@ -56,6 +57,7 @@ export default class StoryManager {
try {
this.currentStory = await get(`/stories/${storyId}`);
this.currentNodeKey = 'start';
this.pathHistory = []; // 重置路径历史
// 记录游玩次数
await post(`/stories/${storyId}/play`);
@@ -85,6 +87,14 @@ export default class StoryManager {
}
const choice = currentNode.choices[choiceIndex];
// 记录路径历史
this.pathHistory.push({
nodeKey: this.currentNodeKey,
content: currentNode.content,
choice: choice.text
});
this.currentNodeKey = choice.nextNodeKey;
return this.getCurrentNode();
@@ -118,6 +128,7 @@ export default class StoryManager {
*/
resetStory() {
this.currentNodeKey = 'start';
this.pathHistory = []; // 清空路径历史
}
/**
@@ -145,6 +156,174 @@ export default class StoryManager {
}
}
/**
* AI改写结局异步提交到草稿箱
*/
async rewriteEndingAsync(storyId, ending, prompt, userId) {
try {
// 先标记之前的未读草稿为已读
await this.markAllDraftsRead(userId);
console.log('[rewriteEndingAsync] pathHistory:', JSON.stringify(this.pathHistory));
const result = await post(`/drafts/ending`, {
userId: userId,
storyId: storyId,
endingName: ending?.name || '未知结局',
endingContent: ending?.content || '',
prompt: prompt,
pathHistory: this.pathHistory || [] // 传递游玩路径
}, { 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,
pathHistory: this.pathHistory || [] // 传递游玩路径
}, { 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
*/
async rewriteBranchAsync(storyId, prompt, userId) {
try {
// 先标记之前的未读草稿为已读,避免轮询弹出之前的通知
await this.markAllDraftsRead(userId);
const currentNode = this.getCurrentNode();
const result = await post(`/drafts`, {
userId: userId,
storyId: storyId,
currentNodeKey: this.currentNodeKey,
pathHistory: this.pathHistory,
currentContent: currentNode?.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;
}
}
/**
* 获取用户草稿列表
*/
async getDrafts(userId) {
try {
const result = await get(`/drafts?userId=${userId}`);
return result || [];
} catch (error) {
console.error('获取草稿列表失败:', error);
return [];
}
}
/**
* 检查是否有新完成的草稿
*/
async checkNewDrafts(userId) {
try {
const result = await get(`/drafts/check-new?userId=${userId}`);
return result || { hasNew: false, count: 0, drafts: [] };
} catch (error) {
console.error('检查新草稿失败:', error);
return { hasNew: false, count: 0, drafts: [] };
}
}
/**
* 批量标记所有未读草稿为已读
*/
async markAllDraftsRead(userId) {
try {
await request({ url: `/drafts/batch-read?userId=${userId}`, method: 'PUT' });
return true;
} catch (error) {
console.error('批量标记已读失败:', error);
return false;
}
}
/**
* 获取草稿详情
*/
async getDraftDetail(draftId) {
try {
const result = await get(`/drafts/${draftId}`);
return result;
} catch (error) {
console.error('获取草稿详情失败:', error);
return null;
}
}
/**
* 删除草稿
*/
async deleteDraft(draftId, userId) {
try {
const result = await request({
url: `/drafts/${draftId}?userId=${userId}`,
method: 'DELETE'
});
return true;
} catch (error) {
console.error('删除草稿失败:', error);
return false;
}
}
/**
* 从草稿加载并播放 AI 生成的内容
*/
loadDraftContent(draft) {
if (!draft || !draft.aiNodes) return null;
// 将 AI 生成的节点合并到当前故事
if (this.currentStory) {
Object.assign(this.currentStory.nodes, draft.aiNodes);
this.currentNodeKey = draft.entryNodeKey || 'branch_1';
return this.getCurrentNode();
}
return null;
}
/**
* AI续写故事
*/

View File

@@ -1,7 +1,7 @@
/**
* 用户数据管理器
*/
import { get, post } from '../utils/http';
import { get, post, put, del } from '../utils/http';
export default class UserManager {
constructor() {
@@ -9,50 +9,125 @@ export default class UserManager {
this.openid = null;
this.nickname = '';
this.avatarUrl = '';
this.token = '';
this.isLoggedIn = false;
}
/**
* 初始化用户
* 检查是否已登录(只检查本地缓存)
* @returns {boolean} 是否已登录
*/
checkLogin() {
const cached = wx.getStorageSync('userInfo');
if (cached && cached.userId && cached.token) {
this.userId = cached.userId;
this.openid = cached.openid;
this.nickname = cached.nickname || '游客';
this.avatarUrl = cached.avatarUrl || '';
this.token = cached.token;
this.isLoggedIn = true;
return true;
}
return false;
}
/**
* 初始化用户(只恢复缓存,不自动登录)
*/
async init() {
try {
// 尝试从本地存储恢复用户信息
const cached = wx.getStorageSync('userInfo');
if (cached) {
this.userId = cached.userId;
this.openid = cached.openid;
this.nickname = cached.nickname;
this.avatarUrl = cached.avatarUrl;
this.isLoggedIn = true;
return;
}
// 只检查本地缓存,不自动登录
this.checkLogin();
}
/**
* 执行登录(供登录按钮调用)
* @param {Object} userInfo - 微信用户信息(头像、昵称等),可选
*/
async doLogin(userInfo = null) {
try {
// 获取登录code
const { code } = await this.wxLogin();
// 调用后端登录接口
const result = await post('/user/login', { code });
// 调用后端登录接口,传入用户信息
const result = await post('/user/login', {
code,
userInfo: userInfo ? {
nickname: userInfo.nickName,
avatarUrl: userInfo.avatarUrl,
gender: userInfo.gender || 0
} : null
});
this.userId = result.userId;
this.openid = result.openid;
this.nickname = result.nickname || '游客';
this.avatarUrl = result.avatarUrl || '';
// 优先使用后端返回的,其次用授权获取的
this.nickname = result.nickname || (userInfo?.nickName) || '游客';
this.avatarUrl = result.avatarUrl || (userInfo?.avatarUrl) || '';
this.token = result.token || '';
this.isLoggedIn = true;
// 缓存用户信息
// 缓存用户信息(包含 token
wx.setStorageSync('userInfo', {
userId: this.userId,
openid: this.openid,
nickname: this.nickname,
avatarUrl: this.avatarUrl
avatarUrl: this.avatarUrl,
token: this.token
});
return {
userId: this.userId,
nickname: this.nickname,
avatarUrl: this.avatarUrl
};
} catch (error) {
console.error('用户初始化失败:', error);
// 使用临时身份
this.userId = 0;
this.nickname = '游客';
this.isLoggedIn = false;
console.error('登录失败:', error);
throw error;
}
}
/**
* 退出登录
*/
logout() {
this.userId = null;
this.openid = null;
this.nickname = '';
this.avatarUrl = '';
this.token = '';
this.isLoggedIn = false;
wx.removeStorageSync('userInfo');
}
/**
* 更新用户资料
*/
async updateProfile(nickname, avatarUrl) {
if (!this.isLoggedIn) return false;
try {
await post('/user/profile', {
nickname,
avatarUrl,
gender: 0
}, { params: { userId: this.userId } });
// 更新本地数据
this.nickname = nickname;
this.avatarUrl = avatarUrl;
// 更新缓存(保留 token
wx.setStorageSync('userInfo', {
userId: this.userId,
openid: this.openid,
nickname: this.nickname,
avatarUrl: this.avatarUrl,
token: this.token
});
return true;
} catch (e) {
console.error('更新资料失败:', e);
return false;
}
}
@@ -63,12 +138,16 @@ export default class UserManager {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('登录超时'));
}, 3000);
}, 5000);
wx.login({
success: (res) => {
clearTimeout(timeout);
resolve(res);
if (res.code) {
resolve(res);
} else {
reject(new Error('获取code失败'));
}
},
fail: (err) => {
clearTimeout(timeout);
@@ -191,4 +270,113 @@ export default class UserManager {
return [];
}
}
/**
* 获取已发布到创作中心的草稿
* @param {string} draftType - 草稿类型: rewrite/continue
*/
async getPublishedDrafts(draftType) {
if (!this.isLoggedIn) return [];
try {
console.log('[UserManager] 获取已发布草稿, userId:', this.userId, 'draftType:', draftType);
const res = await get('/drafts/published', { userId: this.userId, draftType });
console.log('[UserManager] 已发布草稿响应:', res);
return res || [];
} catch (e) {
console.error('获取已发布草稿失败:', e);
return [];
}
}
/**
* 发布草稿到创作中心
* @param {number} draftId - 草稿ID
*/
async publishDraft(draftId) {
if (!this.isLoggedIn) return false;
try {
await put(`/drafts/${draftId}/publish`, null, { params: { userId: this.userId } });
return true;
} catch (e) {
console.error('发布草稿失败:', e);
return false;
}
}
/**
* 从创作中心取消发布
* @param {number} draftId - 草稿ID
*/
async unpublishDraft(draftId) {
if (!this.isLoggedIn) return false;
try {
await put(`/drafts/${draftId}/unpublish`, null, { params: { userId: this.userId } });
return true;
} catch (e) {
console.error('取消发布失败:', e);
return false;
}
}
// ========== 游玩记录相关 ==========
/**
* 保存游玩记录
*/
async savePlayRecord(storyId, endingName, endingType, pathHistory) {
if (!this.isLoggedIn) return null;
try {
return await post('/user/play-record', {
userId: this.userId,
storyId,
endingName,
endingType: endingType || '',
pathHistory: pathHistory || []
});
} catch (e) {
console.error('保存游玩记录失败:', e);
return null;
}
}
/**
* 获取游玩记录列表
* @param {number} storyId - 可选指定故事ID获取该故事的所有记录
*/
async getPlayRecords(storyId = null) {
if (!this.isLoggedIn) return [];
try {
const params = { userId: this.userId };
if (storyId) params.storyId = storyId;
return await get('/user/play-records', params);
} catch (e) {
console.error('获取游玩记录失败:', e);
return [];
}
}
/**
* 获取单条记录详情
*/
async getPlayRecordDetail(recordId) {
if (!this.isLoggedIn) return null;
try {
return await get(`/user/play-records/${recordId}`);
} catch (e) {
console.error('获取记录详情失败:', e);
return null;
}
}
// 删除游玩记录
async deletePlayRecord(recordId) {
if (!this.isLoggedIn) return false;
try {
await del(`/user/play-records/${recordId}`);
return true;
} catch (e) {
console.error('删除记录失败:', e);
return false;
}
}
}

View File

@@ -38,13 +38,45 @@ export default class Main {
this.showLoading('正在加载...');
console.log('[Main] 开始初始化...');
// 用户初始化(失败不阻塞)
console.log('[Main] 初始化用户...');
await this.userManager.init().catch(e => {
console.warn('[Main] 用户初始化失败,使用游客模式:', e);
// 初始化云环境
console.log('[Main] 初始化云环境...');
wx.cloud.init({
env: 'prod-4gc9i2da1c70fc52', // 云环境ID需替换为实际值
traceUser: true
});
console.log('[Main] 用户初始化完成');
console.log('[Main] 云环境初始化完成');
// 检查用户是否已登录(只检查缓存,不自动登录)
console.log('[Main] 检查登录状态...');
const isLoggedIn = this.userManager.checkLogin();
console.log('[Main] 登录状态:', isLoggedIn ? '已登录' : '未登录');
// 隐藏加载界面
this.hideLoading();
if (!isLoggedIn) {
// 未登录,显示登录场景
console.log('[Main] 未登录,显示登录页面');
this.sceneManager.switchScene('login');
} else {
// 已登录,加载数据并进入首页
await this.loadAndEnterHome();
}
// 设置分享
this.setupShare();
} catch (error) {
console.error('[Main] 初始化失败:', error);
this.hideLoading();
this.showError('初始化失败,请重试');
}
}
// 加载数据并进入首页
async loadAndEnterHome() {
this.showLoading('正在加载...');
try {
// 加载故事列表
console.log('[Main] 加载故事列表...');
await this.storyManager.loadStoryList();
@@ -57,12 +89,15 @@ export default class Main {
this.sceneManager.switchScene('home');
console.log('[Main] 初始化完成,进入首页');
// 设置分享
this.setupShare();
// 启动草稿检查(仅登录用户)
if (this.userManager.isLoggedIn) {
this.startDraftChecker();
}
} catch (error) {
console.error('[Main] 初始化失败:', error);
this.hideLoading();
this.showError('初始化失败,请重试');
console.error('[Main] 加载失败:', error);
// 加载失败也进入首页,让用户可以重试
this.sceneManager.switchScene('home');
}
}
@@ -111,6 +146,67 @@ export default class Main {
});
}
// 启动草稿检查定时器
startDraftChecker() {
// 避免重复启动
if (this.draftCheckTimer) return;
console.log('[Main] 启动草稿检查定时器');
// 每30秒检查一次
this.draftCheckTimer = setInterval(async () => {
try {
if (!this.userManager.isLoggedIn) return;
// 如果结局页正在轮询,跳过全局检查
const currentScene = this.sceneManager.currentScene;
if (currentScene && currentScene.draftPollTimer) {
return;
}
const result = await this.storyManager.checkNewDrafts(this.userManager.userId);
if (result && result.hasNew && result.count > 0) {
console.log('[Main] 检测到新草稿:', result.count);
// 先标记为已读,避免重复弹窗
await this.storyManager.markAllDraftsRead(this.userManager.userId);
// 弹窗通知
wx.showModal({
title: 'AI改写完成',
content: `您有 ${result.count} 个新的AI改写已完成是否前往查看`,
confirmText: '查看',
cancelText: '稍后',
success: (res) => {
if (res.confirm) {
// 跳转到个人中心的草稿箱 tab
this.sceneManager.switchScene('profile', { tab: 1 });
} else {
// 点击稍后,如果当前在个人中心页面则刷新草稿列表
const currentScene = this.sceneManager.currentScene;
if (currentScene && currentScene.refreshDrafts) {
currentScene.refreshDrafts();
}
}
}
});
}
} catch (e) {
console.warn('[Main] 草稿检查失败:', e);
}
}, 30000);
}
// 停止草稿检查定时器
stopDraftChecker() {
if (this.draftCheckTimer) {
clearInterval(this.draftCheckTimer);
this.draftCheckTimer = null;
console.log('[Main] 停止草稿检查定时器');
}
}
// 设置分享
setupShare() {
wx.showShareMenu({

View File

@@ -6,8 +6,8 @@ import BaseScene from './BaseScene';
export default class AICreateScene extends BaseScene {
constructor(main, params) {
super(main, params);
this.currentTab = 0; // 0:改写 1:续写 2:创作
this.tabs = ['AI改写', 'AI续写', 'AI创作'];
this.currentTab = 0; // 0:我的改写 1:我的续写 2:AI创作
this.tabs = ['我的改写', '我的续写', 'AI创作'];
// 滚动
this.scrollY = 0;
@@ -17,8 +17,8 @@ export default class AICreateScene extends BaseScene {
this.hasMoved = false;
// 用户数据
this.recentStories = [];
this.aiHistory = [];
this.publishedRewrites = []; // 已发布的改写作品
this.publishedContinues = []; // 已发布的续写作品
this.quota = { daily: 3, used: 0, purchased: 0 };
// 创作表单
@@ -29,12 +29,7 @@ export default class AICreateScene extends BaseScene {
conflict: ''
};
// 选中的故事(用于改写/续写)
this.selectedStory = null;
// 快捷标签
this.rewriteTags = ['主角逆袭', '甜蜜HE', '虐心BE', '反转剧情', '意外重逢', '身份揭秘'];
this.continueTags = ['增加悬念', '感情升温', '冲突加剧', '真相大白', '误会解除'];
this.genreTags = ['都市言情', '古风宫廷', '悬疑推理', '校园青春', '修仙玄幻', '职场商战'];
}
@@ -44,10 +39,17 @@ export default class AICreateScene extends BaseScene {
async loadData() {
try {
// 加载最近游玩的故事
this.recentStories = await this.main.userManager.getRecentPlayed() || [];
// 加载AI创作历史
this.aiHistory = await this.main.userManager.getAIHistory() || [];
const userId = this.main.userManager.userId;
if (!userId) return;
// 加载已发布的改写作品
const rewriteRes = await this.main.userManager.getPublishedDrafts('rewrite');
this.publishedRewrites = rewriteRes || [];
// 加载已发布的续写作品
const continueRes = await this.main.userManager.getPublishedDrafts('continue');
this.publishedContinues = continueRes || [];
// 加载配额
const quotaData = await this.main.userManager.getAIQuota();
if (quotaData) this.quota = quotaData;
@@ -59,8 +61,10 @@ export default class AICreateScene extends BaseScene {
calculateMaxScroll() {
let contentHeight = 400;
if (this.currentTab === 0 || this.currentTab === 1) {
contentHeight = 300 + this.recentStories.length * 80;
if (this.currentTab === 0) {
contentHeight = 300 + this.publishedRewrites.length * 90;
} else if (this.currentTab === 1) {
contentHeight = 300 + this.publishedContinues.length * 90;
} else {
contentHeight = 600;
}
@@ -210,23 +214,10 @@ export default class AICreateScene extends BaseScene {
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('选择一个已玩过的故事AI帮你改写结局', this.screenWidth / 2, y + 25);
ctx.fillText('展示你从草稿箱发布的改写作品', this.screenWidth / 2, y + 25);
// 快捷标签
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('热门改写方向:', padding, y + 55);
const tagEndY = this.renderTags(ctx, this.rewriteTags, padding, y + 70, 'rewrite');
// 选择故事 - 位置根据标签高度动态调整
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('选择要改写的故事:', padding, tagEndY + 25);
this.renderStoryList(ctx, tagEndY + 40, 'rewrite');
// 作品列表
this.renderPublishedList(ctx, y + 50, this.publishedRewrites, 'rewrite');
}
renderContinueTab(ctx, startY) {
@@ -236,21 +227,10 @@ export default class AICreateScene extends BaseScene {
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('选择一个进行中的故事AI帮你续写剧情', this.screenWidth / 2, y + 25);
ctx.fillText('展示你从草稿箱发布的续写作品', this.screenWidth / 2, y + 25);
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('续写方向:', padding, y + 55);
const tagEndY = this.renderTags(ctx, this.continueTags, padding, y + 70, 'continue');
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'left';
ctx.fillText('选择要续写的故事:', padding, tagEndY + 25);
this.renderStoryList(ctx, tagEndY + 40, 'continue');
// 作品列表
this.renderPublishedList(ctx, y + 50, this.publishedContinues, 'continue');
}
renderCreateTab(ctx, startY) {
@@ -388,6 +368,84 @@ export default class AICreateScene extends BaseScene {
this.inputRects[field] = { x, y: y + this.scrollY, width, height, field };
}
renderPublishedList(ctx, startY, items, type) {
const padding = 15;
const cardHeight = 80;
const cardGap = 12;
if (!this.publishedRects) this.publishedRects = {};
this.publishedRects[type] = [];
if (!items || items.length === 0) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '13px sans-serif';
ctx.textAlign = 'center';
const tipText = type === 'rewrite'
? '暂无改写作品,去草稿箱发布吧'
: '暂无续写作品,去草稿箱发布吧';
ctx.fillText(tipText, this.screenWidth / 2, startY + 40);
// 跳转草稿箱按钮
const btnY = startY + 70;
const btnWidth = 120;
const btnX = (this.screenWidth - btnWidth) / 2;
ctx.fillStyle = 'rgba(168, 85, 247, 0.3)';
this.roundRect(ctx, btnX, btnY, btnWidth, 36, 18);
ctx.fill();
ctx.fillStyle = '#a855f7';
ctx.font = '13px sans-serif';
ctx.fillText('前往草稿箱', this.screenWidth / 2, btnY + 24);
this.gotoDraftsBtnRect = { x: btnX, y: btnY + this.scrollY, width: btnWidth, height: 36 };
return;
}
items.forEach((item, index) => {
const y = startY + index * (cardHeight + cardGap);
// 卡片背景
ctx.fillStyle = 'rgba(255,255,255,0.06)';
this.roundRect(ctx, padding, y, this.screenWidth - padding * 2, cardHeight, 12);
ctx.fill();
// 标题
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 14px sans-serif';
ctx.textAlign = 'left';
const title = item.title?.length > 15 ? item.title.substring(0, 15) + '...' : (item.title || '未命名作品');
ctx.fillText(title, padding + 15, y + 25);
// 原故事
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.font = '11px sans-serif';
ctx.fillText(`原故事:${item.storyTitle || '未知'}`, padding + 15, y + 45);
// 创作时间
ctx.fillText(item.createdAt || '', padding + 15, y + 65);
// 阅读按钮
const btnX = this.screenWidth - padding - 70;
const btnGradient = ctx.createLinearGradient(btnX, y + 25, btnX + 60, y + 25);
btnGradient.addColorStop(0, '#a855f7');
btnGradient.addColorStop(1, '#ec4899');
ctx.fillStyle = btnGradient;
this.roundRect(ctx, btnX, y + 25, 60, 30, 15);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('阅读', btnX + 30, y + 45);
this.publishedRects[type].push({
x: padding,
y: y + this.scrollY,
width: this.screenWidth - padding * 2,
height: cardHeight,
item,
btnRect: { x: btnX, y: y + 25 + this.scrollY, width: 60, height: 30 }
});
});
}
renderStoryList(ctx, startY, type) {
const padding = 15;
const cardHeight = 70;
@@ -550,7 +608,6 @@ export default class AICreateScene extends BaseScene {
if (this.currentTab !== tab.index) {
this.currentTab = tab.index;
this.scrollY = 0;
this.selectedStory = null;
this.calculateMaxScroll();
}
return;
@@ -561,14 +618,34 @@ export default class AICreateScene extends BaseScene {
// 调整y坐标考虑滚动
const scrolledY = y + this.scrollY;
// 标签点击
if (this.tagRects) {
const tagType = this.currentTab === 0 ? 'rewrite' : this.currentTab === 1 ? 'continue' : 'genre';
const tags = this.tagRects[tagType];
// 前往草稿箱按钮
if (this.gotoDraftsBtnRect && this.isInRect(x, scrolledY, this.gotoDraftsBtnRect)) {
this.main.sceneManager.switchScene('drafts');
return;
}
// 已发布作品点击(改写/续写Tab
if (this.currentTab < 2 && this.publishedRects) {
const type = this.currentTab === 0 ? 'rewrite' : 'continue';
const items = this.publishedRects[type];
if (items) {
for (const rect of items) {
// 阅读按钮点击
if (this.isInRect(x, scrolledY, rect.btnRect)) {
this.handleReadPublished(rect.item);
return;
}
}
}
}
// 标签点击只有创作Tab有标签
if (this.currentTab === 2 && this.tagRects) {
const tags = this.tagRects['genre'];
if (tags) {
for (const tag of tags) {
if (this.isInRect(x, scrolledY, tag)) {
this.handleTagSelect(tagType, tag);
this.handleTagSelect('genre', tag);
return;
}
}
@@ -586,26 +663,6 @@ export default class AICreateScene extends BaseScene {
}
}
// 故事列表点击
if (this.currentTab < 2 && this.storyRects) {
const type = this.currentTab === 0 ? 'rewrite' : 'continue';
const stories = this.storyRects[type];
if (stories) {
for (const rect of stories) {
if (this.isInRect(x, scrolledY, rect)) {
this.selectedStory = rect.story;
return;
}
}
}
}
// 操作按钮
if (this.actionBtnRect && this.isInRect(x, scrolledY, this.actionBtnRect)) {
this.handleAction(this.actionBtnRect.type);
return;
}
// 创作按钮
if (this.currentTab === 2 && this.createBtnRect && this.isInRect(x, scrolledY, this.createBtnRect)) {
this.handleCreate();
@@ -613,6 +670,15 @@ export default class AICreateScene extends BaseScene {
}
}
handleReadPublished(item) {
// 跳转到故事场景播放AI改写/续写的内容
this.main.sceneManager.switchScene('story', {
storyId: item.storyId,
draftId: item.id,
fromDrafts: true
});
}
isInRect(x, y, rect) {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
@@ -620,10 +686,6 @@ export default class AICreateScene extends BaseScene {
handleTagSelect(type, tag) {
if (type === 'genre') {
this.createForm.genre = tag.value;
} else if (type === 'rewrite') {
this.selectedRewriteTag = tag.index;
} else if (type === 'continue') {
this.selectedContinueTag = tag.index;
}
}

View File

@@ -71,8 +71,10 @@ export default class ChapterScene extends BaseScene {
const cardHeight = 85;
const gap = 12;
const headerHeight = 80;
const contentHeight = this.nodeList.length * (cardHeight + gap) + headerHeight;
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight + 20);
const bottomPadding = 50; // 底部留出空间
const contentHeight = this.nodeList.length * (cardHeight + gap) + headerHeight + bottomPadding;
this.maxScrollY = Math.max(0, contentHeight - this.screenHeight);
console.log('[ChapterScene] nodeList长度:', this.nodeList.length, 'contentHeight:', contentHeight, 'screenHeight:', this.screenHeight, 'maxScrollY:', this.maxScrollY);
}
update() {}
@@ -173,9 +175,17 @@ export default class ChapterScene extends BaseScene {
if (this.maxScrollY > 0) {
const scrollBarHeight = 50;
const scrollBarY = startY + (this.scrollY / this.maxScrollY) * (this.screenHeight - startY - scrollBarHeight - 20);
ctx.fillStyle = 'rgba(255,255,255,0.2)';
this.roundRect(ctx, this.screenWidth - 5, scrollBarY, 3, scrollBarHeight, 1.5);
ctx.fillStyle = 'rgba(255,255,255,0.4)';
this.roundRect(ctx, this.screenWidth - 8, scrollBarY, 5, scrollBarHeight, 2.5);
ctx.fill();
// 如果还没滚动到底部,显示提示
if (this.scrollY < this.maxScrollY - 10) {
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('↑ 上滑查看更多 ↑', this.screenWidth / 2, this.screenHeight - 15);
}
}
}

View File

@@ -8,7 +8,9 @@ export default class EndingScene extends BaseScene {
super(main, params);
this.storyId = params.storyId;
this.ending = params.ending;
console.log('EndingScene 接收到的结局:', JSON.stringify(this.ending));
this.draftId = params.draftId || null; // 保存草稿ID
this.isReplay = params.isReplay || false; // 是否是回放模式
console.log('EndingScene 接收到的结局:', JSON.stringify(this.ending), ', isReplay:', this.isReplay);
this.showButtons = false;
this.fadeIn = 0;
this.particles = [];
@@ -19,6 +21,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;
@@ -35,6 +42,31 @@ export default class EndingScene extends BaseScene {
setTimeout(() => {
this.showButtons = true;
}, 1500);
// 保存游玩记录回放模式和AI草稿不保存
if (!this.isReplay && !this.draftId) {
this.savePlayRecord();
}
}
async savePlayRecord() {
try {
// 获取当前游玩路径
const pathHistory = this.main.storyManager.pathHistory || [];
const endingName = this.ending?.name || '未知结局';
const endingType = this.ending?.type || '';
// 调用保存接口
await this.main.userManager.savePlayRecord(
this.storyId,
endingName,
endingType,
pathHistory
);
console.log('游玩记录保存成功');
} catch (e) {
console.error('保存游玩记录失败:', e);
}
}
async loadQuota() {
@@ -84,6 +116,10 @@ export default class EndingScene extends BaseScene {
if (this.showRewritePanel) {
this.renderRewritePanel(ctx);
}
// AI续写面板
if (this.showContinuePanel) {
this.renderContinuePanel(ctx);
}
}
renderBackground(ctx) {
@@ -256,15 +292,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']);
// 章节选择按钮
@@ -502,6 +540,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();
@@ -609,6 +824,12 @@ export default class EndingScene extends BaseScene {
return;
}
// 如果续写面板打开,优先处理
if (this.showContinuePanel) {
this.handleContinuePanelTouch(x, y);
return;
}
if (!this.showButtons) return;
const padding = 15;
@@ -617,12 +838,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)) {
@@ -645,6 +872,9 @@ export default class EndingScene extends BaseScene {
// 返回首页
if (this.isInRect(x, y, padding + buttonWidth + buttonMargin, row3Y, buttonWidth, buttonHeight)) {
// 清除当前故事状态
this.main.storyManager.currentStory = null;
this.main.storyManager.currentNodeKey = 'start';
this.main.sceneManager.switchScene('home');
return;
}
@@ -692,6 +922,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) {
@@ -730,6 +974,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: '输入改写想法',
@@ -745,6 +1027,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;
@@ -754,68 +1051,100 @@ 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);
// 启动专门的草稿检查每5秒检查一次持续2分钟
this.startDraftPolling(result.draftId);
} 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: '知道了'
});
// 启动专门的草稿检查每5秒检查一次持续2分钟
this.startDraftPolling(result.draftId);
} else {
wx.showToast({ title: '提交失败,请重试', icon: 'none' });
}
} catch (error) {
wx.hideLoading();
console.error('续写失败:', error);
wx.showToast({ title: error.message || '网络错误', icon: 'none' });
} finally {
this.isRewriting = false;
}
}
@@ -844,7 +1173,16 @@ export default class EndingScene extends BaseScene {
handleReplay() {
this.main.storyManager.resetStory();
this.main.sceneManager.switchScene('story', { storyId: this.storyId });
// 如果是从草稿进入的,重头游玩时保留草稿上下文
if (this.draftId) {
this.main.sceneManager.switchScene('story', {
storyId: this.storyId,
draftId: this.draftId
});
} else {
this.main.sceneManager.switchScene('story', { storyId: this.storyId });
}
}
handleLike() {
@@ -857,4 +1195,69 @@ export default class EndingScene extends BaseScene {
this.isCollected = !this.isCollected;
this.main.userManager.collectStory(this.storyId, this.isCollected);
}
// 启动草稿完成轮询每5秒检查一次持续2分钟
startDraftPolling(draftId) {
// 清除之前的轮询
if (this.draftPollTimer) {
clearInterval(this.draftPollTimer);
}
let pollCount = 0;
const maxPolls = 24; // 2分钟 / 5秒 = 24次
console.log('[EndingScene] 启动草稿轮询, draftId:', draftId);
this.draftPollTimer = setInterval(async () => {
pollCount++;
if (pollCount > maxPolls) {
console.log('[EndingScene] 轮询超时,停止检查');
clearInterval(this.draftPollTimer);
this.draftPollTimer = null;
return;
}
try {
const userId = this.main.userManager.userId;
if (!userId) return;
const result = await this.main.storyManager.checkNewDrafts(userId);
if (result && result.hasNew && result.count > 0) {
console.log('[EndingScene] 检测到新草稿:', result.count);
// 停止轮询
clearInterval(this.draftPollTimer);
this.draftPollTimer = null;
// 标记为已读
await this.main.storyManager.markAllDraftsRead(userId);
// 弹窗通知
wx.showModal({
title: 'AI改写完成',
content: `您有 ${result.count} 个新的AI改写已完成是否前往查看`,
confirmText: '查看',
cancelText: '稍后',
success: (res) => {
if (res.confirm) {
this.main.sceneManager.switchScene('profile', { tab: 1 });
}
}
});
}
} catch (e) {
console.warn('[EndingScene] 草稿检查失败:', e);
}
}, 5000); // 每5秒检查一次
}
// 场景销毁时清理轮询
destroy() {
if (this.draftPollTimer) {
clearInterval(this.draftPollTimer);
this.draftPollTimer = null;
}
}
}

View File

@@ -65,7 +65,7 @@ export default class HomeScene extends BaseScene {
const stories = this.getFilteredStories();
const cardHeight = 130;
const gap = 12;
const startY = 175;
const startY = 190;
const tabHeight = 60;
const contentBottom = startY + stories.length * (cardHeight + gap);
const visibleBottom = this.screenHeight - tabHeight;
@@ -130,7 +130,7 @@ export default class HomeScene extends BaseScene {
}
renderContentTabs(ctx) {
const tabY = 60;
const tabY = 75;
const tabWidth = (this.screenWidth - 30) / 4;
const padding = 15;
@@ -160,7 +160,7 @@ export default class HomeScene extends BaseScene {
}
renderCategories(ctx) {
const startY = 95;
const startY = 110;
const tagHeight = 28;
let x = 15 - this.categoryScrollX;
@@ -207,7 +207,7 @@ export default class HomeScene extends BaseScene {
}
renderStoryList(ctx) {
const startY = 140;
const startY = 155;
const cardHeight = 130;
const cardMargin = 12;
const stories = this.getFilteredStories();
@@ -425,7 +425,7 @@ export default class HomeScene extends BaseScene {
this.touchStartY = touch.clientY;
this.hasMoved = false;
if (touch.clientY >= 90 && touch.clientY <= 130) {
if (touch.clientY >= 105 && touch.clientY <= 145) {
this.isCategoryDragging = true;
this.isDragging = false;
} else {
@@ -489,7 +489,7 @@ export default class HomeScene extends BaseScene {
}
// 分类点击
if (y >= 90 && y <= 130) {
if (y >= 105 && y <= 145) {
this.handleCategoryClick(x);
return;
}
@@ -530,7 +530,7 @@ export default class HomeScene extends BaseScene {
}
handleStoryClick(x, y) {
const startY = 140;
const startY = 155;
const cardHeight = 130;
const cardMargin = 12;
const stories = this.getFilteredStories();

View File

@@ -0,0 +1,257 @@
/**
* 登录场景 - 微信授权登录
*/
import BaseScene from './BaseScene';
export default class LoginScene extends BaseScene {
constructor(main, params = {}) {
super(main, params);
this.isLoading = false;
this.userInfoButton = null;
}
loadAssets() {
// 不加载外部图片,使用 Canvas 绘制
}
init() {
console.log('[LoginScene] 初始化登录场景');
// 创建微信授权按钮
this.createUserInfoButton();
}
// 创建微信用户信息授权按钮
createUserInfoButton() {
const { screenWidth, screenHeight } = this;
const btnWidth = 280;
const btnHeight = 50;
const btnX = (screenWidth - btnWidth) / 2;
const btnY = screenHeight * 0.55;
// 创建透明的用户信息按钮,覆盖在登录按钮上
this.userInfoButton = wx.createUserInfoButton({
type: 'text',
text: '',
style: {
left: btnX,
top: btnY,
width: btnWidth,
height: btnHeight,
backgroundColor: 'transparent',
borderColor: 'transparent',
borderWidth: 0,
borderRadius: 25,
color: 'transparent',
textAlign: 'center',
fontSize: 18,
lineHeight: 50
}
});
// 监听点击事件
this.userInfoButton.onTap(async (res) => {
console.log('[LoginScene] 用户信息授权回调:', res);
if (res.userInfo) {
// 用户同意授权,获取到了头像昵称
await this.doLoginWithUserInfo(res.userInfo);
} else {
// 未获取到用户信息,静默登录
console.log('[LoginScene] 未获取到用户信息,使用静默登录');
await this.doLoginWithUserInfo(null);
}
});
}
render(ctx) {
const { screenWidth, screenHeight } = this;
// 绘制背景渐变
const gradient = ctx.createLinearGradient(0, 0, 0, screenHeight);
gradient.addColorStop(0, '#1a1a2e');
gradient.addColorStop(0.5, '#16213e');
gradient.addColorStop(1, '#0f0f23');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, screenWidth, screenHeight);
// 绘制装饰性光效
this.renderGlow(ctx);
// 绘制Logo
this.renderLogo(ctx);
// 绘制应用名称
this.renderTitle(ctx);
// 绘制登录按钮
this.renderLoginButton(ctx);
// 绘制底部提示
this.renderFooter(ctx);
}
renderGlow(ctx) {
const { screenWidth, screenHeight } = this;
// 顶部光晕
const topGlow = ctx.createRadialGradient(
screenWidth / 2, -100, 0,
screenWidth / 2, -100, 400
);
topGlow.addColorStop(0, 'rgba(106, 90, 205, 0.3)');
topGlow.addColorStop(1, 'rgba(106, 90, 205, 0)');
ctx.fillStyle = topGlow;
ctx.fillRect(0, 0, screenWidth, 400);
}
renderLogo(ctx) {
const { screenWidth, screenHeight } = this;
const logoSize = 120;
const logoX = (screenWidth - logoSize) / 2;
const logoY = screenHeight * 0.2;
// 绘制Logo背景圆
ctx.beginPath();
ctx.arc(screenWidth / 2, logoY + logoSize / 2, logoSize / 2 + 10, 0, Math.PI * 2);
const logoGradient = ctx.createRadialGradient(
screenWidth / 2, logoY + logoSize / 2, 0,
screenWidth / 2, logoY + logoSize / 2, logoSize / 2 + 10
);
logoGradient.addColorStop(0, 'rgba(106, 90, 205, 0.4)');
logoGradient.addColorStop(1, 'rgba(106, 90, 205, 0.1)');
ctx.fillStyle = logoGradient;
ctx.fill();
// 绘制内圆
ctx.fillStyle = '#6a5acd';
ctx.beginPath();
ctx.arc(screenWidth / 2, logoY + logoSize / 2, logoSize / 2 - 10, 0, Math.PI * 2);
ctx.fill();
// 绘制书本图标
ctx.fillStyle = '#fff';
ctx.font = `${logoSize * 0.5}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('📖', screenWidth / 2, logoY + logoSize / 2);
}
renderTitle(ctx) {
const { screenWidth, screenHeight } = this;
const titleY = screenHeight * 0.2 + 160;
// 应用名称
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 32px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('AI互动故事', screenWidth / 2, titleY);
// 副标题
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)';
ctx.font = '16px sans-serif';
ctx.fillText('探索无限可能的剧情世界', screenWidth / 2, titleY + 40);
}
renderLoginButton(ctx) {
const { screenWidth, screenHeight } = this;
// 按钮位置和尺寸
const btnWidth = 280;
const btnHeight = 50;
const btnX = (screenWidth - btnWidth) / 2;
const btnY = screenHeight * 0.55;
// 绘制按钮背景
const btnGradient = ctx.createLinearGradient(btnX, btnY, btnX + btnWidth, btnY);
if (this.isLoading) {
btnGradient.addColorStop(0, '#666666');
btnGradient.addColorStop(1, '#888888');
} else {
btnGradient.addColorStop(0, '#07c160');
btnGradient.addColorStop(1, '#06ae56');
}
this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 25);
ctx.fillStyle = btnGradient;
ctx.fill();
// 绘制按钮阴影效果
if (!this.isLoading) {
ctx.shadowColor = 'rgba(7, 193, 96, 0.4)';
ctx.shadowBlur = 20;
ctx.shadowOffsetY = 5;
this.roundRect(ctx, btnX, btnY, btnWidth, btnHeight, 25);
ctx.fill();
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.shadowOffsetY = 0;
}
// 绘制按钮文字
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 18px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (this.isLoading) {
ctx.fillText('登录中...', screenWidth / 2, btnY + btnHeight / 2);
} else {
ctx.fillText('微信一键登录', screenWidth / 2, btnY + btnHeight / 2);
}
}
renderFooter(ctx) {
const { screenWidth, screenHeight } = this;
const footerY = screenHeight - 60;
// 用户协议提示
ctx.fillStyle = 'rgba(255, 255, 255, 0.4)';
ctx.font = '12px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('登录即表示同意《用户协议》和《隐私政策》', screenWidth / 2, footerY);
}
// 不再需要手动处理点击事件,由 userInfoButton 处理
onTouchEnd(e) {}
async doLoginWithUserInfo(userInfo) {
if (this.isLoading) return;
this.isLoading = true;
console.log('[LoginScene] 开始登录,用户信息:', userInfo);
try {
// 调用 UserManager 的登录方法,传入用户信息
await this.main.userManager.doLogin(userInfo);
console.log('[LoginScene] 登录成功,加载数据并进入首页');
// 隐藏授权按钮
if (this.userInfoButton) {
this.userInfoButton.hide();
}
// 登录成功,加载数据并进入首页
await this.main.loadAndEnterHome();
} catch (error) {
console.error('[LoginScene] 登录失败:', error);
this.isLoading = false;
wx.showToast({
title: error.message || '登录失败',
icon: 'none'
});
}
}
destroy() {
console.log('[LoginScene] 销毁登录场景');
// 销毁授权按钮
if (this.userInfoButton) {
this.userInfoButton.destroy();
this.userInfoButton = null;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import EndingScene from './EndingScene';
import ProfileScene from './ProfileScene';
import ChapterScene from './ChapterScene';
import AICreateScene from './AICreateScene';
import LoginScene from './LoginScene';
export default class SceneManager {
constructor(main) {
@@ -18,7 +19,8 @@ export default class SceneManager {
ending: EndingScene,
profile: ProfileScene,
chapter: ChapterScene,
aiCreate: AICreateScene
aiCreate: AICreateScene,
login: LoginScene
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,148 @@
/**
* 网络请求工具
* 网络请求工具 - 支持本地/云托管切换
*/
// API基础地址开发环境
const BASE_URL = 'http://localhost:3000/api';
// ============================================
// 环境配置(切换这里即可)
// ============================================
const ENV = 'local'; // 'local' = 本地后端, 'cloud' = 微信云托管
const CONFIG = {
local: {
baseUrl: 'http://localhost:8001/api'
},
cloud: {
env: 'prod-6gjx1rd4c40f5884',
serviceName: 'express-fuvd'
}
};
/**
* 获取存储的 Token
*/
function getToken() {
try {
const userInfo = wx.getStorageSync('userInfo');
return userInfo?.token || '';
} catch (e) {
return '';
}
}
/**
* 发送HTTP请求
*/
export function request(options) {
if (ENV === 'local') {
return requestLocal(options);
} else {
return requestCloud(options);
}
}
/**
* 本地后端请求wx.request
*/
function requestLocal(options) {
return new Promise((resolve, reject) => {
const timeoutMs = options.timeout || 30000;
// 自动添加 Token 到请求头
const token = getToken();
const header = {
'Content-Type': 'application/json',
...options.header
};
if (token) {
header['Authorization'] = `Bearer ${token}`;
}
// 处理 URL 查询参数
let url = CONFIG.local.baseUrl + options.url;
if (options.params) {
const queryString = Object.entries(options.params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
url += (url.includes('?') ? '&' : '?') + queryString;
}
wx.request({
url: BASE_URL + options.url,
url,
method: options.method || 'GET',
data: options.data || {},
timeout: timeoutMs,
header: {
'Content-Type': 'application/json',
...options.header
},
timeout: options.timeout || 30000,
header,
success(res) {
if (res.data.code === 0) {
// 处理 401 未授权错误
if (res.statusCode === 401) {
wx.removeStorageSync('userInfo');
reject(new Error('登录已过期,请重新登录'));
return;
}
if (res.data && res.data.code === 0) {
resolve(res.data.data);
} else {
reject(new Error(res.data.message || '请求失败'));
reject(new Error(res.data?.message || '请求失败'));
}
},
fail(err) {
console.error('[HTTP-Local] 请求失败:', err);
reject(err);
}
});
});
}
/**
* 云托管请求wx.cloud.callContainer
*/
function requestCloud(options) {
return new Promise((resolve, reject) => {
// 自动添加 Token 到请求头
const token = getToken();
const header = {
'X-WX-SERVICE': CONFIG.cloud.serviceName,
'Content-Type': 'application/json',
...options.header
};
if (token) {
header['Authorization'] = `Bearer ${token}`;
}
// 处理 URL 查询参数
let path = '/api' + options.url;
if (options.params) {
const queryString = Object.entries(options.params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
path += (path.includes('?') ? '&' : '?') + queryString;
}
wx.cloud.callContainer({
config: {
env: CONFIG.cloud.env
},
path,
method: options.method || 'GET',
data: options.data || {},
header,
success(res) {
// 处理 401 未授权错误
if (res.statusCode === 401) {
wx.removeStorageSync('userInfo');
reject(new Error('登录已过期,请重新登录'));
return;
}
if (res.data && res.data.code === 0) {
resolve(res.data.data);
} else if (res.data) {
reject(new Error(res.data.message || '请求失败'));
} else {
reject(new Error('响应数据异常'));
}
},
fail(err) {
console.error('[HTTP-Cloud] 请求失败:', err);
reject(err);
}
});
@@ -38,8 +152,8 @@ export function request(options) {
/**
* GET请求
*/
export function get(url, data) {
return request({ url, method: 'GET', data });
export function get(url, params) {
return request({ url, method: 'GET', params });
}
/**
@@ -49,4 +163,18 @@ export function post(url, data, options = {}) {
return request({ url, method: 'POST', data, ...options });
}
export default { request, get, post };
/**
* DELETE请求
*/
export function del(url, data) {
return request({ url, method: 'DELETE', data });
}
/**
* PUT请求
*/
export function put(url, data, options = {}) {
return request({ url, method: 'PUT', data, ...options });
}
export default { request, get, post, put, del };

View File

@@ -1,6 +1,6 @@
{
"appid": "wx27be06bc3365e84b",
"compileType": "game",
"appid": "wxed1cd84761d927fd",
"compileType": "miniprogram",
"projectname": "stardom-story",
"setting": {
"es6": true,
@@ -42,5 +42,7 @@
"ignore": [],
"include": []
},
"editorSetting": {}
"editorSetting": {},
"libVersion": "3.14.3",
"isGameTourist": false
}

View File

@@ -36,6 +36,13 @@ class Settings(BaseSettings):
wx_appid: str = ""
wx_secret: str = ""
# JWT 配置
jwt_secret_key: str = "your-super-secret-key-change-in-production"
jwt_expire_hours: int = 168 # 7天
# 文件上传配置
upload_path: str = "./uploads"
@property
def database_url(self) -> str:
return f"mysql+aiomysql://{self.db_user}:{self.db_password}@{self.db_host}:{self.db_port}/{self.db_name}"

View File

@@ -23,6 +23,9 @@ AsyncSessionLocal = sessionmaker(
expire_on_commit=False
)
# 后台任务使用的会话工厂
async_session_factory = AsyncSessionLocal
# 基类
Base = declarative_base()

View File

@@ -1,12 +1,14 @@
"""
星域故事汇 - Python后端服务
"""
import os
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.config import get_settings
from app.routers import story, user
from app.routers import story, user, drafts, upload
settings = get_settings()
@@ -29,6 +31,12 @@ app.add_middleware(
# 注册路由
app.include_router(story.router, prefix="/api/stories", tags=["故事"])
app.include_router(user.router, prefix="/api/user", tags=["用户"])
app.include_router(drafts.router, prefix="/api", tags=["草稿箱"])
app.include_router(upload.router, prefix="/api", tags=["上传"])
# 静态文件服务(用于访问上传的图片)
os.makedirs(settings.upload_path, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=settings.upload_path), name="uploads")
@app.get("/")

View File

@@ -1,10 +1,11 @@
"""
故事相关ORM模型
"""
from sqlalchemy import Column, Integer, String, Text, Boolean, TIMESTAMP, ForeignKey
from sqlalchemy import Column, Integer, String, Text, Boolean, TIMESTAMP, ForeignKey, Enum, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
import enum
class Story(Base):
@@ -25,6 +26,28 @@ class Story(Base):
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
nodes = relationship("StoryNode", back_populates="story", cascade="all, delete-orphan")
characters = relationship("StoryCharacter", back_populates="story", cascade="all, delete-orphan")
class StoryCharacter(Base):
"""故事角色表"""
__tablename__ = "story_characters"
id = Column(Integer, primary_key=True, autoincrement=True)
story_id = Column(Integer, ForeignKey("stories.id", ondelete="CASCADE"), nullable=False)
name = Column(String(50), nullable=False)
role_type = Column(String(20), default="supporting") # protagonist/antagonist/supporting
gender = Column(String(10), default="")
age_range = Column(String(20), default="")
appearance = Column(Text) # 外貌描述
personality = Column(Text) # 性格描述
background = Column(Text) # 背景故事
avatar_prompt = Column(Text) # AI绘图提示词
avatar_url = Column(String(500), default="")
created_at = Column(TIMESTAMP, server_default=func.now())
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
story = relationship("Story", back_populates="characters")
class StoryNode(Base):
@@ -64,3 +87,45 @@ class StoryChoice(Base):
created_at = Column(TIMESTAMP, server_default=func.now())
node = relationship("StoryNode", back_populates="choices")
class DraftStatus(enum.Enum):
"""草稿状态枚举"""
pending = "pending"
processing = "processing"
completed = "completed"
failed = "failed"
class StoryDraft(Base):
"""AI改写草稿表"""
__tablename__ = "story_drafts"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
story_id = Column(Integer, ForeignKey("stories.id", ondelete="CASCADE"), nullable=False)
title = Column(String(100), default="")
# 用户输入
path_history = Column(JSON, default=None) # 用户之前的选择路径
current_node_key = Column(String(50), default="")
current_content = Column(Text, default="")
user_prompt = Column(String(500), nullable=False)
# AI生成结果
ai_nodes = Column(JSON, default=None) # AI生成的新节点
entry_node_key = Column(String(50), default="")
tokens_used = Column(Integer, default=0)
# 状态
status = Column(Enum(DraftStatus), default=DraftStatus.pending)
error_message = Column(String(500), default="")
is_read = Column(Boolean, default=False) # 用户是否已查看
published_to_center = Column(Boolean, default=False) # 是否发布到创作中心
draft_type = Column(String(20), default="rewrite") # 草稿类型: rewrite/continue/create
created_at = Column(TIMESTAMP, server_default=func.now())
completed_at = Column(TIMESTAMP, default=None)
# 关联
story = relationship("Story")

View File

@@ -1,7 +1,7 @@
"""
用户相关ORM模型
"""
from sqlalchemy import Column, Integer, String, Boolean, TIMESTAMP, ForeignKey, UniqueConstraint
from sqlalchemy import Column, Integer, String, Boolean, TIMESTAMP, ForeignKey, UniqueConstraint, JSON, Index
from sqlalchemy.sql import func
from app.database import Base
@@ -56,3 +56,21 @@ class UserEnding(Base):
__table_args__ = (
UniqueConstraint('user_id', 'story_id', 'ending_name', name='uk_user_ending'),
)
class PlayRecord(Base):
"""游玩记录表 - 保存每次游玩的完整路径"""
__tablename__ = "play_records"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
story_id = Column(Integer, ForeignKey("stories.id", ondelete="CASCADE"), nullable=False)
ending_name = Column(String(100), nullable=False) # 结局名称
ending_type = Column(String(20), default="") # 结局类型 (good/bad/hidden/rewrite)
path_history = Column(JSON, nullable=False) # 完整的选择路径
play_duration = Column(Integer, default=0) # 游玩时长(秒)
created_at = Column(TIMESTAMP, server_default=func.now())
__table_args__ = (
Index('idx_user_story', 'user_id', 'story_id'),
)

Binary file not shown.

View File

@@ -0,0 +1,749 @@
"""
草稿箱路由 - AI异步改写功能
"""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, delete
from sqlalchemy.sql import func
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime
from app.database import get_db
from app.models.story import Story, StoryDraft, DraftStatus, StoryCharacter
router = APIRouter(prefix="/drafts", tags=["草稿箱"])
# ============ 辅助函数 ============
async def get_story_characters(db: AsyncSession, story_id: int) -> List[dict]:
"""获取故事的所有角色并转为字典列表"""
result = await db.execute(
select(StoryCharacter).where(StoryCharacter.story_id == story_id)
)
characters = result.scalars().all()
return [
{
"name": c.name,
"role_type": c.role_type,
"gender": c.gender,
"age_range": c.age_range,
"appearance": c.appearance,
"personality": c.personality
}
for c in characters
]
# ============ 请求/响应模型 ============
class PathHistoryItem(BaseModel):
nodeKey: str
content: str
choice: str
class CreateDraftRequest(BaseModel):
userId: int
storyId: int
currentNodeKey: str
pathHistory: List[PathHistoryItem]
currentContent: str
prompt: str
class CreateEndingDraftRequest(BaseModel):
"""结局改写请求"""
userId: int
storyId: int
endingName: str
endingContent: str
prompt: str
pathHistory: list = [] # 游玩路径历史(可选)
class ContinueEndingDraftRequest(BaseModel):
"""结局续写请求"""
userId: int
storyId: int
endingName: str
endingContent: str
prompt: str
pathHistory: list = [] # 游玩路径历史(可选)
class DraftResponse(BaseModel):
id: int
storyId: int
storyTitle: str
title: str
userPrompt: str
status: str
isRead: bool
createdAt: str
completedAt: Optional[str] = None
class Config:
from_attributes = True
# ============ 后台任务 ============
async def process_ai_rewrite(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
# 获取故事角色
characters = await get_story_characters(db, story.id)
# 转换路径历史格式
path_history = draft.path_history or []
# 调用AI服务
ai_result = await ai_service.rewrite_branch(
story_title=story.title,
story_category=story.category or "未知",
path_history=path_history,
current_content=draft.current_content or "",
user_prompt=draft.user_prompt,
characters=characters
)
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", "branch_1")
draft.tokens_used = ai_result.get("tokens_used", 0)
draft.title = f"{story.title}-改写"
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] 异常: {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_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
# 获取故事角色
characters = await get_story_characters(db, story.id)
# 从草稿字段获取结局信息
ending_name = draft.current_node_key or "未知结局"
ending_content = draft.current_content or ""
# 调用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,
characters=characters
)
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 = {
"ending_rewrite": {
"content": content,
"speaker": "旁白",
"is_ending": True,
"ending_name": new_ending_name,
"ending_type": "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
# 获取故事角色
characters = await get_story_characters(db, story.id)
# 从草稿字段获取结局信息
ending_name = draft.current_node_key or "未知结局"
ending_content = draft.current_content or ""
# 调用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,
characters=characters
)
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("")
async def create_draft(
request: CreateDraftRequest,
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 = [
{"nodeKey": item.nodeKey, "content": item.content, "choice": item.choice}
for item in request.pathHistory
]
# 创建草稿记录
draft = StoryDraft(
user_id=request.userId,
story_id=request.storyId,
title=f"{story.title}-改写",
path_history=path_history,
current_node_key=request.currentNodeKey,
current_content=request.currentContent,
user_prompt=request.prompt,
status=DraftStatus.pending,
draft_type='rewrite'
)
db.add(draft)
await db.commit()
await db.refresh(draft)
# 添加后台任务
background_tasks.add_task(process_ai_rewrite, draft.id)
return {
"code": 0,
"data": {
"draftId": draft.id,
"message": "已提交AI正在生成中..."
}
}
@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="故事不存在")
# 创建草稿记录,保存游玩路径和结局信息
draft = StoryDraft(
user_id=request.userId,
story_id=request.storyId,
title=f"{story.title}-结局改写",
path_history=request.pathHistory, # 保存游玩路径
current_node_key=request.endingName, # 保存结局名称
current_content=request.endingContent, # 保存结局内容
user_prompt=request.prompt,
status=DraftStatus.pending,
draft_type='rewrite'
)
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="故事不存在")
# 创建草稿记录,保存游玩路径和结局信息
draft = StoryDraft(
user_id=request.userId,
story_id=request.storyId,
title=f"{story.title}-结局续写",
path_history=request.pathHistory, # 保存游玩路径
current_node_key=request.endingName, # 保存结局名称
current_content=request.endingContent, # 保存结局内容
user_prompt=request.prompt,
status=DraftStatus.pending,
draft_type='continue'
)
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,
db: AsyncSession = Depends(get_db)
):
"""获取用户的草稿列表"""
result = await db.execute(
select(StoryDraft, Story.title.label("story_title"))
.join(Story, StoryDraft.story_id == Story.id)
.where(StoryDraft.user_id == userId)
.order_by(StoryDraft.created_at.desc())
)
drafts = []
for row in result:
draft = row[0]
story_title = row[1]
drafts.append({
"id": draft.id,
"storyId": draft.story_id,
"storyTitle": story_title,
"title": draft.title,
"userPrompt": draft.user_prompt,
"status": draft.status.value if draft.status else "pending",
"isRead": draft.is_read,
"publishedToCenter": draft.published_to_center,
"draftType": draft.draft_type or "rewrite",
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else "",
"completedAt": draft.completed_at.strftime("%Y-%m-%d %H:%M") if draft.completed_at else None
})
return {"code": 0, "data": drafts}
@router.get("/check-new")
async def check_new_drafts(
userId: int,
db: AsyncSession = Depends(get_db)
):
"""检查是否有新完成的草稿(用于弹窗通知)"""
result = await db.execute(
select(StoryDraft)
.where(
StoryDraft.user_id == userId,
StoryDraft.status == DraftStatus.completed,
StoryDraft.is_read == False
)
)
unread_drafts = result.scalars().all()
return {
"code": 0,
"data": {
"hasNew": len(unread_drafts) > 0,
"count": len(unread_drafts),
"drafts": [
{
"id": d.id,
"title": d.title,
"userPrompt": d.user_prompt
}
for d in unread_drafts[:3] # 最多返回3个
]
}
}
@router.get("/published")
async def get_published_drafts(
userId: int,
draftType: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""获取已发布到创作中心的草稿列表"""
query = select(StoryDraft, Story.title.label('story_title')).join(
Story, StoryDraft.story_id == Story.id
).where(
StoryDraft.user_id == userId,
StoryDraft.published_to_center == True,
StoryDraft.status == DraftStatus.completed
)
# 按类型筛选
if draftType:
query = query.where(StoryDraft.draft_type == draftType)
query = query.order_by(StoryDraft.created_at.desc())
result = await db.execute(query)
rows = result.all()
drafts = []
for draft, story_title in rows:
drafts.append({
"id": draft.id,
"storyId": draft.story_id,
"storyTitle": story_title or "未知故事",
"title": draft.title or "",
"userPrompt": draft.user_prompt,
"draftType": draft.draft_type or "rewrite",
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else ""
})
return {
"code": 0,
"data": drafts
}
@router.get("/{draft_id}")
async def get_draft_detail(
draft_id: int,
db: AsyncSession = Depends(get_db)
):
"""获取草稿详情"""
result = await db.execute(
select(StoryDraft, Story)
.join(Story, StoryDraft.story_id == Story.id)
.where(StoryDraft.id == draft_id)
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="草稿不存在")
draft, story = row
# 标记为已读
if not draft.is_read:
draft.is_read = True
await db.commit()
return {
"code": 0,
"data": {
"id": draft.id,
"storyId": draft.story_id,
"storyTitle": story.title,
"storyCategory": story.category,
"title": draft.title,
"pathHistory": draft.path_history,
"currentNodeKey": draft.current_node_key,
"currentContent": draft.current_content,
"userPrompt": draft.user_prompt,
"aiNodes": draft.ai_nodes,
"entryNodeKey": draft.entry_node_key,
"tokensUsed": draft.tokens_used,
"status": draft.status.value if draft.status else "pending",
"errorMessage": draft.error_message,
"createdAt": draft.created_at.strftime("%Y-%m-%d %H:%M") if draft.created_at else "",
"completedAt": draft.completed_at.strftime("%Y-%m-%d %H:%M") if draft.completed_at else None
}
}
@router.delete("/{draft_id}")
async def delete_draft(
draft_id: int,
userId: int,
db: AsyncSession = Depends(get_db)
):
"""删除草稿"""
result = await db.execute(
select(StoryDraft).where(
StoryDraft.id == draft_id,
StoryDraft.user_id == userId
)
)
draft = result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="草稿不存在")
await db.delete(draft)
await db.commit()
return {"code": 0, "message": "删除成功"}
@router.put("/{draft_id}/read")
async def mark_draft_read(
draft_id: int,
db: AsyncSession = Depends(get_db)
):
"""标记草稿为已读"""
await db.execute(
update(StoryDraft)
.where(StoryDraft.id == draft_id)
.values(is_read=True)
)
await db.commit()
return {"code": 0, "message": "已标记为已读"}
@router.put("/batch-read")
async def mark_all_drafts_read(
userId: int,
db: AsyncSession = Depends(get_db)
):
"""批量标记所有未读草稿为已读"""
await db.execute(
update(StoryDraft)
.where(
StoryDraft.user_id == userId,
StoryDraft.is_read == False
)
.values(is_read=True)
)
await db.commit()
return {"code": 0, "message": "已全部标记为已读"}
@router.put("/{draft_id}/publish")
async def publish_draft_to_center(
draft_id: int,
userId: int,
db: AsyncSession = Depends(get_db)
):
"""发布草稿到创作中心"""
# 验证草稿存在且属于该用户
result = await db.execute(
select(StoryDraft).where(
StoryDraft.id == draft_id,
StoryDraft.user_id == userId,
StoryDraft.status == DraftStatus.completed
)
)
draft = result.scalar_one_or_none()
if not draft:
raise HTTPException(status_code=404, detail="草稿不存在或未完成")
# 更新发布状态
draft.published_to_center = True
await db.commit()
return {"code": 0, "message": "已发布到创作中心"}
@router.put("/{draft_id}/unpublish")
async def unpublish_draft_from_center(
draft_id: int,
userId: int,
db: AsyncSession = Depends(get_db)
):
"""从创作中心取消发布"""
await db.execute(
update(StoryDraft)
.where(
StoryDraft.id == draft_id,
StoryDraft.user_id == userId
)
.values(published_to_center=False)
)
await db.commit()
return {"code": 0, "message": "已从创作中心移除"}

View File

@@ -5,11 +5,11 @@ import random
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func, distinct
from typing import Optional
from typing import Optional, List
from pydantic import BaseModel
from app.database import get_db
from app.models.story import Story, StoryNode, StoryChoice
from app.models.story import Story, StoryNode, StoryChoice, StoryCharacter
router = APIRouter()
@@ -25,6 +25,20 @@ class RewriteRequest(BaseModel):
prompt: str
class PathHistoryItem(BaseModel):
nodeKey: str
content: str = ""
choice: str = ""
class RewriteBranchRequest(BaseModel):
userId: int
currentNodeKey: str
pathHistory: List[PathHistoryItem]
currentContent: str
prompt: str
# ========== API接口 ==========
@router.get("")
@@ -200,6 +214,22 @@ async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSes
if not story:
raise HTTPException(status_code=404, detail="故事不存在")
# 获取故事角色
char_result = await db.execute(
select(StoryCharacter).where(StoryCharacter.story_id == story_id)
)
characters = [
{
"name": c.name,
"role_type": c.role_type,
"gender": c.gender,
"age_range": c.age_range,
"appearance": c.appearance,
"personality": c.personality
}
for c in char_result.scalars().all()
]
# 调用 AI 服务
from app.services.ai import ai_service
@@ -208,7 +238,8 @@ async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSes
story_category=story.category or "未知",
ending_name=request.ending_name or "未知结局",
ending_content=request.ending_content or "",
user_prompt=request.prompt
user_prompt=request.prompt,
characters=characters
)
if ai_result and ai_result.get("content"):
@@ -268,3 +299,76 @@ async def ai_rewrite_ending(story_id: int, request: RewriteRequest, db: AsyncSes
"ending_type": "rewrite"
}
}
@router.post("/{story_id}/rewrite-branch")
async def ai_rewrite_branch(
story_id: int,
request: RewriteBranchRequest,
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 == story_id))
story = result.scalar_one_or_none()
if not story:
raise HTTPException(status_code=404, detail="故事不存在")
# 获取故事角色
char_result = await db.execute(
select(StoryCharacter).where(StoryCharacter.story_id == story_id)
)
characters = [
{
"name": c.name,
"role_type": c.role_type,
"gender": c.gender,
"age_range": c.age_range,
"appearance": c.appearance,
"personality": c.personality
}
for c in char_result.scalars().all()
]
# 将 Pydantic 模型转换为字典列表
path_history = [
{"nodeKey": item.nodeKey, "content": item.content, "choice": item.choice}
for item in request.pathHistory
]
# 调用 AI 服务
from app.services.ai import ai_service
ai_result = await ai_service.rewrite_branch(
story_title=story.title,
story_category=story.category or "未知",
path_history=path_history,
current_content=request.currentContent,
user_prompt=request.prompt,
characters=characters
)
if ai_result and ai_result.get("nodes"):
return {
"code": 0,
"data": {
"nodes": ai_result["nodes"],
"entryNodeKey": ai_result.get("entryNodeKey", "branch_1"),
"tokensUsed": ai_result.get("tokens_used", 0)
}
}
# AI 服务不可用时,返回空结果(不使用兜底模板)
return {
"code": 0,
"data": {
"nodes": None,
"entryNodeKey": None,
"tokensUsed": 0,
"error": "AI服务暂时不可用"
}
}

View File

@@ -0,0 +1,108 @@
"""
文件上传路由
"""
import os
import uuid
from datetime import datetime
from fastapi import APIRouter, UploadFile, File, Depends, HTTPException
from ..config import get_settings
from ..utils.jwt_utils import get_current_user_id
router = APIRouter(prefix="/upload", tags=["上传"])
# 允许的图片格式
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
# 最大文件大小 (5MB)
MAX_FILE_SIZE = 5 * 1024 * 1024
def allowed_file(filename: str) -> bool:
"""检查文件扩展名是否允许"""
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
@router.post("/avatar")
async def upload_avatar(
file: UploadFile = File(...),
user_id: int = Depends(get_current_user_id)
):
"""上传用户头像"""
# 检查文件类型
if not file.filename or not allowed_file(file.filename):
raise HTTPException(status_code=400, detail="不支持的文件格式")
# 读取文件内容
content = await file.read()
# 检查文件大小
if len(content) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="文件大小超过限制(5MB)")
# 生成唯一文件名
ext = file.filename.rsplit('.', 1)[1].lower()
filename = f"{user_id}_{uuid.uuid4().hex[:8]}.{ext}"
# 创建上传目录
settings = get_settings()
upload_dir = os.path.join(settings.upload_path, "avatars")
os.makedirs(upload_dir, exist_ok=True)
# 保存文件
file_path = os.path.join(upload_dir, filename)
with open(file_path, "wb") as f:
f.write(content)
# 返回访问URL
# 使用相对路径,前端拼接 baseUrl
avatar_url = f"/uploads/avatars/{filename}"
return {
"code": 0,
"data": {
"url": avatar_url,
"filename": filename
},
"message": "上传成功"
}
@router.post("/image")
async def upload_image(
file: UploadFile = File(...),
user_id: int = Depends(get_current_user_id)
):
"""上传通用图片"""
# 检查文件类型
if not file.filename or not allowed_file(file.filename):
raise HTTPException(status_code=400, detail="不支持的文件格式")
# 读取文件内容
content = await file.read()
# 检查文件大小
if len(content) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="文件大小超过限制(5MB)")
# 生成唯一文件名
ext = file.filename.rsplit('.', 1)[1].lower()
date_str = datetime.now().strftime("%Y%m%d")
filename = f"{date_str}_{uuid.uuid4().hex[:8]}.{ext}"
# 创建上传目录
settings = get_settings()
upload_dir = os.path.join(settings.upload_path, "images")
os.makedirs(upload_dir, exist_ok=True)
# 保存文件
file_path = os.path.join(upload_dir, filename)
with open(file_path, "wb") as f:
f.write(content)
# 返回访问URL
image_url = f"/uploads/images/{filename}"
return {
"code": 0,
"data": {
"url": image_url,
"filename": filename
},
"message": "上传成功"
}

View File

@@ -3,13 +3,16 @@
"""
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, func, text
from sqlalchemy import select, update, func, text, delete
from typing import Optional
from pydantic import BaseModel
import httpx
from app.database import get_db
from app.models.user import User, UserProgress, UserEnding
from app.models.user import User, UserProgress, UserEnding, PlayRecord
from app.models.story import Story
from app.config import get_settings
from app.utils.jwt_utils import create_token, get_current_user_id, get_optional_user_id
router = APIRouter()
@@ -46,14 +49,45 @@ class CollectRequest(BaseModel):
isCollected: bool
class PlayRecordRequest(BaseModel):
userId: int
storyId: int
endingName: str
endingType: str = ""
pathHistory: list
# ========== API接口 ==========
@router.post("/login")
async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
"""微信登录"""
# 实际部署时需要调用微信API获取openid
# 这里简化处理用code作为openid
openid = request.code
settings = get_settings()
# 打印配置状态
print(f"[Login] wx_appid配置: {'已配置' if settings.wx_appid else '未配置'}")
print(f"[Login] wx_secret配置: {'已配置' if settings.wx_secret else '未配置'}")
# 调用微信API获取openid
if settings.wx_appid and settings.wx_secret:
try:
url = f"https://api.weixin.qq.com/sns/jscode2session?appid={settings.wx_appid}&secret={settings.wx_secret}&js_code={request.code}&grant_type=authorization_code"
async with httpx.AsyncClient(verify=False) as client:
resp = await client.get(url, timeout=10.0)
data = resp.json()
if "errcode" in data and data["errcode"] != 0:
# 微信API返回错误使用code作为openid开发模式
print(f"[Login] 微信API错误: {data}")
openid = request.code
else:
openid = data.get("openid", request.code)
except Exception as e:
print(f"[Login] 调用微信API失败: {e}")
openid = request.code
else:
# 未配置微信密钥开发模式用code作为openid
openid = request.code
# 查找或创建用户
result = await db.execute(select(User).where(User.openid == openid))
@@ -71,6 +105,55 @@ async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
await db.commit()
await db.refresh(user)
# 生成 JWT Token
token = create_token(user.id, user.openid)
return {
"code": 0,
"data": {
"userId": user.id,
"openid": user.openid,
"nickname": user.nickname,
"avatarUrl": user.avatar_url,
"gender": user.gender,
"total_play_count": user.total_play_count,
"total_endings": user.total_endings,
"token": token
}
}
@router.post("/refresh-token")
async def refresh_token(user_id: int = Depends(get_current_user_id), db: AsyncSession = Depends(get_db)):
"""刷新 Token"""
# 查找用户
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 生成新 Token
new_token = create_token(user.id, user.openid)
return {
"code": 0,
"data": {
"token": new_token,
"userId": user.id
}
}
@router.get("/me")
async def get_current_user(user_id: int = Depends(get_current_user_id), db: AsyncSession = Depends(get_db)):
"""获取当前用户信息(通过 Token 验证)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return {
"code": 0,
"data": {
@@ -419,3 +502,147 @@ async def get_ai_quota(user_id: int = Query(..., alias="userId"), db: AsyncSessi
"gift": 0
}
}
# ========== 游玩记录 API ==========
@router.post("/play-record")
async def save_play_record(request: PlayRecordRequest, db: AsyncSession = Depends(get_db)):
"""保存游玩记录(相同路径只保留最新)"""
import json
# 查找该用户该故事的所有记录
result = await db.execute(
select(PlayRecord)
.where(PlayRecord.user_id == request.userId, PlayRecord.story_id == request.storyId)
)
existing_records = result.scalars().all()
# 检查是否有相同路径的记录
new_path_str = json.dumps(request.pathHistory, sort_keys=True, ensure_ascii=False)
for old_record in existing_records:
old_path_str = json.dumps(old_record.path_history, sort_keys=True, ensure_ascii=False)
if old_path_str == new_path_str:
# 相同路径,删除旧记录
await db.delete(old_record)
# 创建新记录
record = PlayRecord(
user_id=request.userId,
story_id=request.storyId,
ending_name=request.endingName,
ending_type=request.endingType,
path_history=request.pathHistory
)
db.add(record)
await db.commit()
await db.refresh(record)
return {
"code": 0,
"data": {
"recordId": record.id,
"message": "记录保存成功"
}
}
@router.get("/play-records")
async def get_play_records(
user_id: int = Query(..., alias="userId"),
story_id: Optional[int] = Query(None, alias="storyId"),
db: AsyncSession = Depends(get_db)
):
"""获取游玩记录列表"""
if story_id:
# 获取指定故事的记录
result = await db.execute(
select(PlayRecord)
.where(PlayRecord.user_id == user_id, PlayRecord.story_id == story_id)
.order_by(PlayRecord.created_at.desc())
)
records = result.scalars().all()
data = [{
"id": r.id,
"endingName": r.ending_name,
"endingType": r.ending_type,
"createdAt": r.created_at.strftime("%Y-%m-%d %H:%M") if r.created_at else ""
} for r in records]
else:
# 获取所有玩过的故事(按故事分组,取最新一条)
result = await db.execute(
select(PlayRecord, Story.title, Story.cover_url)
.join(Story, PlayRecord.story_id == Story.id)
.where(PlayRecord.user_id == user_id)
.order_by(PlayRecord.created_at.desc())
)
rows = result.all()
# 按 story_id 分组,取每个故事的最新记录和记录数
story_map = {}
for row in rows:
sid = row.PlayRecord.story_id
if sid not in story_map:
story_map[sid] = {
"storyId": sid,
"storyTitle": row.title,
"coverUrl": row.cover_url,
"latestEnding": row.PlayRecord.ending_name,
"latestTime": row.PlayRecord.created_at.strftime("%Y-%m-%d %H:%M") if row.PlayRecord.created_at else "",
"recordCount": 0
}
story_map[sid]["recordCount"] += 1
data = list(story_map.values())
return {"code": 0, "data": data}
@router.get("/play-records/{record_id}")
async def get_play_record_detail(
record_id: int,
db: AsyncSession = Depends(get_db)
):
"""获取单条记录详情"""
result = await db.execute(
select(PlayRecord, Story.title)
.join(Story, PlayRecord.story_id == Story.id)
.where(PlayRecord.id == record_id)
)
row = result.first()
if not row:
return {"code": 404, "message": "记录不存在"}
record = row.PlayRecord
return {
"code": 0,
"data": {
"id": record.id,
"storyId": record.story_id,
"storyTitle": row.title,
"endingName": record.ending_name,
"endingType": record.ending_type,
"pathHistory": record.path_history,
"createdAt": record.created_at.strftime("%Y-%m-%d %H:%M") if record.created_at else ""
}
}
@router.delete("/play-records/{record_id}")
async def delete_play_record(
record_id: int,
db: AsyncSession = Depends(get_db)
):
"""删除游玩记录"""
result = await db.execute(select(PlayRecord).where(PlayRecord.id == record_id))
record = result.scalar_one_or_none()
if not record:
return {"code": 404, "message": "记录不存在"}
await db.delete(record)
await db.commit()
return {"code": 0, "message": "删除成功"}

Binary file not shown.

View File

@@ -2,8 +2,32 @@
AI服务封装模块
支持多种AI提供商DeepSeek, OpenAI, Claude, 通义千问
"""
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
import httpx
import json
import re
def format_characters_prompt(characters: List[Dict]) -> str:
"""格式化角色信息为prompt文本"""
if not characters:
return ""
text = "\n【故事角色设定】\n"
for char in characters:
text += f"- {char.get('name', '未知')}{char.get('role_type', '配角')}"
if char.get('gender'):
text += f" {char.get('gender')}"
if char.get('age_range'):
text += f" {char.get('age_range')}"
if char.get('appearance'):
text += f" 外貌:{char.get('appearance')[:100]}"
if char.get('personality'):
text += f" 性格:{char.get('personality')[:100]}"
text += "\n"
return text
class AIService:
def __init__(self):
@@ -13,11 +37,14 @@ class AIService:
self.enabled = settings.ai_service_enabled
self.provider = settings.ai_provider
print(f"[AI服务初始化] enabled={self.enabled}, provider={self.provider}")
# 根据提供商初始化配置
if self.provider == "deepseek":
self.api_key = settings.deepseek_api_key
self.base_url = settings.deepseek_base_url
self.model = settings.deepseek_model
print(f"[AI服务初始化] DeepSeek配置: api_key={self.api_key[:20] + '...' if self.api_key else 'None'}, base_url={self.base_url}, model={self.model}")
elif self.provider == "openai":
self.api_key = settings.openai_api_key
self.base_url = settings.openai_base_url
@@ -44,7 +71,8 @@ class AIService:
story_category: str,
ending_name: str,
ending_content: str,
user_prompt: str
user_prompt: str,
characters: List[Dict] = None
) -> Optional[Dict[str, Any]]:
"""
AI改写结局
@@ -53,14 +81,19 @@ class AIService:
if not self.enabled or not self.api_key:
return None
# 格式化角色信息
characters_text = format_characters_prompt(characters) if characters else ""
# 构建Prompt
system_prompt = """你是一个专业的互动故事创作专家。根据用户的改写指令,重新创作故事结局。
system_prompt = f"""你是一个专业的互动故事创作专家。根据用户的改写指令,重新创作故事结局。
{characters_text}
要求:
1. 保持原故事的世界观和人物性格
2. 结局要有张力和情感冲击
3. 结局内容字数控制在200-400字
4. 为新结局取一个4-8字的新名字体现改写后的剧情走向
5. 输出格式必须是JSON{"ending_name": "新结局名称", "content": "结局内容"}"""
5. 如果有角色设定,必须保持角色性格和外貌描述的一致性
6. 输出格式必须是JSON{{"ending_name": "新结局名称", "content": "结局内容"}}"""
user_prompt_text = f"""故事标题:{story_title}
故事分类:{story_category}
@@ -86,6 +119,550 @@ class AIService:
return None
async def rewrite_branch(
self,
story_title: str,
story_category: str,
path_history: List[Dict[str, str]],
current_content: str,
user_prompt: str,
characters: List[Dict] = None
) -> Optional[Dict[str, Any]]:
"""
AI改写中间章节生成新的剧情分支
"""
print(f"\n[rewrite_branch] ========== 开始调用 ==========")
print(f"[rewrite_branch] story_title={story_title}, category={story_category}")
print(f"[rewrite_branch] user_prompt={user_prompt}")
print(f"[rewrite_branch] path_history长度={len(path_history)}")
print(f"[rewrite_branch] current_content长度={len(current_content)}")
print(f"[rewrite_branch] characters数量={len(characters) if characters else 0}")
print(f"[rewrite_branch] enabled={self.enabled}, api_key存在={bool(self.api_key)}")
if not self.enabled or not self.api_key:
print(f"[rewrite_branch] 服务未启用或API Key为空返回None")
return None
# 格式化角色信息
characters_text = format_characters_prompt(characters) if characters else ""
# 构建路径历史文本
path_text = ""
for i, item in enumerate(path_history, 1):
path_text += f"{i}段:{item.get('content', '')}\n"
if item.get('choice'):
path_text += f" → 用户选择:{item['choice']}\n"
# 构建系统提示词
system_prompt_header = f"""你是一个专业的互动故事续写专家。用户正在玩一个互动故事,想要在当前位置改变剧情走向。
{characters_text}
【任务】
请从当前节点开始,创作新的剧情分支。新剧情的第一句话必须紧接当前节点最后的情节,仿佛是同一段故事的自然延续,不能有任何跳跃感。
【写作要求】
1. 第一个节点必须紧密衔接当前剧情,像是同一段话的下一句
2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合)
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\\n\\n分隔包含场景描写、人物对话、心理活动
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
5. 严格符合用户的改写意图,围绕用户指令展开剧情
6. 保持原故事的人物性格、语言风格和世界观
7. 如果有角色设定,必须保持角色性格和外貌的一致性
8. 对话要自然生动,描写要有画面感
【关于结局 - 极其重要!】
★★★ 每一条分支路径的尽头必须是结局节点 ★★★
- 结局节点必须设置 "is_ending": true
- 结局内容要 200-400 字,分 2-3 段,有情感冲击力
- 结局名称 4-8 字,体现剧情走向
- 如果有2个选项分支最终必须有2个不同的结局
- 不允许出现没有结局的"死胡同"节点
- 每个结局必须有 "ending_score" 评分0-100
- good 好结局80-100分
- bad 坏结局20-50分
- neutral 中立结局50-70分
- special 特殊结局70-90分
【内容分段示例】
"content": "他的声音在耳边响起,像是一阵温柔的风。\\n\\n\\"我喜欢你。\\"他说,目光坚定地看着你。\\n\\n你的心跳漏了一拍一时间不知该如何回应。"
"""
system_prompt_json = """【输出格式】严格JSON不要有任何额外文字
{
"nodes": {
"branch_1": {
"content": "新剧情第一段150-300字...",
"speaker": "旁白",
"choices": [
{"text": "选项A5-15字", "nextNodeKey": "branch_2a"},
{"text": "选项B5-15字", "nextNodeKey": "branch_2b"}
]
},
"branch_2a": {
"content": "...",
"speaker": "旁白",
"choices": [
{"text": "选项C", "nextNodeKey": "branch_ending_good"},
{"text": "选项D", "nextNodeKey": "branch_ending_bad"}
]
},
"branch_2b": {
"content": "...",
"speaker": "旁白",
"choices": [
{"text": "选项E", "nextNodeKey": "branch_ending_neutral"},
{"text": "选项F", "nextNodeKey": "branch_ending_special"}
]
},
"branch_ending_good": {
"content": "好结局内容200-400字...\\n\\n【达成结局xxx】",
"speaker": "旁白",
"is_ending": true,
"ending_name": "结局名称",
"ending_type": "good",
"ending_score": 90
},
"branch_ending_bad": {
"content": "坏结局内容...\\n\\n【达成结局xxx】",
"speaker": "旁白",
"is_ending": true,
"ending_name": "结局名称",
"ending_type": "bad",
"ending_score": 40
},
"branch_ending_neutral": {
"content": "中立结局...\\n\\n【达成结局xxx】",
"speaker": "旁白",
"is_ending": true,
"ending_name": "结局名称",
"ending_type": "neutral",
"ending_score": 60
},
"branch_ending_special": {
"content": "特殊结局...\\n\\n【达成结局xxx】",
"speaker": "旁白",
"is_ending": true,
"ending_name": "结局名称",
"ending_type": "special",
"ending_score": 80
}
},
"entryNodeKey": "branch_1"
}"""
system_prompt = system_prompt_header + system_prompt_json
# 构建用户提示词
user_prompt_text = f"""【原故事信息】
故事标题:{story_title}
故事分类:{story_category}
【用户已走过的剧情】
{path_text}
【当前节点】
{current_content}
【用户改写指令】
{user_prompt}
请创作新的剧情分支输出JSON格式"""
print(f"[rewrite_branch] 提示词构建完成开始调用AI...")
print(f"[rewrite_branch] provider={self.provider}")
try:
result = None
if self.provider == "openai":
print(f"[rewrite_branch] 调用 OpenAI...")
result = await self._call_openai_long(system_prompt, user_prompt_text)
elif self.provider == "claude":
print(f"[rewrite_branch] 调用 Claude...")
result = await self._call_claude(f"{system_prompt}\n\n{user_prompt_text}")
elif self.provider == "qwen":
print(f"[rewrite_branch] 调用 Qwen...")
result = await self._call_qwen_long(system_prompt, user_prompt_text)
elif self.provider == "deepseek":
print(f"[rewrite_branch] 调用 DeepSeek...")
result = await self._call_deepseek_long(system_prompt, user_prompt_text)
print(f"[rewrite_branch] AI调用完成result存在={result is not None}")
if result and result.get("content"):
print(f"[rewrite_branch] AI返回内容长度={len(result.get('content', ''))}")
print(f"[rewrite_branch] AI返回内容前500字: {result.get('content', '')[:500]}")
# 解析JSON响应
parsed = self._parse_branch_json(result["content"])
print(f"[rewrite_branch] JSON解析结果: parsed存在={parsed is not None}")
if parsed:
parsed["tokens_used"] = result.get("tokens_used", 0)
print(f"[rewrite_branch] 成功! nodes数量={len(parsed.get('nodes', {}))}, tokens={parsed.get('tokens_used')}")
return parsed
else:
print(f"[rewrite_branch] JSON解析失败!")
else:
print(f"[rewrite_branch] AI返回为空或无content")
return None
except Exception as e:
print(f"[rewrite_branch] 异常: {type(e).__name__}: {e}")
import traceback
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,
characters: List[Dict] = None
) -> 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] characters数量={len(characters) if characters else 0}")
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
# 格式化角色信息
characters_text = format_characters_prompt(characters) if characters else ""
# 构建系统提示词
system_prompt_header = f"""你是一个专业的互动故事续写专家。用户已经到达故事的某个结局,现在想要从这个结局继续故事发展。
{characters_text}
【任务】
请从这个结局开始,创作故事的延续。新剧情必须紧接结局内容,仿佛故事并没有在此结束,而是有了新的发展。
【写作要求】
1. 第一个节点必须紧密衔接原结局,像是结局之后自然发生的事
2. 生成 4-6 个新节点,形成有层次的剧情发展(起承转合)
3. 每个节点内容 150-300 字,要分 2-3 个自然段(用\\n\\n分隔包含场景描写、人物对话、心理活动
4. 每个非结局节点有 2 个选项,选项要有明显的剧情差异和后果
5. 严格符合用户的续写意图,围绕用户指令展开剧情
6. 保持原故事的人物性格、语言风格和世界观
7. 如果有角色设定,必须保持角色性格和外貌的一致性
8. 对话要自然生动,描写要有画面感
【关于新结局 - 极其重要!】
★★★ 每一条分支路径的尽头必须是新结局节点 ★★★
- 结局节点必须设置 "is_ending": true
- 结局内容要 200-400 字,分 2-3 段,有情感冲击力
- 结局名称 4-8 字,体现剧情走向
- 如果有2个选项分支最终必须有2个不同的结局
- 每个结局必须有 "ending_score" 评分0-100
"""
system_prompt_json = """【输出格式】严格JSON不要有任何额外文字
{
"nodes": {
"continue_1": {
"content": "续写剧情第一段150-300字...",
"speaker": "旁白",
"choices": [
{"text": "选项A5-15字", "nextNodeKey": "continue_2a"},
{"text": "选项B5-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"
}"""
system_prompt = system_prompt_header + system_prompt_json
# 构建用户提示词
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)}")
# 移除 markdown 代码块标记
clean_content = content.strip()
if clean_content.startswith('```'):
# 移除开头的 ```json 或 ```
clean_content = re.sub(r'^```(?:json)?\s*', '', clean_content)
# 移除结尾的 ```
clean_content = re.sub(r'\s*```$', '', clean_content)
try:
# 尝试直接解析
result = json.loads(clean_content)
print(f"[_parse_branch_json] 直接解析成功!")
return result
except json.JSONDecodeError as e:
print(f"[_parse_branch_json] 直接解析失败: {e}")
# 尝试提取JSON块
try:
# 匹配 { ... } 结构
brace_match = re.search(r'\{[\s\S]*\}', clean_content)
if brace_match:
json_str = brace_match.group(0)
print(f"[_parse_branch_json] 找到花括号块,尝试解析...")
try:
result = json.loads(json_str)
print(f"[_parse_branch_json] 花括号块解析成功!")
return result
except json.JSONDecodeError as e:
print(f"[_parse_branch_json] 花括号块解析失败: {e}")
# 打印错误位置附近的内容
error_pos = e.pos if hasattr(e, 'pos') else 0
start = max(0, error_pos - 100)
end = min(len(json_str), error_pos + 100)
print(f"[_parse_branch_json] 错误位置附近内容: ...{json_str[start:end]}...")
# 尝试修复不完整的 JSON
print(f"[_parse_branch_json] 尝试修复不完整的JSON...")
fixed_json = self._try_fix_incomplete_json(json_str)
if fixed_json:
print(f"[_parse_branch_json] JSON修复成功!")
return fixed_json
except Exception as e:
print(f"[_parse_branch_json] 提取解析异常: {e}")
print(f"[_parse_branch_json] 所有解析方法都失败了")
return None
def _try_fix_incomplete_json(self, json_str: str) -> Optional[Dict]:
"""尝试修复不完整的JSON被截断的情况"""
try:
# 找到已完成的节点,截断不完整的部分
# 查找最后一个完整的节点(以 } 结尾,后面跟着逗号或闭括号)
# 先找到 "nodes": { 的位置
nodes_match = re.search(r'"nodes"\s*:\s*\{', json_str)
if not nodes_match:
return None
nodes_start = nodes_match.end()
# 找所有完整的 branch 节点
branch_pattern = r'"branch_\w+"\s*:\s*\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
branches = list(re.finditer(branch_pattern, json_str[nodes_start:]))
if not branches:
return None
# 取最后一个完整的节点的结束位置
last_complete_end = nodes_start + branches[-1].end()
# 构建修复后的 JSON
# 截取到最后一个完整节点,然后补全结构
truncated = json_str[:last_complete_end]
# 补全 JSON 结构
fixed = truncated + '\n },\n "entryNodeKey": "branch_1"\n}'
print(f"[_try_fix_incomplete_json] 修复后的JSON长度: {len(fixed)}")
result = json.loads(fixed)
# 验证结果结构
if "nodes" in result and len(result["nodes"]) > 0:
print(f"[_try_fix_incomplete_json] 修复后节点数: {len(result['nodes'])}")
return result
except Exception as e:
print(f"[_try_fix_incomplete_json] 修复失败: {e}")
return None
async def _call_deepseek_long(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
"""调用 DeepSeek API (长文本版本)"""
print(f"[_call_deepseek_long] 开始调用...")
print(f"[_call_deepseek_long] base_url={self.base_url}")
print(f"[_call_deepseek_long] model={self.model}")
url = f"{self.base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
data = {
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"temperature": 0.85,
"max_tokens": 6000 # 增加输出长度确保JSON完整
}
print(f"[_call_deepseek_long] system_prompt长度={len(system_prompt)}")
print(f"[_call_deepseek_long] user_prompt长度={len(user_prompt)}")
async with httpx.AsyncClient(timeout=300.0) as client:
try:
print(f"[_call_deepseek_long] 发送请求到 {url}...")
response = await client.post(url, headers=headers, json=data)
print(f"[_call_deepseek_long] 响应状态码: {response.status_code}")
response.raise_for_status()
result = response.json()
print(f"[_call_deepseek_long] 响应JSON keys: {result.keys()}")
if "choices" in result and len(result["choices"]) > 0:
content = result["choices"][0]["message"]["content"]
tokens = result.get("usage", {}).get("total_tokens", 0)
print(f"[_call_deepseek_long] 成功! content长度={len(content)}, tokens={tokens}")
return {"content": content.strip(), "tokens_used": tokens}
else:
print(f"[_call_deepseek_long] 响应异常无choices: {result}")
return None
except httpx.HTTPStatusError as e:
print(f"[_call_deepseek_long] HTTP错误: {e.response.status_code} - {e.response.text}")
return None
except httpx.TimeoutException as e:
print(f"[_call_deepseek_long] 请求超时: {e}")
return None
except Exception as e:
print(f"[_call_deepseek_long] 其他错误: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
return None
async def _call_openai_long(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
"""调用OpenAI API (长文本版本)"""
url = f"{self.base_url}/chat/completions"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
data = {
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"temperature": 0.8,
"max_tokens": 2000
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, headers=headers, json=data)
response.raise_for_status()
result = response.json()
content = result["choices"][0]["message"]["content"]
tokens = result["usage"]["total_tokens"]
return {"content": content.strip(), "tokens_used": tokens}
async def _call_qwen_long(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
"""调用通义千问API (长文本版本)"""
url = "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
data = {
"model": self.model,
"input": {
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
},
"parameters": {
"result_format": "message",
"temperature": 0.8,
"max_tokens": 2000
}
}
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(url, headers=headers, json=data)
response.raise_for_status()
result = response.json()
content = result["output"]["choices"][0]["message"]["content"]
tokens = result.get("usage", {}).get("total_tokens", 0)
return {"content": content.strip(), "tokens_used": tokens}
async def _call_openai(self, system_prompt: str, user_prompt: str) -> Optional[Dict]:
"""调用OpenAI API"""
url = f"{self.base_url}/chat/completions"

View File

@@ -0,0 +1,6 @@
"""
工具模块
"""
from .jwt_utils import create_token, verify_token, get_current_user_id, get_optional_user_id
__all__ = ["create_token", "verify_token", "get_current_user_id", "get_optional_user_id"]

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,78 @@
"""
JWT 工具函数
"""
import jwt
from datetime import datetime, timedelta
from typing import Optional
from fastapi import HTTPException, Depends, Header
from app.config import get_settings
def create_token(user_id: int, openid: str) -> str:
"""
创建 JWT Token
"""
settings = get_settings()
expire = datetime.utcnow() + timedelta(hours=settings.jwt_expire_hours)
payload = {
"user_id": user_id,
"openid": openid,
"exp": expire,
"iat": datetime.utcnow()
}
token = jwt.encode(payload, settings.jwt_secret_key, algorithm="HS256")
return token
def verify_token(token: str) -> dict:
"""
验证 JWT Token
返回 payload 或抛出异常
"""
settings = get_settings()
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=["HS256"])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token已过期请重新登录")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="无效的Token")
def get_current_user_id(authorization: Optional[str] = Header(None, alias="Authorization")) -> int:
"""
从 Header 中获取并验证 Token返回 user_id
用作 FastAPI 依赖注入
"""
if not authorization:
raise HTTPException(status_code=401, detail="未提供身份令牌")
# 支持 "Bearer xxx" 格式
token = authorization
if authorization.startswith("Bearer "):
token = authorization[7:]
payload = verify_token(token)
return payload.get("user_id")
def get_optional_user_id(authorization: Optional[str] = Header(None, alias="Authorization")) -> Optional[int]:
"""
可选的用户验证,未提供 Token 时返回 None
用于不强制要求登录的接口
"""
if not authorization:
return None
try:
token = authorization
if authorization.startswith("Bearer "):
token = authorization[7:]
payload = verify_token(token)
return payload.get("user_id")
except HTTPException:
return None

View File

@@ -9,3 +9,4 @@ pydantic-settings==2.1.0
python-dotenv==1.0.0
python-multipart==0.0.6
httpx==0.27.0
PyJWT==2.8.0

View 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()

View File

@@ -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'
}

74
server/sql/rebuild_db.py Normal file
View 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()

View File

@@ -1,107 +1,218 @@
-- 星域故事汇数据库初始化脚本
-- 创建数据库
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 '作者ID0表示官方',
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 '作者ID0表示官方',
`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 '用户是否已查看',
`published_to_center` TINYINT(1) DEFAULT 0 COMMENT '是否发布到创作中心',
`draft_type` VARCHAR(20) DEFAULT 'rewrite' COMMENT '草稿类型: rewrite/continue/create',
`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改写草稿表';
-- ============================================
-- 8. 游玩记录表
-- ============================================
CREATE TABLE IF NOT EXISTS `play_records` (
`id` INT NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL COMMENT '用户ID',
`story_id` INT NOT NULL COMMENT '故事ID',
`ending_name` VARCHAR(100) NOT NULL COMMENT '结局名称',
`ending_type` VARCHAR(20) DEFAULT '' COMMENT '结局类型',
`path_history` JSON NOT NULL COMMENT '完整的选择路径',
`play_duration` INT DEFAULT 0 COMMENT '游玩时长(秒)',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_story` (`user_id`, `story_id`),
KEY `idx_user` (`user_id`),
KEY `idx_story` (`story_id`),
CONSTRAINT `play_records_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `play_records_ibfk_2` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游玩记录表';
-- ============================================
-- 9. 故事角色表
-- ============================================
CREATE TABLE IF NOT EXISTS `story_characters` (
`id` INT NOT NULL AUTO_INCREMENT,
`story_id` INT NOT NULL COMMENT '所属故事ID',
`name` VARCHAR(50) NOT NULL COMMENT '角色名称',
`role_type` VARCHAR(20) DEFAULT 'supporting' COMMENT '角色类型: protagonist/antagonist/supporting',
`gender` VARCHAR(10) DEFAULT '' COMMENT '性别: male/female/unknown',
`age_range` VARCHAR(20) DEFAULT '' COMMENT '年龄段: child/teen/young/middle/old',
`appearance` TEXT COMMENT '外貌描述(用于生成图片)',
`personality` TEXT COMMENT '性格描述',
`background` TEXT COMMENT '背景故事',
`avatar_prompt` TEXT COMMENT 'AI绘图提示词',
`avatar_url` VARCHAR(500) DEFAULT '' COMMENT '角色头像URL',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_story_id` (`story_id`),
CONSTRAINT `story_characters_ibfk_1` FOREIGN KEY (`story_id`) REFERENCES `stories` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='故事角色表';
-- ============================================
-- 10. 节点角色关联表
-- ============================================
CREATE TABLE IF NOT EXISTS `node_characters` (
`id` INT NOT NULL AUTO_INCREMENT,
`story_id` INT NOT NULL COMMENT '故事ID',
`node_key` VARCHAR(50) NOT NULL COMMENT '节点key',
`character_id` INT NOT NULL COMMENT '角色ID',
`is_speaker` TINYINT(1) DEFAULT 0 COMMENT '是否为该节点说话者',
`emotion` VARCHAR(30) DEFAULT 'neutral' COMMENT '该节点情绪: happy/sad/angry/neutral等',
PRIMARY KEY (`id`),
KEY `idx_story_node` (`story_id`, `node_key`),
KEY `idx_character` (`character_id`),
CONSTRAINT `node_characters_ibfk_1` FOREIGN KEY (`character_id`) REFERENCES `story_characters` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='节点角色关联表';

View File

@@ -564,3 +564,39 @@ CREATE TABLE sensitive_words (
UNIQUE KEY uk_word (word),
INDEX idx_category (category)
) ENGINE=InnoDB COMMENT='敏感词表';
-- ============================================
-- 九、AI改写草稿箱
-- ============================================
-- AI改写草稿表
CREATE TABLE story_drafts (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT '用户ID',
story_id BIGINT NOT NULL COMMENT '原故事ID',
title VARCHAR(100) DEFAULT '' COMMENT '草稿标题',
-- 用户输入
path_history JSON COMMENT '用户之前的选择路径',
current_node_key VARCHAR(50) DEFAULT '' COMMENT '改写起始节点',
current_content TEXT COMMENT '当前节点内容',
user_prompt VARCHAR(500) NOT NULL COMMENT '用户改写指令',
-- AI生成结果
ai_nodes JSON 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 BOOLEAN DEFAULT FALSE COMMENT '用户是否已查看',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL COMMENT '完成时间',
INDEX idx_user (user_id),
INDEX idx_story (story_id),
INDEX idx_status (status),
INDEX idx_user_unread (user_id, is_read)
) ENGINE=InnoDB COMMENT='AI改写草稿表';

View File

@@ -0,0 +1,209 @@
-- 角色种子数据
USE stardom_story;
-- ============================================
-- 故事1: 总裁的替身新娘
-- ============================================
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
(1, 1, '林诗语', 'protagonist', 'female', 'young', '清秀温婉的面容,一双明亮的杏眼,乌黑长发及腰,身材纤细,气质如兰', '善良隐忍,聪慧坚韧,温柔但有底线', 'young chinese woman, gentle beauty, long black hair, bright almond eyes, slender figure, elegant temperament, wedding dress'),
(2, 1, '陆景深', 'protagonist', 'male', 'young', '剑眉星目,薄唇紧抿,高大挺拔,气势凌厉,眼神冰冷如寒潭', '外冷内热,霸道专一,深情而克制', 'handsome chinese man, sharp eyebrows, thin lips, tall and imposing, cold piercing eyes, black suit, CEO style'),
(3, 1, '林诗韵', 'supporting', 'female', 'young', '容貌出众,浓妆艳抹,身姿妖娆', '任性自私,贪慕虚荣', 'beautiful chinese woman, heavy makeup, glamorous figure, designer clothes');
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
(1, 'start', 1, 0, 'nervous'),
(1, 'start', 2, 0, 'neutral'),
(1, 'choice1_a', 1, 0, 'anxious'),
(1, 'choice1_a', 2, 1, 'cold'),
(1, 'choice1_b', 1, 0, 'scared'),
(1, 'choice1_b', 2, 1, 'amused'),
(1, 'choice2_a', 1, 1, 'honest'),
(1, 'choice2_a', 2, 0, 'interested'),
(1, 'choice2_b', 1, 1, 'nervous'),
(1, 'choice2_b', 2, 0, 'smirking'),
(1, 'ending_good', 1, 0, 'happy'),
(1, 'ending_good', 2, 1, 'loving'),
(1, 'ending_normal', 1, 1, 'calm'),
(1, 'ending_normal', 2, 0, 'sad'),
(1, 'ending_bad', 1, 0, 'heartbroken'),
(1, 'ending_bad', 2, 1, 'cruel');
-- ============================================
-- 故事2: 密室中的第四个人
-- ============================================
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
(4, 2, '王教授', 'supporting', 'male', 'middle', '戴眼镜的中年男人,头发花白,穿着整洁的西装', '学者气质,表面儒雅,实际隐藏着秘密', 'middle-aged chinese man, glasses, graying hair, neat suit, scholarly appearance'),
(5, 2, '苏小姐', 'supporting', 'female', 'young', '穿着名牌,妆容精致,身上有淡淡香水味', '看似高傲,内心脆弱', 'young elegant chinese woman, designer clothes, delicate makeup, wealthy appearance'),
(6, 2, '李明', 'antagonist', 'male', 'young', '沉默寡言的青年,眼神阴沉,嘴角常带冷笑', '隐忍复仇,城府极深', 'young chinese man, gloomy eyes, cold expression, dark casual clothes');
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
(2, 'start', 4, 0, 'dead'),
(2, 'start', 5, 0, 'shocked'),
(2, 'start', 6, 0, 'calm'),
(2, 'investigate_room', 4, 0, 'dead'),
(2, 'question_su', 5, 1, 'nervous'),
(2, 'question_li', 6, 1, 'cold'),
(2, 'accuse_su', 5, 1, 'crying'),
(2, 'accuse_li', 6, 1, 'vengeful'),
(2, 'ending_truth', 6, 1, 'resigned'),
(2, 'ending_wrong', 5, 0, 'wronged');
-- ============================================
-- 故事3: 凤临天下
-- ============================================
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
(7, 3, '林清婉', 'protagonist', 'female', 'young', '眉目如画,肤若凝脂,身姿窈窕,举止端庄', '外柔内刚,隐忍聪慧,心思缜密', 'beautiful ancient chinese woman, delicate features, fair skin, graceful posture, palace costume, elegant hairpin'),
(8, 3, '皇帝', 'protagonist', 'male', 'young', '龙袍加身,眉宇威严,俊朗不凡,气势天成', '深沉多疑,霸道深情', 'handsome ancient chinese emperor, dragon robe, majestic presence, piercing eyes, imperial crown'),
(9, 3, '皇后', 'antagonist', 'female', 'middle', '珠翠环绕,气度雍容,凤冠霞帔,笑容不达眼底', '城府极深,手段狠辣', 'ancient chinese empress, phoenix crown, luxurious robes, dignified bearing, calculating smile');
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
(3, 'start', 7, 0, 'nervous'),
(3, 'meet_emperor', 7, 1, 'respectful'),
(3, 'meet_emperor', 8, 1, 'curious'),
(3, 'meet_consort', 7, 1, 'cautious'),
(3, 'meet_consort', 9, 1, 'scrutinizing'),
(3, 'choice_emperor', 7, 1, 'humble'),
(3, 'choice_emperor', 8, 1, 'pleased'),
(3, 'choice_queen', 7, 1, 'calculating'),
(3, 'choice_queen', 9, 1, 'satisfied'),
(3, 'ending_empress', 7, 0, 'triumphant'),
(3, 'ending_empress', 8, 1, 'loving'),
(3, 'ending_concubine', 7, 0, 'peaceful'),
(3, 'ending_tragic', 7, 0, 'despairing');
-- ============================================
-- 故事4: 暗恋那件小事
-- ============================================
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
(10, 4, '', 'protagonist', 'female', 'teen', '扎着马尾的高中女生,青涩可爱,眼神里藏着小秘密', '内向害羞,暗恋成痴,纯真善良', 'teenage chinese girl, ponytail, school uniform, shy expression, cute appearance'),
(11, 4, '沈昼', 'protagonist', 'male', 'teen', '校园男神,阳光帅气,篮球服下身材修长,笑起来眼睛弯弯', '温柔体贴,学霸气质,暖心细腻', 'handsome teenage chinese boy, school uniform, basketball jersey, warm smile, athletic build'),
(12, 4, '同桌', 'supporting', 'female', 'teen', '活泼开朗的女生,表情丰富,是个话痨', '热心肠,爱八卦,仗义', 'cheerful teenage chinese girl, expressive face, school uniform, lively personality');
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
(4, 'start', 10, 0, 'daydreaming'),
(4, 'start', 11, 0, 'neutral'),
(4, 'start', 12, 1, 'teasing'),
(4, 'library', 10, 1, 'flustered'),
(4, 'library', 11, 1, 'gentle'),
(4, 'basketball', 10, 0, 'excited'),
(4, 'basketball', 11, 1, 'playful'),
(4, 'rooftop', 10, 0, 'nervous'),
(4, 'rooftop', 11, 1, 'serious'),
(4, 'confess_yes', 10, 1, 'brave'),
(4, 'confess_yes', 11, 0, 'happy'),
(4, 'confess_no', 10, 0, 'tearful'),
(4, 'ending_friends', 10, 0, 'regretful');
-- ============================================
-- 故事5: 废柴逆袭录
-- ============================================
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
(13, 5, '陈风', 'protagonist', 'male', 'young', '相貌普通的少年,眼神坚定,穿着破旧的外门弟子服', '隐忍不屈,心性坚韧,大智若愚', 'young chinese man, ordinary appearance, determined eyes, worn-out cultivation robes, underdog aura'),
(14, 5, '天元大帝', 'supporting', 'male', 'old', '白发苍苍的虚影,仙风道骨,眼中闪烁着智慧的光芒', '睿智从容,亦师亦友', 'ancient chinese immortal, white long hair, ethereal figure, wise eyes, flowing robes, ghostly appearance'),
(15, 5, '赵天龙', 'antagonist', 'male', 'young', '内门弟子,华服锦衣,面带傲色,眼高于顶', '嚣张跋扈,欺软怕硬', 'arrogant young chinese man, luxurious cultivation robes, haughty expression, inner sect disciple');
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
(5, 'start', 13, 0, 'oppressed'),
(5, 'inheritance', 13, 0, 'shocked'),
(5, 'inheritance', 14, 1, 'wise'),
(5, 'challenge', 13, 0, 'calm'),
(5, 'challenge', 15, 1, 'mocking'),
(5, 'show_power', 13, 1, 'confident'),
(5, 'show_power', 15, 0, 'terrified'),
(5, 'hide_power', 13, 0, 'scheming'),
(5, 'hide_power', 14, 1, 'approving'),
(5, 'ending_immortal', 13, 0, 'transcendent'),
(5, 'ending_mortal', 13, 0, 'peaceful');
-- ============================================
-- 故事6: 回到高考前一天
-- ============================================
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
(16, 6, '', 'protagonist', 'male', 'teen', '穿着高中校服的少年,眼神带着超越年龄的沧桑', '成熟稳重,心怀遗憾,想要改变', 'teenage chinese boy, school uniform, eyes with wisdom beyond age, determined expression'),
(17, 6, '林小雨', 'supporting', 'female', 'teen', '瘦弱的女孩,眼眶微红,神情木然', '敏感脆弱,承受着巨大压力', 'fragile teenage chinese girl, red-rimmed eyes, blank expression, thin figure, school uniform'),
(18, 6, '爷爷', 'supporting', 'male', 'old', '慈祥的老人,满头银发,笑容温暖', '和蔼可亲,疼爱孙辈', 'kind elderly chinese man, silver hair, warm smile, traditional chinese clothes');
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
(6, 'start', 16, 0, 'shocked'),
(6, 'choice_study', 16, 0, 'focused'),
(6, 'choice_relax', 16, 0, 'thoughtful'),
(6, 'visit_girl', 16, 1, 'concerned'),
(6, 'visit_girl', 17, 1, 'vulnerable'),
(6, 'ending_perfect', 16, 0, 'fulfilled'),
(6, 'ending_changed', 16, 0, 'content'),
(6, 'ending_changed', 17, 1, 'grateful');
-- ============================================
-- 故事7: 逆风翻盘
-- ============================================
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
(19, 7, '陈墨', 'protagonist', 'male', 'young', '改头换面后的复仇者,西装革履,眼神锐利如鹰', '隐忍多年,城府极深,复仇心切', 'handsome chinese businessman, sharp suit, eagle-like eyes, confident posture, mysterious aura'),
(20, 7, '周明', 'antagonist', 'male', 'middle', '发福的中年男人,眼神世故贪婪,穿着名牌却掩不住小人嘴脸', '背信弃义,贪婪无耻', 'overweight chinese businessman, greedy eyes, expensive suit, treacherous appearance');
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
(7, 'start', 19, 0, 'vengeful'),
(7, 'meeting', 19, 1, 'calculating'),
(7, 'meeting', 20, 1, 'obsequious'),
(7, 'expose', 19, 1, 'revealing'),
(7, 'expose', 20, 1, 'terrified'),
(7, 'ending_revenge', 19, 0, 'empty'),
(7, 'ending_forgive', 19, 0, 'liberated');
-- ============================================
-- 故事8: 2099最后一班地铁
-- ============================================
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
(21, 8, '', 'protagonist', 'male', 'young', '末世的普通人,穿着简朴,眼神迷茫又带着一丝希望', '迷茫困惑,渴望改变', 'young man in futuristic simple clothes, contemplative expression, sci-fi subway setting'),
(22, 8, 'Zero', 'supporting', 'female', 'young', '穿白裙的神秘女孩,眼睛明亮,不属于这个时代', '超然世外,引导者', 'mysterious girl in white dress, bright luminous eyes, ethereal beauty, futuristic subway conductor');
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
(8, 'start', 21, 0, 'curious'),
(8, 'start', 22, 1, 'welcoming'),
(8, 'talk_girl', 21, 1, 'questioning'),
(8, 'talk_girl', 22, 1, 'mysterious'),
(8, 'choose_past', 21, 1, 'hopeful'),
(8, 'choose_past', 22, 1, 'guiding'),
(8, 'ending_past', 21, 0, 'determined'),
(8, 'ending_past', 22, 1, 'encouraging'),
(8, 'ending_future', 21, 0, 'amazed'),
(8, 'ending_future', 22, 1, 'serene');
-- ============================================
-- 故事9: 第七夜
-- ============================================
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
(23, 9, '', 'protagonist', 'male', 'young', '被困旅馆的旅客,神情紧张,黑眼圈明显', '胆小谨慎,求生欲强', 'nervous young man, dark circles under eyes, casual travel clothes, fearful expression'),
(24, 9, '老板娘', 'supporting', 'female', 'middle', '面容苍老,眼神奇怪,穿着老旧的旗袍', '神秘莫测,知道真相', 'mysterious middle-aged chinese woman, old-fashioned qipao, strange knowing eyes, creepy inn keeper'),
(25, 9, '白裙女孩', 'antagonist', 'female', 'young', '苍白的脸,穿着白裙,笑容诡异,若隐若现', '怨念深重,寻找陪伴', 'pale ghost girl in white dress, eerie smile, translucent figure, long black hair covering face, horror style');
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
(9, 'start', 23, 0, 'uneasy'),
(9, 'start', 24, 1, 'cryptic'),
(9, 'night_sound', 23, 0, 'terrified'),
(9, 'night_sound', 25, 1, 'pleading'),
(9, 'open_door', 23, 0, 'horrified'),
(9, 'open_door', 25, 1, 'sinister'),
(9, 'not_open', 23, 0, 'anxious'),
(9, 'ending_death', 23, 0, 'dying'),
(9, 'ending_death', 25, 1, 'satisfied'),
(9, 'ending_escape', 23, 0, 'relieved');
-- ============================================
-- 故事10: 我的室友是只猫
-- ============================================
INSERT INTO story_characters (id, story_id, name, role_type, gender, age_range, appearance, personality, avatar_prompt) VALUES
(26, 10, '', 'protagonist', 'male', 'young', '普通大学生,一脸懵逼,被室友变猫吓到', '善良搞笑,容易慌张', 'confused chinese college student, dorm room setting, panicked expression, casual clothes'),
(27, 10, '室友/橘猫', 'protagonist', 'male', 'young', '胖胖的橘色大猫,会说话,表情丰富,原本是个学霸', '聪明话多,倒霉体质,吃货', 'fat orange tabby cat, expressive face, sitting like human, funny expression, college dorm background');
INSERT INTO node_characters (story_id, node_key, character_id, is_speaker, emotion) VALUES
(10, 'start', 26, 0, 'shocked'),
(10, 'start', 27, 1, 'frustrated'),
(10, 'panic', 26, 1, 'panicking'),
(10, 'panic', 27, 1, 'embarrassed'),
(10, 'exam_plan', 26, 0, 'thinking'),
(10, 'exam_plan', 27, 1, 'excited'),
(10, 'bring_cat', 26, 0, 'nervous'),
(10, 'bring_cat', 27, 1, 'whispering'),
(10, 'ending_funny', 26, 0, 'relieved'),
(10, 'ending_funny', 27, 1, 'smug'),
(10, 'ending_caught', 26, 0, 'doomed'),
(10, 'ending_caught', 27, 1, 'defeated');

View File

@@ -1,7 +1,51 @@
-- 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);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(2, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(3, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(4, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(5, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(6, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(7, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(8, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(9, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(10, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(11, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(12, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(13, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(14, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(15, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(16, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(17, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(18, 'test_user', '测试用户', '', 0, 0, 0);
INSERT INTO `users` (`id`, `openid`, `nickname`, `avatar_url`, `gender`, `total_play_count`, `total_endings`) VALUES
(19, '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);