refactor: 后端从Node.js重写为Python FastAPI
This commit is contained in:
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
15
.idea/ai_game.iml
generated
Normal file
15
.idea/ai_game.iml
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="FacetManager">
|
||||||
|
<facet type="Python" name="Python">
|
||||||
|
<configuration sdkName="Python 3.12 (mip-ad-maintenance-sW6qRjaA-py3.12)" />
|
||||||
|
</facet>
|
||||||
|
</component>
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="Python 3.12 (mip-ad-maintenance-sW6qRjaA-py3.12) interpreter library" level="application" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
10
.idea/misc.xml
generated
Normal file
10
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?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>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/ai_game.iml" filepath="$PROJECT_DIR$/.idea/ai_game.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
54
README.md
54
README.md
@@ -25,18 +25,23 @@ ai_game/
|
|||||||
│ ├── game.js
|
│ ├── game.js
|
||||||
│ └── game.json
|
│ └── game.json
|
||||||
│
|
│
|
||||||
├── server/ # Node.js 后端服务
|
├── server/ # Python 后端服务 (FastAPI)
|
||||||
│ ├── routes/
|
│ ├── app/
|
||||||
│ │ ├── story.js # 故事接口
|
│ │ ├── routers/
|
||||||
│ │ └── user.js # 用户接口
|
│ │ │ ├── story.py # 故事接口
|
||||||
│ ├── models/
|
│ │ │ └── user.py # 用户接口
|
||||||
│ │ ├── story.js # 故事模型
|
│ │ ├── models/
|
||||||
│ │ └── user.js # 用户模型
|
│ │ │ ├── story.py # 故事ORM模型
|
||||||
│ ├── config/
|
│ │ │ └── user.py # 用户ORM模型
|
||||||
|
│ │ ├── config.py # 配置管理
|
||||||
|
│ │ ├── database.py # 数据库连接
|
||||||
|
│ │ └── main.py # 应用入口
|
||||||
│ ├── sql/
|
│ ├── sql/
|
||||||
│ │ ├── schema.sql # 基础表结构
|
│ │ ├── schema.sql # 基础表结构
|
||||||
│ │ └── schema_v2.sql # 完整表结构(含AI/UGC)
|
│ │ ├── init_db.py # Python建库脚本
|
||||||
│ └── app.js
|
│ │ └── seed_stories_*.sql # 种子数据
|
||||||
|
│ ├── requirements.txt
|
||||||
|
│ └── .env
|
||||||
│
|
│
|
||||||
├── docs/
|
├── docs/
|
||||||
│ └── AI创作系统设计.md
|
│ └── AI创作系统设计.md
|
||||||
@@ -49,22 +54,30 @@ ai_game/
|
|||||||
| 层级 | 技术 |
|
| 层级 | 技术 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 客户端 | 原生微信小游戏 Canvas 2D |
|
| 客户端 | 原生微信小游戏 Canvas 2D |
|
||||||
| 服务端 | Node.js + Express |
|
| 服务端 | Python + FastAPI + SQLAlchemy |
|
||||||
| 数据库 | MySQL 8.0 |
|
| 数据库 | MySQL 8.0 |
|
||||||
| AI服务 | OpenAI / Claude API |
|
| AI服务 | OpenAI / Claude API |
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
### 1. 启动后端
|
### 1. 初始化数据库
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd server
|
cd server
|
||||||
npm install
|
pip install -r requirements.txt
|
||||||
cp .env.example .env # 配置数据库
|
# 配置 .env 文件(数据库密码等)
|
||||||
npm start
|
python sql/init_db.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 导入客户端
|
### 2. 启动后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
python -m app.main
|
||||||
|
# 服务运行在 http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 导入客户端
|
||||||
|
|
||||||
1. 微信开发者工具 → 导入项目 → 选择 `client` 目录
|
1. 微信开发者工具 → 导入项目 → 选择 `client` 目录
|
||||||
2. 填入 AppID
|
2. 填入 AppID
|
||||||
@@ -137,8 +150,13 @@ npm start
|
|||||||
- [x] AI创作中心UI
|
- [x] AI创作中心UI
|
||||||
- [x] 首页UGC布局
|
- [x] 首页UGC布局
|
||||||
- [x] 个人中心创作者功能
|
- [x] 个人中心创作者功能
|
||||||
- [x] 后端API完善
|
- [x] 后端基础框架(Python + FastAPI)
|
||||||
- [ ] AI服务对接
|
- [x] 故事模型设计(标题、章节、选项、结局)
|
||||||
|
- [x] 故事列表/详情接口
|
||||||
|
- [x] 用户游玩记录接口
|
||||||
|
- [x] AI改写结局接口(模拟)
|
||||||
|
- [ ] AI服务对接(OpenAI/Claude)
|
||||||
|
- [ ] 配额系统
|
||||||
- [ ] 审核系统
|
- [ ] 审核系统
|
||||||
- [ ] 支付系统
|
- [ ] 支付系统
|
||||||
- [ ] 社交分享
|
- [ ] 社交分享
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"appid": "wx772e2f0fbc498020",
|
"appid": "wx27be06bc3365e84b",
|
||||||
"compileType": "game",
|
"compileType": "game",
|
||||||
"projectname": "stardom-story",
|
"projectname": "stardom-story",
|
||||||
"setting": {
|
"setting": {
|
||||||
@@ -42,6 +42,5 @@
|
|||||||
"ignore": [],
|
"ignore": [],
|
||||||
"include": []
|
"include": []
|
||||||
},
|
},
|
||||||
"isGameTourist": false,
|
|
||||||
"editorSetting": {}
|
"editorSetting": {}
|
||||||
}
|
}
|
||||||
@@ -380,7 +380,10 @@ class AIService {
|
|||||||
## 九、后续迭代
|
## 九、后续迭代
|
||||||
|
|
||||||
### Phase 1(当前)
|
### Phase 1(当前)
|
||||||
- [x] AI改写结局
|
- [x] AI改写结局(后端接口已完成,模拟实现)
|
||||||
|
- [x] 后端基础框架(Python + FastAPI)
|
||||||
|
- [x] 故事/用户数据模型
|
||||||
|
- [x] 游玩记录接口
|
||||||
- [ ] 配额系统接入
|
- [ ] 配额系统接入
|
||||||
- [ ] 基础审核流程
|
- [ ] 基础审核流程
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,17 @@
|
|||||||
"disablePlugins": [],
|
"disablePlugins": [],
|
||||||
"outputPath": ""
|
"outputPath": ""
|
||||||
},
|
},
|
||||||
"useCompilerPlugins": false
|
"useCompilerPlugins": false,
|
||||||
|
"compileWorklet": false,
|
||||||
|
"uploadWithSourceMap": true,
|
||||||
|
"packNpmManually": false,
|
||||||
|
"minifyWXSS": true,
|
||||||
|
"minifyWXML": true,
|
||||||
|
"localPlugins": false,
|
||||||
|
"disableUseStrict": false,
|
||||||
|
"condition": false,
|
||||||
|
"swc": false,
|
||||||
|
"disableSWC": true
|
||||||
},
|
},
|
||||||
"compileType": "game",
|
"compileType": "game",
|
||||||
"simulatorPluginLibVersion": {},
|
"simulatorPluginLibVersion": {},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 服务器配置
|
cd "D:\idea project\ai_game\server"# 服务器配置
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# MySQL数据库配置
|
# MySQL数据库配置
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const cors = require('cors');
|
|
||||||
require('dotenv').config();
|
|
||||||
|
|
||||||
const storyRoutes = require('./routes/story');
|
|
||||||
const userRoutes = require('./routes/user');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
|
|
||||||
// 中间件
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
|
|
||||||
// 路由
|
|
||||||
app.use('/api/stories', storyRoutes);
|
|
||||||
app.use('/api/user', userRoutes);
|
|
||||||
|
|
||||||
// 健康检查
|
|
||||||
app.get('/api/health', (req, res) => {
|
|
||||||
res.json({ status: 'ok', message: '星域故事汇服务运行中' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// 错误处理
|
|
||||||
app.use((err, req, res, next) => {
|
|
||||||
console.error(err.stack);
|
|
||||||
res.status(500).json({ code: 500, message: '服务器内部错误' });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`星域故事汇服务器运行在 http://localhost:${PORT}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = app;
|
|
||||||
0
server/app/__init__.py
Normal file
0
server/app/__init__.py
Normal file
BIN
server/app/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
server/app/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/app/__pycache__/config.cpython-310.pyc
Normal file
BIN
server/app/__pycache__/config.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/app/__pycache__/database.cpython-310.pyc
Normal file
BIN
server/app/__pycache__/database.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/app/__pycache__/main.cpython-310.pyc
Normal file
BIN
server/app/__pycache__/main.cpython-310.pyc
Normal file
Binary file not shown.
37
server/app/config.py
Normal file
37
server/app/config.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
"""
|
||||||
|
配置管理
|
||||||
|
"""
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
"""应用配置"""
|
||||||
|
# 数据库配置
|
||||||
|
db_host: str = "localhost"
|
||||||
|
db_port: int = 3306
|
||||||
|
db_user: str = "root"
|
||||||
|
db_password: str = ""
|
||||||
|
db_name: str = "stardom_story"
|
||||||
|
|
||||||
|
# 服务配置
|
||||||
|
server_host: str = "0.0.0.0"
|
||||||
|
server_port: int = 3000
|
||||||
|
debug: bool = True
|
||||||
|
|
||||||
|
# AI服务配置(预留)
|
||||||
|
openai_api_key: str = ""
|
||||||
|
openai_base_url: str = "https://api.openai.com/v1"
|
||||||
|
|
||||||
|
@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}"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
env_file_encoding = "utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache()
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
36
server/app/database.py
Normal file
36
server/app/database.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
数据库连接
|
||||||
|
"""
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# 创建异步引擎
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=settings.debug,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20
|
||||||
|
)
|
||||||
|
|
||||||
|
# 创建异步会话
|
||||||
|
AsyncSessionLocal = sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# 基类
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
"""获取数据库会话"""
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
51
server/app/main.py
Normal file
51
server/app/main.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"""
|
||||||
|
星域故事汇 - Python后端服务
|
||||||
|
"""
|
||||||
|
import uvicorn
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.routers import story, user
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# 创建应用
|
||||||
|
app = FastAPI(
|
||||||
|
title="星域故事汇",
|
||||||
|
description="互动故事小游戏后端服务",
|
||||||
|
version="1.0.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 跨域配置
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 注册路由
|
||||||
|
app.include_router(story.router, prefix="/api/stories", tags=["故事"])
|
||||||
|
app.include_router(user.router, prefix="/api/user", tags=["用户"])
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"message": "星域故事汇后端服务运行中", "version": "1.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"星域故事汇服务器运行在 http://localhost:{settings.server_port}")
|
||||||
|
uvicorn.run(
|
||||||
|
"app.main:app",
|
||||||
|
host=settings.server_host,
|
||||||
|
port=settings.server_port,
|
||||||
|
reload=settings.debug
|
||||||
|
)
|
||||||
2
server/app/models/__init__.py
Normal file
2
server/app/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from app.models.story import Story, StoryNode, StoryChoice
|
||||||
|
from app.models.user import User, UserProgress, UserEnding
|
||||||
BIN
server/app/models/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
server/app/models/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/app/models/__pycache__/story.cpython-310.pyc
Normal file
BIN
server/app/models/__pycache__/story.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/app/models/__pycache__/user.cpython-310.pyc
Normal file
BIN
server/app/models/__pycache__/user.cpython-310.pyc
Normal file
Binary file not shown.
66
server/app/models/story.py
Normal file
66
server/app/models/story.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
故事相关ORM模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Text, Boolean, TIMESTAMP, ForeignKey
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Story(Base):
|
||||||
|
"""故事主表"""
|
||||||
|
__tablename__ = "stories"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
title = Column(String(100), nullable=False)
|
||||||
|
cover_url = Column(String(255), default="")
|
||||||
|
description = Column(Text)
|
||||||
|
author_id = Column(Integer, default=0)
|
||||||
|
category = Column(String(50), nullable=False)
|
||||||
|
play_count = Column(Integer, default=0)
|
||||||
|
like_count = Column(Integer, default=0)
|
||||||
|
is_featured = Column(Boolean, default=False)
|
||||||
|
status = Column(Integer, default=1)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
nodes = relationship("StoryNode", back_populates="story", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class StoryNode(Base):
|
||||||
|
"""故事节点表"""
|
||||||
|
__tablename__ = "story_nodes"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
story_id = Column(Integer, ForeignKey("stories.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
node_key = Column(String(50), nullable=False)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
speaker = Column(String(50), default="")
|
||||||
|
background_image = Column(String(255), default="")
|
||||||
|
character_image = Column(String(255), default="")
|
||||||
|
bgm = Column(String(255), default="")
|
||||||
|
is_ending = Column(Boolean, default=False)
|
||||||
|
ending_name = Column(String(100), default="")
|
||||||
|
ending_score = Column(Integer, default=0)
|
||||||
|
ending_type = Column(String(20), default="")
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
story = relationship("Story", back_populates="nodes")
|
||||||
|
choices = relationship("StoryChoice", back_populates="node", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|
||||||
|
class StoryChoice(Base):
|
||||||
|
"""故事选项表"""
|
||||||
|
__tablename__ = "story_choices"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
node_id = Column(Integer, ForeignKey("story_nodes.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
story_id = Column(Integer, nullable=False)
|
||||||
|
text = Column(String(200), nullable=False)
|
||||||
|
next_node_key = Column(String(50), nullable=False)
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
is_locked = Column(Boolean, default=False)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
node = relationship("StoryNode", back_populates="choices")
|
||||||
58
server/app/models/user.py
Normal file
58
server/app/models/user.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
用户相关ORM模型
|
||||||
|
"""
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean, TIMESTAMP, ForeignKey, UniqueConstraint
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""用户表"""
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
openid = Column(String(100), unique=True, nullable=False)
|
||||||
|
nickname = Column(String(100), default="")
|
||||||
|
avatar_url = Column(String(255), default="")
|
||||||
|
gender = Column(Integer, default=0)
|
||||||
|
total_play_count = Column(Integer, default=0)
|
||||||
|
total_endings = Column(Integer, default=0)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class UserProgress(Base):
|
||||||
|
"""用户进度表"""
|
||||||
|
__tablename__ = "user_progress"
|
||||||
|
|
||||||
|
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)
|
||||||
|
current_node_key = Column(String(50), default="start")
|
||||||
|
is_completed = Column(Boolean, default=False)
|
||||||
|
ending_reached = Column(String(100), default="")
|
||||||
|
is_liked = Column(Boolean, default=False)
|
||||||
|
is_collected = Column(Boolean, default=False)
|
||||||
|
play_count = Column(Integer, default=1)
|
||||||
|
created_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
updated_at = Column(TIMESTAMP, server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('user_id', 'story_id', name='uk_user_story'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UserEnding(Base):
|
||||||
|
"""用户结局收集表"""
|
||||||
|
__tablename__ = "user_endings"
|
||||||
|
|
||||||
|
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_score = Column(Integer, default=0)
|
||||||
|
unlocked_at = Column(TIMESTAMP, server_default=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint('user_id', 'story_id', 'ending_name', name='uk_user_ending'),
|
||||||
|
)
|
||||||
1
server/app/routers/__init__.py
Normal file
1
server/app/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from app.routers import story, user
|
||||||
BIN
server/app/routers/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
server/app/routers/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/app/routers/__pycache__/story.cpython-310.pyc
Normal file
BIN
server/app/routers/__pycache__/story.cpython-310.pyc
Normal file
Binary file not shown.
BIN
server/app/routers/__pycache__/user.cpython-310.pyc
Normal file
BIN
server/app/routers/__pycache__/user.cpython-310.pyc
Normal file
Binary file not shown.
221
server/app/routers/story.py
Normal file
221
server/app/routers/story.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""
|
||||||
|
故事相关API路由
|
||||||
|
"""
|
||||||
|
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 pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.story import Story, StoryNode, StoryChoice
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 请求/响应模型 ==========
|
||||||
|
class LikeRequest(BaseModel):
|
||||||
|
like: bool
|
||||||
|
|
||||||
|
|
||||||
|
class RewriteRequest(BaseModel):
|
||||||
|
ending_name: str
|
||||||
|
ending_content: str
|
||||||
|
prompt: str
|
||||||
|
|
||||||
|
|
||||||
|
# ========== API接口 ==========
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_stories(
|
||||||
|
category: Optional[str] = Query(None),
|
||||||
|
featured: bool = Query(False),
|
||||||
|
limit: int = Query(20, ge=1, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取故事列表"""
|
||||||
|
query = select(Story).where(Story.status == 1)
|
||||||
|
|
||||||
|
if category:
|
||||||
|
query = query.where(Story.category == category)
|
||||||
|
if featured:
|
||||||
|
query = query.where(Story.is_featured == True)
|
||||||
|
|
||||||
|
query = query.order_by(Story.is_featured.desc(), Story.play_count.desc())
|
||||||
|
query = query.limit(limit).offset(offset)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
stories = result.scalars().all()
|
||||||
|
|
||||||
|
data = [{
|
||||||
|
"id": s.id,
|
||||||
|
"title": s.title,
|
||||||
|
"cover_url": s.cover_url,
|
||||||
|
"description": s.description,
|
||||||
|
"category": s.category,
|
||||||
|
"play_count": s.play_count,
|
||||||
|
"like_count": s.like_count,
|
||||||
|
"is_featured": s.is_featured
|
||||||
|
} for s in stories]
|
||||||
|
|
||||||
|
return {"code": 0, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hot")
|
||||||
|
async def get_hot_stories(
|
||||||
|
limit: int = Query(10, ge=1, le=50),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取热门故事"""
|
||||||
|
query = select(Story).where(Story.status == 1).order_by(Story.play_count.desc()).limit(limit)
|
||||||
|
result = await db.execute(query)
|
||||||
|
stories = result.scalars().all()
|
||||||
|
|
||||||
|
data = [{
|
||||||
|
"id": s.id,
|
||||||
|
"title": s.title,
|
||||||
|
"cover_url": s.cover_url,
|
||||||
|
"description": s.description,
|
||||||
|
"category": s.category,
|
||||||
|
"play_count": s.play_count,
|
||||||
|
"like_count": s.like_count
|
||||||
|
} for s in stories]
|
||||||
|
|
||||||
|
return {"code": 0, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
async def get_categories(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""获取分类列表"""
|
||||||
|
query = select(distinct(Story.category)).where(Story.status == 1)
|
||||||
|
result = await db.execute(query)
|
||||||
|
categories = [row[0] for row in result.all()]
|
||||||
|
|
||||||
|
return {"code": 0, "data": categories}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{story_id}")
|
||||||
|
async def get_story_detail(story_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""获取故事详情(含节点和选项)"""
|
||||||
|
# 获取故事
|
||||||
|
result = await db.execute(select(Story).where(Story.id == story_id, Story.status == 1))
|
||||||
|
story = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not story:
|
||||||
|
raise HTTPException(status_code=404, detail="故事不存在")
|
||||||
|
|
||||||
|
# 获取节点
|
||||||
|
nodes_result = await db.execute(
|
||||||
|
select(StoryNode).where(StoryNode.story_id == story_id).order_by(StoryNode.sort_order)
|
||||||
|
)
|
||||||
|
nodes = nodes_result.scalars().all()
|
||||||
|
|
||||||
|
# 获取选项
|
||||||
|
choices_result = await db.execute(
|
||||||
|
select(StoryChoice).where(StoryChoice.story_id == story_id).order_by(StoryChoice.sort_order)
|
||||||
|
)
|
||||||
|
choices = choices_result.scalars().all()
|
||||||
|
|
||||||
|
# 组装节点和选项
|
||||||
|
nodes_map = {}
|
||||||
|
for node in nodes:
|
||||||
|
nodes_map[node.node_key] = {
|
||||||
|
"id": node.id,
|
||||||
|
"node_key": node.node_key,
|
||||||
|
"content": node.content,
|
||||||
|
"speaker": node.speaker,
|
||||||
|
"background_image": node.background_image,
|
||||||
|
"character_image": node.character_image,
|
||||||
|
"bgm": node.bgm,
|
||||||
|
"is_ending": node.is_ending,
|
||||||
|
"ending_name": node.ending_name,
|
||||||
|
"ending_score": node.ending_score,
|
||||||
|
"ending_type": node.ending_type,
|
||||||
|
"choices": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for choice in choices:
|
||||||
|
# 找到对应的节点
|
||||||
|
for node in nodes:
|
||||||
|
if node.id == choice.node_id and node.node_key in nodes_map:
|
||||||
|
nodes_map[node.node_key]["choices"].append({
|
||||||
|
"text": choice.text,
|
||||||
|
"nextNodeKey": choice.next_node_key,
|
||||||
|
"isLocked": choice.is_locked
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"id": story.id,
|
||||||
|
"title": story.title,
|
||||||
|
"cover_url": story.cover_url,
|
||||||
|
"description": story.description,
|
||||||
|
"category": story.category,
|
||||||
|
"author_id": story.author_id,
|
||||||
|
"play_count": story.play_count,
|
||||||
|
"like_count": story.like_count,
|
||||||
|
"is_featured": story.is_featured,
|
||||||
|
"nodes": nodes_map
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"code": 0, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{story_id}/play")
|
||||||
|
async def record_play(story_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""记录游玩"""
|
||||||
|
await db.execute(
|
||||||
|
update(Story).where(Story.id == story_id).values(play_count=Story.play_count + 1)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"code": 0, "message": "记录成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{story_id}/like")
|
||||||
|
async def toggle_like(story_id: int, request: LikeRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""点赞/取消点赞"""
|
||||||
|
delta = 1 if request.like else -1
|
||||||
|
await db.execute(
|
||||||
|
update(Story).where(Story.id == story_id).values(like_count=Story.like_count + delta)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"code": 0, "message": "点赞成功" if request.like else "取消点赞成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{story_id}/rewrite")
|
||||||
|
async def ai_rewrite_ending(story_id: int, request: RewriteRequest, 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()
|
||||||
|
|
||||||
|
# 模拟AI生成(后续替换为真实API调用)
|
||||||
|
templates = [
|
||||||
|
f"根据你的愿望「{request.prompt}」,故事有了新的发展...\n\n",
|
||||||
|
f"命运的齿轮开始转动,{request.prompt}...\n\n",
|
||||||
|
f"在另一个平行世界里,{request.prompt}成为了现实...\n\n"
|
||||||
|
]
|
||||||
|
|
||||||
|
template = random.choice(templates)
|
||||||
|
new_content = (
|
||||||
|
template +
|
||||||
|
"原本的结局被改写,新的故事在这里展开。\n\n" +
|
||||||
|
f"【AI改写提示】这是基于「{request.prompt}」生成的新结局。\n" +
|
||||||
|
"实际部署时,这里将由AI大模型根据上下文生成更精彩的内容。"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"content": new_content,
|
||||||
|
"speaker": "旁白",
|
||||||
|
"is_ending": True,
|
||||||
|
"ending_name": f"{request.ending_name}(改写版)",
|
||||||
|
"ending_type": "rewrite"
|
||||||
|
}
|
||||||
|
}
|
||||||
421
server/app/routers/user.py
Normal file
421
server/app/routers/user.py
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
"""
|
||||||
|
用户相关API路由
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, update, func, text
|
||||||
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models.user import User, UserProgress, UserEnding
|
||||||
|
from app.models.story import Story
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ========== 请求/响应模型 ==========
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
userInfo: Optional[dict] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileRequest(BaseModel):
|
||||||
|
nickname: str
|
||||||
|
avatarUrl: str
|
||||||
|
gender: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressRequest(BaseModel):
|
||||||
|
userId: int
|
||||||
|
storyId: int
|
||||||
|
currentNodeKey: str
|
||||||
|
isCompleted: bool = False
|
||||||
|
endingReached: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class LikeRequest(BaseModel):
|
||||||
|
userId: int
|
||||||
|
storyId: int
|
||||||
|
isLiked: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CollectRequest(BaseModel):
|
||||||
|
userId: int
|
||||||
|
storyId: int
|
||||||
|
isCollected: bool
|
||||||
|
|
||||||
|
|
||||||
|
# ========== API接口 ==========
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""微信登录"""
|
||||||
|
# 实际部署时需要调用微信API获取openid
|
||||||
|
# 这里简化处理:用code作为openid
|
||||||
|
openid = request.code
|
||||||
|
|
||||||
|
# 查找或创建用户
|
||||||
|
result = await db.execute(select(User).where(User.openid == openid))
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
user_info = request.userInfo or {}
|
||||||
|
user = User(
|
||||||
|
openid=openid,
|
||||||
|
nickname=user_info.get("nickname", ""),
|
||||||
|
avatar_url=user_info.get("avatarUrl", ""),
|
||||||
|
gender=user_info.get("gender", 0)
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(user)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/profile")
|
||||||
|
async def update_profile(request: ProfileRequest, user_id: int = Query(..., alias="userId"), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""更新用户信息"""
|
||||||
|
await db.execute(
|
||||||
|
update(User).where(User.id == user_id).values(
|
||||||
|
nickname=request.nickname,
|
||||||
|
avatar_url=request.avatarUrl,
|
||||||
|
gender=request.gender
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"code": 0, "message": "更新成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/progress")
|
||||||
|
async def get_progress(
|
||||||
|
user_id: int = Query(..., alias="userId"),
|
||||||
|
story_id: Optional[str] = Query(None, alias="storyId"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取用户进度"""
|
||||||
|
# 处理 storyId 为 "null" 字符串的情况
|
||||||
|
story_id_int = None
|
||||||
|
if story_id and story_id != "null":
|
||||||
|
try:
|
||||||
|
story_id_int = int(story_id)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
query = (
|
||||||
|
select(UserProgress, Story.title.label("story_title"), Story.cover_url)
|
||||||
|
.join(Story, UserProgress.story_id == Story.id)
|
||||||
|
.where(UserProgress.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if story_id_int:
|
||||||
|
query = query.where(UserProgress.story_id == story_id_int)
|
||||||
|
|
||||||
|
query = query.order_by(UserProgress.updated_at.desc())
|
||||||
|
result = await db.execute(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
data = [{
|
||||||
|
"id": row.UserProgress.id,
|
||||||
|
"user_id": row.UserProgress.user_id,
|
||||||
|
"story_id": row.UserProgress.story_id,
|
||||||
|
"story_title": row.story_title,
|
||||||
|
"cover_url": row.cover_url,
|
||||||
|
"current_node_key": row.UserProgress.current_node_key,
|
||||||
|
"is_completed": row.UserProgress.is_completed,
|
||||||
|
"ending_reached": row.UserProgress.ending_reached,
|
||||||
|
"is_liked": row.UserProgress.is_liked,
|
||||||
|
"is_collected": row.UserProgress.is_collected,
|
||||||
|
"play_count": row.UserProgress.play_count
|
||||||
|
} for row in rows]
|
||||||
|
|
||||||
|
if story_id_int:
|
||||||
|
return {"code": 0, "data": data[0] if data else None}
|
||||||
|
return {"code": 0, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/progress")
|
||||||
|
async def save_progress(request: ProgressRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""保存用户进度"""
|
||||||
|
user_id = request.userId
|
||||||
|
story_id = request.storyId
|
||||||
|
|
||||||
|
# 查找是否存在
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserProgress).where(
|
||||||
|
UserProgress.user_id == user_id,
|
||||||
|
UserProgress.story_id == story_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
progress = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if progress:
|
||||||
|
# 更新
|
||||||
|
await db.execute(
|
||||||
|
update(UserProgress).where(UserProgress.id == progress.id).values(
|
||||||
|
current_node_key=request.currentNodeKey,
|
||||||
|
is_completed=request.isCompleted,
|
||||||
|
ending_reached=request.endingReached,
|
||||||
|
play_count=UserProgress.play_count + 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 新建
|
||||||
|
progress = UserProgress(
|
||||||
|
user_id=user_id,
|
||||||
|
story_id=story_id,
|
||||||
|
current_node_key=request.currentNodeKey,
|
||||||
|
is_completed=request.isCompleted,
|
||||||
|
ending_reached=request.endingReached
|
||||||
|
)
|
||||||
|
db.add(progress)
|
||||||
|
|
||||||
|
# 如果完成,记录结局
|
||||||
|
if request.isCompleted and request.endingReached:
|
||||||
|
# 检查是否已存在
|
||||||
|
ending_result = await db.execute(
|
||||||
|
select(UserEnding).where(
|
||||||
|
UserEnding.user_id == user_id,
|
||||||
|
UserEnding.story_id == story_id,
|
||||||
|
UserEnding.ending_name == request.endingReached
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not ending_result.scalar_one_or_none():
|
||||||
|
ending = UserEnding(
|
||||||
|
user_id=user_id,
|
||||||
|
story_id=story_id,
|
||||||
|
ending_name=request.endingReached
|
||||||
|
)
|
||||||
|
db.add(ending)
|
||||||
|
|
||||||
|
# 更新用户统计
|
||||||
|
await db.execute(
|
||||||
|
update(User).where(User.id == user_id).values(
|
||||||
|
total_play_count=User.total_play_count + 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# 更新结局总数
|
||||||
|
count_result = await db.execute(
|
||||||
|
select(func.count()).select_from(UserEnding).where(UserEnding.user_id == user_id)
|
||||||
|
)
|
||||||
|
count = count_result.scalar()
|
||||||
|
await db.execute(
|
||||||
|
update(User).where(User.id == user_id).values(total_endings=count)
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"code": 0, "message": "保存成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/like")
|
||||||
|
async def toggle_like(request: LikeRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""点赞/取消点赞"""
|
||||||
|
user_id = request.userId
|
||||||
|
story_id = request.storyId
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserProgress).where(
|
||||||
|
UserProgress.user_id == user_id,
|
||||||
|
UserProgress.story_id == story_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
progress = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if progress:
|
||||||
|
await db.execute(
|
||||||
|
update(UserProgress).where(UserProgress.id == progress.id).values(is_liked=request.isLiked)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
progress = UserProgress(
|
||||||
|
user_id=user_id,
|
||||||
|
story_id=story_id,
|
||||||
|
is_liked=request.isLiked
|
||||||
|
)
|
||||||
|
db.add(progress)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"code": 0, "message": "点赞成功" if request.isLiked else "取消点赞成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collect")
|
||||||
|
async def toggle_collect(request: CollectRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""收藏/取消收藏"""
|
||||||
|
user_id = request.userId
|
||||||
|
story_id = request.storyId
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserProgress).where(
|
||||||
|
UserProgress.user_id == user_id,
|
||||||
|
UserProgress.story_id == story_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
progress = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if progress:
|
||||||
|
await db.execute(
|
||||||
|
update(UserProgress).where(UserProgress.id == progress.id).values(is_collected=request.isCollected)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
progress = UserProgress(
|
||||||
|
user_id=user_id,
|
||||||
|
story_id=story_id,
|
||||||
|
is_collected=request.isCollected
|
||||||
|
)
|
||||||
|
db.add(progress)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return {"code": 0, "message": "收藏成功" if request.isCollected else "取消收藏成功"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collections")
|
||||||
|
async def get_collections(user_id: int = Query(..., alias="userId"), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""获取收藏列表"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Story)
|
||||||
|
.join(UserProgress, Story.id == UserProgress.story_id)
|
||||||
|
.where(UserProgress.user_id == user_id, UserProgress.is_collected == True)
|
||||||
|
.order_by(UserProgress.updated_at.desc())
|
||||||
|
)
|
||||||
|
stories = result.scalars().all()
|
||||||
|
|
||||||
|
data = [{
|
||||||
|
"id": s.id,
|
||||||
|
"title": s.title,
|
||||||
|
"cover_url": s.cover_url,
|
||||||
|
"description": s.description,
|
||||||
|
"category": s.category,
|
||||||
|
"play_count": s.play_count,
|
||||||
|
"like_count": s.like_count
|
||||||
|
} for s in stories]
|
||||||
|
|
||||||
|
return {"code": 0, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/endings")
|
||||||
|
async def get_unlocked_endings(
|
||||||
|
user_id: int = Query(..., alias="userId"),
|
||||||
|
story_id: Optional[str] = Query(None, alias="storyId"),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取已解锁结局"""
|
||||||
|
# 处理 storyId 为 "null" 字符串的情况
|
||||||
|
story_id_int = None
|
||||||
|
if story_id and story_id != "null":
|
||||||
|
try:
|
||||||
|
story_id_int = int(story_id)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
query = select(UserEnding).where(UserEnding.user_id == user_id)
|
||||||
|
if story_id_int:
|
||||||
|
query = query.where(UserEnding.story_id == story_id_int)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
endings = result.scalars().all()
|
||||||
|
|
||||||
|
data = [{
|
||||||
|
"id": e.id,
|
||||||
|
"story_id": e.story_id,
|
||||||
|
"ending_name": e.ending_name,
|
||||||
|
"ending_score": e.ending_score,
|
||||||
|
"unlocked_at": str(e.unlocked_at) if e.unlocked_at else None
|
||||||
|
} for e in endings]
|
||||||
|
|
||||||
|
return {"code": 0, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/my-works")
|
||||||
|
async def get_my_works(user_id: int = Query(..., alias="userId"), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""获取我的作品"""
|
||||||
|
try:
|
||||||
|
result = await db.execute(
|
||||||
|
select(Story).where(Story.author_id == user_id).order_by(Story.created_at.desc())
|
||||||
|
)
|
||||||
|
works = result.scalars().all()
|
||||||
|
|
||||||
|
data = [{
|
||||||
|
"id": w.id,
|
||||||
|
"title": w.title,
|
||||||
|
"description": w.description,
|
||||||
|
"category": w.category,
|
||||||
|
"cover_url": w.cover_url,
|
||||||
|
"play_count": w.play_count,
|
||||||
|
"like_count": w.like_count,
|
||||||
|
"status": w.status,
|
||||||
|
"created_at": str(w.created_at) if w.created_at else None,
|
||||||
|
"updated_at": str(w.updated_at) if w.updated_at else None
|
||||||
|
} for w in works]
|
||||||
|
|
||||||
|
return {"code": 0, "data": data}
|
||||||
|
except Exception:
|
||||||
|
return {"code": 0, "data": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/drafts")
|
||||||
|
async def get_drafts(user_id: int = Query(..., alias="userId"), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""获取草稿箱(预留)"""
|
||||||
|
# story_drafts表可能不存在,返回空
|
||||||
|
return {"code": 0, "data": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-played")
|
||||||
|
async def get_recent_played(
|
||||||
|
user_id: int = Query(..., alias="userId"),
|
||||||
|
limit: int = Query(10, ge=1, le=50),
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""获取最近游玩"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Story, UserProgress)
|
||||||
|
.join(UserProgress, Story.id == UserProgress.story_id)
|
||||||
|
.where(UserProgress.user_id == user_id)
|
||||||
|
.order_by(UserProgress.updated_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
data = [{
|
||||||
|
"id": row.Story.id,
|
||||||
|
"title": row.Story.title,
|
||||||
|
"category": row.Story.category,
|
||||||
|
"description": row.Story.description,
|
||||||
|
"cover_url": row.Story.cover_url,
|
||||||
|
"current_node_key": row.UserProgress.current_node_key,
|
||||||
|
"is_completed": row.UserProgress.is_completed,
|
||||||
|
"progress": "已完成" if row.UserProgress.is_completed else "进行中"
|
||||||
|
} for row in rows]
|
||||||
|
|
||||||
|
return {"code": 0, "data": data}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ai-history")
|
||||||
|
async def get_ai_history(user_id: int = Query(..., alias="userId"), limit: int = Query(20), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""获取AI创作历史(预留)"""
|
||||||
|
return {"code": 0, "data": []}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ai-quota")
|
||||||
|
async def get_ai_quota(user_id: int = Query(..., alias="userId"), db: AsyncSession = Depends(get_db)):
|
||||||
|
"""获取AI配额"""
|
||||||
|
# 返回默认值
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"daily": 3,
|
||||||
|
"used": 0,
|
||||||
|
"purchased": 0,
|
||||||
|
"gift": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
const mysql = require('mysql2/promise');
|
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
|
||||||
host: process.env.DB_HOST || 'localhost',
|
|
||||||
port: process.env.DB_PORT || 3306,
|
|
||||||
user: process.env.DB_USER || 'root',
|
|
||||||
password: process.env.DB_PASSWORD || '',
|
|
||||||
database: process.env.DB_NAME || 'stardom_story',
|
|
||||||
waitForConnections: true,
|
|
||||||
connectionLimit: 10,
|
|
||||||
queueLimit: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = pool;
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
const pool = require('../config/db');
|
|
||||||
|
|
||||||
const StoryModel = {
|
|
||||||
// 获取故事列表
|
|
||||||
async getList(options = {}) {
|
|
||||||
const { category, featured, limit = 20, offset = 0 } = options;
|
|
||||||
let sql = `SELECT id, title, cover_url, description, category, play_count, like_count, is_featured
|
|
||||||
FROM stories WHERE status = 1`;
|
|
||||||
const params = [];
|
|
||||||
|
|
||||||
if (category) {
|
|
||||||
sql += ' AND category = ?';
|
|
||||||
params.push(category);
|
|
||||||
}
|
|
||||||
if (featured) {
|
|
||||||
sql += ' AND is_featured = 1';
|
|
||||||
}
|
|
||||||
|
|
||||||
sql += ' ORDER BY is_featured DESC, play_count DESC LIMIT ? OFFSET ?';
|
|
||||||
params.push(limit, offset);
|
|
||||||
|
|
||||||
const [rows] = await pool.query(sql, params);
|
|
||||||
return rows;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取故事详情(含节点和选项)
|
|
||||||
async getDetail(storyId) {
|
|
||||||
// 获取故事基本信息
|
|
||||||
const [stories] = await pool.query(
|
|
||||||
'SELECT * FROM stories WHERE id = ? AND status = 1',
|
|
||||||
[storyId]
|
|
||||||
);
|
|
||||||
if (stories.length === 0) return null;
|
|
||||||
|
|
||||||
const story = stories[0];
|
|
||||||
|
|
||||||
// 获取所有节点
|
|
||||||
const [nodes] = await pool.query(
|
|
||||||
`SELECT id, node_key, content, speaker, background_image, character_image, bgm,
|
|
||||||
is_ending, ending_name, ending_score, ending_type
|
|
||||||
FROM story_nodes WHERE story_id = ? ORDER BY sort_order`,
|
|
||||||
[storyId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 获取所有选项
|
|
||||||
const [choices] = await pool.query(
|
|
||||||
'SELECT id, node_id, text, next_node_key, is_locked FROM story_choices WHERE story_id = ? ORDER BY sort_order',
|
|
||||||
[storyId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 组装节点和选项
|
|
||||||
const nodesMap = {};
|
|
||||||
nodes.forEach(node => {
|
|
||||||
nodesMap[node.node_key] = {
|
|
||||||
...node,
|
|
||||||
choices: []
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
choices.forEach(choice => {
|
|
||||||
const node = nodes.find(n => n.id === choice.node_id);
|
|
||||||
if (node && nodesMap[node.node_key]) {
|
|
||||||
nodesMap[node.node_key].choices.push({
|
|
||||||
text: choice.text,
|
|
||||||
nextNodeKey: choice.next_node_key,
|
|
||||||
isLocked: choice.is_locked
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
story.nodes = nodesMap;
|
|
||||||
return story;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 增加游玩次数
|
|
||||||
async incrementPlayCount(storyId) {
|
|
||||||
await pool.query(
|
|
||||||
'UPDATE stories SET play_count = play_count + 1 WHERE id = ?',
|
|
||||||
[storyId]
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 点赞/取消点赞
|
|
||||||
async toggleLike(storyId, increment) {
|
|
||||||
const delta = increment ? 1 : -1;
|
|
||||||
await pool.query(
|
|
||||||
'UPDATE stories SET like_count = like_count + ? WHERE id = ?',
|
|
||||||
[delta, storyId]
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取热门故事
|
|
||||||
async getHotStories(limit = 10) {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT id, title, cover_url, description, category, play_count, like_count
|
|
||||||
FROM stories WHERE status = 1 ORDER BY play_count DESC LIMIT ?`,
|
|
||||||
[limit]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取分类列表
|
|
||||||
async getCategories() {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
'SELECT DISTINCT category FROM stories WHERE status = 1'
|
|
||||||
);
|
|
||||||
return rows.map(r => r.category);
|
|
||||||
},
|
|
||||||
|
|
||||||
// AI改写结局
|
|
||||||
async aiRewriteEnding({ storyId, endingName, endingContent, prompt }) {
|
|
||||||
// 获取故事信息用于上下文
|
|
||||||
const [stories] = await pool.query(
|
|
||||||
'SELECT title, category, description FROM stories WHERE id = ?',
|
|
||||||
[storyId]
|
|
||||||
);
|
|
||||||
const story = stories[0];
|
|
||||||
|
|
||||||
// TODO: 接入真实AI服务(OpenAI/Claude/自建模型)
|
|
||||||
// 这里先返回模拟结果,后续替换为真实AI调用
|
|
||||||
const aiContent = await this.callAIService({
|
|
||||||
storyTitle: story?.title,
|
|
||||||
storyCategory: story?.category,
|
|
||||||
originalEnding: endingContent,
|
|
||||||
userPrompt: prompt
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
content: aiContent,
|
|
||||||
speaker: '旁白',
|
|
||||||
is_ending: true,
|
|
||||||
ending_name: `${endingName}(改写版)`,
|
|
||||||
ending_type: 'rewrite'
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
// AI服务调用(模拟/真实)
|
|
||||||
async callAIService({ storyTitle, storyCategory, originalEnding, userPrompt }) {
|
|
||||||
// 模拟AI生成内容
|
|
||||||
// 实际部署时替换为真实API调用
|
|
||||||
const templates = [
|
|
||||||
`根据你的愿望「${userPrompt}」,故事有了新的发展...\n\n`,
|
|
||||||
`命运的齿轮开始转动,${userPrompt}...\n\n`,
|
|
||||||
`在另一个平行世界里,${userPrompt}成为了现实...\n\n`
|
|
||||||
];
|
|
||||||
|
|
||||||
const template = templates[Math.floor(Math.random() * templates.length)];
|
|
||||||
const newContent = template +
|
|
||||||
`原本的结局被改写,新的故事在这里展开。\n\n` +
|
|
||||||
`【AI改写提示】这是基于「${userPrompt}」生成的新结局。\n` +
|
|
||||||
`实际部署时,这里将由AI大模型根据上下文生成更精彩的内容。`;
|
|
||||||
|
|
||||||
return newContent;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = StoryModel;
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
const pool = require('../config/db');
|
|
||||||
|
|
||||||
const UserModel = {
|
|
||||||
// 通过openid查找或创建用户
|
|
||||||
async findOrCreate(openid, userInfo = {}) {
|
|
||||||
const [existing] = await pool.query(
|
|
||||||
'SELECT * FROM users WHERE openid = ?',
|
|
||||||
[openid]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing.length > 0) {
|
|
||||||
return existing[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建新用户
|
|
||||||
const [result] = await pool.query(
|
|
||||||
'INSERT INTO users (openid, nickname, avatar_url, gender) VALUES (?, ?, ?, ?)',
|
|
||||||
[openid, userInfo.nickname || '', userInfo.avatarUrl || '', userInfo.gender || 0]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: result.insertId,
|
|
||||||
openid,
|
|
||||||
nickname: userInfo.nickname || '',
|
|
||||||
avatar_url: userInfo.avatarUrl || '',
|
|
||||||
gender: userInfo.gender || 0
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
// 更新用户信息
|
|
||||||
async updateProfile(userId, userInfo) {
|
|
||||||
await pool.query(
|
|
||||||
'UPDATE users SET nickname = ?, avatar_url = ?, gender = ? WHERE id = ?',
|
|
||||||
[userInfo.nickname, userInfo.avatarUrl, userInfo.gender, userId]
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取用户进度
|
|
||||||
async getProgress(userId, storyId = null) {
|
|
||||||
let sql = `SELECT up.*, s.title as story_title, s.cover_url
|
|
||||||
FROM user_progress up
|
|
||||||
JOIN stories s ON up.story_id = s.id
|
|
||||||
WHERE up.user_id = ?`;
|
|
||||||
const params = [userId];
|
|
||||||
|
|
||||||
if (storyId) {
|
|
||||||
sql += ' AND up.story_id = ?';
|
|
||||||
params.push(storyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
sql += ' ORDER BY up.updated_at DESC';
|
|
||||||
const [rows] = await pool.query(sql, params);
|
|
||||||
return storyId ? rows[0] : rows;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 保存用户进度
|
|
||||||
async saveProgress(userId, storyId, data) {
|
|
||||||
const { currentNodeKey, isCompleted, endingReached } = data;
|
|
||||||
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO user_progress (user_id, story_id, current_node_key, is_completed, ending_reached)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
current_node_key = VALUES(current_node_key),
|
|
||||||
is_completed = VALUES(is_completed),
|
|
||||||
ending_reached = VALUES(ending_reached),
|
|
||||||
play_count = play_count + 1`,
|
|
||||||
[userId, storyId, currentNodeKey, isCompleted || false, endingReached || '']
|
|
||||||
);
|
|
||||||
|
|
||||||
// 如果完成了,记录结局
|
|
||||||
if (isCompleted && endingReached) {
|
|
||||||
await pool.query(
|
|
||||||
`INSERT IGNORE INTO user_endings (user_id, story_id, ending_name) VALUES (?, ?, ?)`,
|
|
||||||
[userId, storyId, endingReached]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 更新用户统计
|
|
||||||
await pool.query(
|
|
||||||
'UPDATE users SET total_play_count = total_play_count + 1, total_endings = (SELECT COUNT(*) FROM user_endings WHERE user_id = ?) WHERE id = ?',
|
|
||||||
[userId, userId]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 点赞/取消点赞
|
|
||||||
async toggleLike(userId, storyId, isLiked) {
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO user_progress (user_id, story_id, is_liked)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE is_liked = ?`,
|
|
||||||
[userId, storyId, isLiked, isLiked]
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 收藏/取消收藏
|
|
||||||
async toggleCollect(userId, storyId, isCollected) {
|
|
||||||
await pool.query(
|
|
||||||
`INSERT INTO user_progress (user_id, story_id, is_collected)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON DUPLICATE KEY UPDATE is_collected = ?`,
|
|
||||||
[userId, storyId, isCollected, isCollected]
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取收藏列表
|
|
||||||
async getCollections(userId) {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT s.id, s.title, s.cover_url, s.description, s.category, s.play_count, s.like_count
|
|
||||||
FROM user_progress up
|
|
||||||
JOIN stories s ON up.story_id = s.id
|
|
||||||
WHERE up.user_id = ? AND up.is_collected = 1
|
|
||||||
ORDER BY up.updated_at DESC`,
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取用户解锁的结局
|
|
||||||
async getUnlockedEndings(userId, storyId = null) {
|
|
||||||
let sql = 'SELECT * FROM user_endings WHERE user_id = ?';
|
|
||||||
const params = [userId];
|
|
||||||
|
|
||||||
if (storyId) {
|
|
||||||
sql += ' AND story_id = ?';
|
|
||||||
params.push(storyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [rows] = await pool.query(sql, params);
|
|
||||||
return rows;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取我的作品
|
|
||||||
async getMyWorks(userId) {
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT id, title, description, category, cover_url, play_count, like_count,
|
|
||||||
comment_count, status, created_at, updated_at,
|
|
||||||
COALESCE(earnings, 0) as earnings
|
|
||||||
FROM stories
|
|
||||||
WHERE author_id = ?
|
|
||||||
ORDER BY created_at DESC`,
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch (e) {
|
|
||||||
// 表可能还没有author_id字段,返回空数组
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取草稿箱
|
|
||||||
async getDrafts(userId) {
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT id, title, category, node_count, source, created_at, updated_at
|
|
||||||
FROM story_drafts
|
|
||||||
WHERE user_id = ?
|
|
||||||
ORDER BY updated_at DESC`,
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch (e) {
|
|
||||||
// 表可能不存在,返回空数组
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取最近游玩
|
|
||||||
async getRecentPlayed(userId, limit = 10) {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT s.id, s.title, s.category, s.description, s.cover_url,
|
|
||||||
up.current_node_key, up.is_completed,
|
|
||||||
CASE WHEN up.is_completed THEN '已完成' ELSE '进行中' END as progress
|
|
||||||
FROM user_progress up
|
|
||||||
JOIN stories s ON up.story_id = s.id
|
|
||||||
WHERE up.user_id = ?
|
|
||||||
ORDER BY up.updated_at DESC
|
|
||||||
LIMIT ?`,
|
|
||||||
[userId, limit]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取AI创作历史
|
|
||||||
async getAIHistory(userId, limit = 20) {
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT id, gen_type, input_prompt, output_content, status, created_at
|
|
||||||
FROM ai_generations
|
|
||||||
WHERE user_id = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?`,
|
|
||||||
[userId, limit]
|
|
||||||
);
|
|
||||||
return rows;
|
|
||||||
} catch (e) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取AI配额
|
|
||||||
async getAIQuota(userId) {
|
|
||||||
try {
|
|
||||||
const [rows] = await pool.query(
|
|
||||||
`SELECT daily_free_total as daily, daily_free_used as used,
|
|
||||||
purchased_quota as purchased, gift_quota as gift
|
|
||||||
FROM user_ai_quota
|
|
||||||
WHERE user_id = ?`,
|
|
||||||
[userId]
|
|
||||||
);
|
|
||||||
if (rows.length > 0) {
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
// 没有记录则返回默认值
|
|
||||||
return { daily: 3, used: 0, purchased: 0, gift: 0 };
|
|
||||||
} catch (e) {
|
|
||||||
return { daily: 3, used: 0, purchased: 0, gift: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = UserModel;
|
|
||||||
1269
server/package-lock.json
generated
1269
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "stardom-story-server",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "星域故事汇小游戏后端服务",
|
|
||||||
"main": "app.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node app.js",
|
|
||||||
"dev": "nodemon app.js",
|
|
||||||
"init-db": "node sql/init.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^4.18.2",
|
|
||||||
"mysql2": "^3.6.5",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^16.3.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"nodemon": "^3.0.2"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
10
server/requirements.txt
Normal file
10
server/requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Python后端依赖
|
||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
sqlalchemy==2.0.23
|
||||||
|
aiomysql==0.2.0
|
||||||
|
pymysql==1.1.0
|
||||||
|
pydantic==2.5.2
|
||||||
|
pydantic-settings==2.1.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
python-multipart==0.0.6
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const StoryModel = require('../models/story');
|
|
||||||
|
|
||||||
// 获取故事列表
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { category, featured, limit, offset } = req.query;
|
|
||||||
const stories = await StoryModel.getList({
|
|
||||||
category,
|
|
||||||
featured: featured === 'true',
|
|
||||||
limit: parseInt(limit) || 20,
|
|
||||||
offset: parseInt(offset) || 0
|
|
||||||
});
|
|
||||||
res.json({ code: 0, data: stories });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取故事列表失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '获取故事列表失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取热门故事
|
|
||||||
router.get('/hot', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { limit } = req.query;
|
|
||||||
const stories = await StoryModel.getHotStories(parseInt(limit) || 10);
|
|
||||||
res.json({ code: 0, data: stories });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取热门故事失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '获取热门故事失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取分类列表
|
|
||||||
router.get('/categories', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const categories = await StoryModel.getCategories();
|
|
||||||
res.json({ code: 0, data: categories });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取分类列表失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '获取分类列表失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取故事详情
|
|
||||||
router.get('/:id', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const storyId = parseInt(req.params.id);
|
|
||||||
const story = await StoryModel.getDetail(storyId);
|
|
||||||
if (!story) {
|
|
||||||
return res.status(404).json({ code: 404, message: '故事不存在' });
|
|
||||||
}
|
|
||||||
res.json({ code: 0, data: story });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取故事详情失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '获取故事详情失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 记录游玩
|
|
||||||
router.post('/:id/play', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const storyId = parseInt(req.params.id);
|
|
||||||
await StoryModel.incrementPlayCount(storyId);
|
|
||||||
res.json({ code: 0, message: '记录成功' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('记录游玩失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '记录游玩失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 点赞
|
|
||||||
router.post('/:id/like', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const storyId = parseInt(req.params.id);
|
|
||||||
const { like } = req.body; // true: 点赞, false: 取消点赞
|
|
||||||
await StoryModel.toggleLike(storyId, like);
|
|
||||||
res.json({ code: 0, message: like ? '点赞成功' : '取消点赞成功' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('点赞操作失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '点赞操作失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// AI改写结局
|
|
||||||
router.post('/:id/rewrite', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const storyId = parseInt(req.params.id);
|
|
||||||
const { ending_name, ending_content, prompt } = req.body;
|
|
||||||
|
|
||||||
if (!prompt) {
|
|
||||||
return res.status(400).json({ code: 400, message: '请输入改写指令' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用AI服务生成新内容
|
|
||||||
const result = await StoryModel.aiRewriteEnding({
|
|
||||||
storyId,
|
|
||||||
endingName: ending_name,
|
|
||||||
endingContent: ending_content,
|
|
||||||
prompt
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ code: 0, data: result });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('AI改写失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: 'AI改写失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const UserModel = require('../models/user');
|
|
||||||
|
|
||||||
// 模拟微信登录(实际环境需要调用微信API)
|
|
||||||
router.post('/login', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { code, userInfo } = req.body;
|
|
||||||
|
|
||||||
// 实际项目中需要用code换取openid
|
|
||||||
// 这里为了开发测试,暂时用code作为openid
|
|
||||||
const openid = code || `test_user_${Date.now()}`;
|
|
||||||
|
|
||||||
const user = await UserModel.findOrCreate(openid, userInfo);
|
|
||||||
res.json({
|
|
||||||
code: 0,
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
openid: user.openid,
|
|
||||||
nickname: user.nickname,
|
|
||||||
avatarUrl: user.avatar_url
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('登录失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '登录失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 更新用户信息
|
|
||||||
router.post('/profile', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId, nickname, avatarUrl, gender } = req.body;
|
|
||||||
await UserModel.updateProfile(userId, { nickname, avatarUrl, gender });
|
|
||||||
res.json({ code: 0, message: '更新成功' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('更新用户信息失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '更新用户信息失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取用户进度
|
|
||||||
router.get('/progress', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId, storyId } = req.query;
|
|
||||||
const progress = await UserModel.getProgress(
|
|
||||||
parseInt(userId),
|
|
||||||
storyId ? parseInt(storyId) : null
|
|
||||||
);
|
|
||||||
res.json({ code: 0, data: progress });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取进度失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '获取进度失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 保存用户进度
|
|
||||||
router.post('/progress', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId, storyId, currentNodeKey, isCompleted, endingReached } = req.body;
|
|
||||||
await UserModel.saveProgress(parseInt(userId), parseInt(storyId), {
|
|
||||||
currentNodeKey,
|
|
||||||
isCompleted,
|
|
||||||
endingReached
|
|
||||||
});
|
|
||||||
res.json({ code: 0, message: '保存成功' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('保存进度失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '保存进度失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 点赞操作
|
|
||||||
router.post('/like', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId, storyId, isLiked } = req.body;
|
|
||||||
await UserModel.toggleLike(parseInt(userId), parseInt(storyId), isLiked);
|
|
||||||
res.json({ code: 0, message: isLiked ? '点赞成功' : '取消点赞' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('点赞操作失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '点赞操作失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 收藏操作
|
|
||||||
router.post('/collect', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId, storyId, isCollected } = req.body;
|
|
||||||
await UserModel.toggleCollect(parseInt(userId), parseInt(storyId), isCollected);
|
|
||||||
res.json({ code: 0, message: isCollected ? '收藏成功' : '取消收藏' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('收藏操作失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '收藏操作失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取收藏列表
|
|
||||||
router.get('/collections', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId } = req.query;
|
|
||||||
const collections = await UserModel.getCollections(parseInt(userId));
|
|
||||||
res.json({ code: 0, data: collections });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取收藏列表失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '获取收藏列表失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取已解锁结局
|
|
||||||
router.get('/endings', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId, storyId } = req.query;
|
|
||||||
const endings = await UserModel.getUnlockedEndings(
|
|
||||||
parseInt(userId),
|
|
||||||
storyId ? parseInt(storyId) : null
|
|
||||||
);
|
|
||||||
res.json({ code: 0, data: endings });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取结局列表失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '获取结局列表失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取我的作品
|
|
||||||
router.get('/my-works', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId } = req.query;
|
|
||||||
const works = await UserModel.getMyWorks(parseInt(userId));
|
|
||||||
res.json({ code: 0, data: works });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取作品失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '获取作品失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取草稿箱
|
|
||||||
router.get('/drafts', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId } = req.query;
|
|
||||||
const drafts = await UserModel.getDrafts(parseInt(userId));
|
|
||||||
res.json({ code: 0, data: drafts });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取草稿失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '获取草稿失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取最近游玩
|
|
||||||
router.get('/recent-played', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId, limit } = req.query;
|
|
||||||
const recent = await UserModel.getRecentPlayed(parseInt(userId), parseInt(limit) || 10);
|
|
||||||
res.json({ code: 0, data: recent });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取最近游玩失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '获取最近游玩失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取AI创作历史
|
|
||||||
router.get('/ai-history', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId, limit } = req.query;
|
|
||||||
const history = await UserModel.getAIHistory(parseInt(userId), parseInt(limit) || 20);
|
|
||||||
res.json({ code: 0, data: history });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取AI历史失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '获取AI历史失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 获取AI配额
|
|
||||||
router.get('/ai-quota', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { userId } = req.query;
|
|
||||||
const quota = await UserModel.getAIQuota(parseInt(userId));
|
|
||||||
res.json({ code: 0, data: quota });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('获取AI配额失败:', err);
|
|
||||||
res.status(500).json({ code: 500, message: '获取AI配额失败' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
93
server/sql/init_db.py
Normal file
93
server/sql/init_db.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""
|
||||||
|
数据库初始化脚本
|
||||||
|
在IDEA中直接运行此文件即可建库
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import pymysql
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# 获取当前脚本所在目录
|
||||||
|
SQL_DIR = Path(__file__).parent
|
||||||
|
|
||||||
|
# 数据库配置(从环境变量或默认值)
|
||||||
|
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: str) -> str:
|
||||||
|
"""读取SQL文件"""
|
||||||
|
filepath = SQL_DIR / filename
|
||||||
|
with open(filepath, 'r', encoding='utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
def execute_sql(cursor, sql: str, description: str):
|
||||||
|
"""执行SQL语句(支持多语句)"""
|
||||||
|
print(f'{description}...')
|
||||||
|
# 按分号分割SQL语句
|
||||||
|
statements = [s.strip() for s in sql.split(';') if s.strip()]
|
||||||
|
for stmt in statements:
|
||||||
|
if stmt:
|
||||||
|
try:
|
||||||
|
cursor.execute(stmt)
|
||||||
|
except pymysql.Error as e:
|
||||||
|
# 忽略某些错误(如数据库已存在)
|
||||||
|
if e.args[0] not in [1007, 1050]: # 数据库已存在、表已存在
|
||||||
|
print(f' 警告: {e.args[1]}')
|
||||||
|
print(f' {description}完成!')
|
||||||
|
|
||||||
|
|
||||||
|
def init_database():
|
||||||
|
"""初始化数据库"""
|
||||||
|
print('=' * 50)
|
||||||
|
print('星域故事汇 - 数据库初始化')
|
||||||
|
print('=' * 50)
|
||||||
|
print(f'连接信息: {DB_CONFIG["user"]}@{DB_CONFIG["host"]}:{DB_CONFIG["port"]}')
|
||||||
|
print()
|
||||||
|
|
||||||
|
# 连接MySQL
|
||||||
|
connection = pymysql.connect(**DB_CONFIG)
|
||||||
|
cursor = connection.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. 执行schema.sql(创建数据库和表)
|
||||||
|
schema_sql = read_sql_file('schema.sql')
|
||||||
|
execute_sql(cursor, schema_sql, '创建数据库表结构')
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
# 2. 执行种子数据第1部分
|
||||||
|
seed1_sql = read_sql_file('seed_stories_part1.sql')
|
||||||
|
execute_sql(cursor, seed1_sql, '导入种子数据(第1部分)')
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
# 3. 执行种子数据第2部分
|
||||||
|
seed2_sql = read_sql_file('seed_stories_part2.sql')
|
||||||
|
execute_sql(cursor, seed2_sql, '导入种子数据(第2部分)')
|
||||||
|
connection.commit()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('=' * 50)
|
||||||
|
print('数据库初始化完成!')
|
||||||
|
print('共创建10个种子故事,包含66个剧情节点和多个结局分支。')
|
||||||
|
print('=' * 50)
|
||||||
|
print()
|
||||||
|
print('现在可以启动后端服务了:')
|
||||||
|
print(' cd "D:\\idea project\\ai_game\\server"')
|
||||||
|
print(' python -m app.main')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f'初始化失败: {e}')
|
||||||
|
connection.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
cursor.close()
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
init_database()
|
||||||
Reference in New Issue
Block a user