refactor: 后端从Node.js重写为Python FastAPI

This commit is contained in:
wangwuww111
2026-03-04 18:31:48 +08:00
parent 729bb3aaeb
commit ac0accdde6
40 changed files with 1096 additions and 2035 deletions

8
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@@ -25,18 +25,23 @@ ai_game/
│ ├── game.js
│ └── game.json
├── server/ # Node.js 后端服务
│ ├── routes/
│ │ ├── story.js # 故事接口
│ │ └── user.js # 用户接口
├── models/
│ │ ├── story.js # 故事模型
│ │ └── user.js # 用户模型
├── config/
├── server/ # Python 后端服务 (FastAPI)
│ ├── app/
│ │ ├── routers/
│ │ │ ├── story.py # 故事接口
│ │ └── user.py # 用户接口
│ │ ├── models/
│ │ │ ├── story.py # 故事ORM模型
│ │ └── user.py # 用户ORM模型
│ │ ├── config.py # 配置管理
│ │ ├── database.py # 数据库连接
│ │ └── main.py # 应用入口
│ ├── sql/
│ │ ├── schema.sql # 基础表结构
│ │ ── schema_v2.sql # 完整表结构含AI/UGC
│ └── app.js
│ │ ── init_db.py # Python建库脚本
│ └── seed_stories_*.sql # 种子数据
│ ├── requirements.txt
│ └── .env
├── docs/
│ └── AI创作系统设计.md
@@ -49,22 +54,30 @@ ai_game/
| 层级 | 技术 |
|------|------|
| 客户端 | 原生微信小游戏 Canvas 2D |
| 服务端 | Node.js + Express |
| 服务端 | Python + FastAPI + SQLAlchemy |
| 数据库 | MySQL 8.0 |
| AI服务 | OpenAI / Claude API |
## 快速开始
### 1. 启动后端
### 1. 初始化数据库
```bash
cd server
npm install
cp .env.example .env # 配置数据库
npm start
pip install -r requirements.txt
# 配置 .env 文件(数据库密码等)
python sql/init_db.py
```
### 2. 导入客户
### 2. 启动后
```bash
cd server
python -m app.main
# 服务运行在 http://localhost:3000
```
### 3. 导入客户端
1. 微信开发者工具 → 导入项目 → 选择 `client` 目录
2. 填入 AppID
@@ -137,8 +150,13 @@ npm start
- [x] AI创作中心UI
- [x] 首页UGC布局
- [x] 个人中心创作者功能
- [x] 后端API完善
- [ ] AI服务对接
- [x] 后端基础框架Python + FastAPI
- [x] 故事模型设计(标题、章节、选项、结局)
- [x] 故事列表/详情接口
- [x] 用户游玩记录接口
- [x] AI改写结局接口模拟
- [ ] AI服务对接OpenAI/Claude
- [ ] 配额系统
- [ ] 审核系统
- [ ] 支付系统
- [ ] 社交分享

View File

@@ -1,5 +1,5 @@
{
"appid": "wx772e2f0fbc498020",
"appid": "wx27be06bc3365e84b",
"compileType": "game",
"projectname": "stardom-story",
"setting": {
@@ -42,6 +42,5 @@
"ignore": [],
"include": []
},
"isGameTourist": false,
"editorSetting": {}
}

View File

@@ -380,7 +380,10 @@ class AIService {
## 九、后续迭代
### Phase 1当前
- [x] AI改写结局
- [x] AI改写结局(后端接口已完成,模拟实现)
- [x] 后端基础框架Python + FastAPI
- [x] 故事/用户数据模型
- [x] 游玩记录接口
- [ ] 配额系统接入
- [ ] 基础审核流程

View File

@@ -11,7 +11,17 @@
"disablePlugins": [],
"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",
"simulatorPluginLibVersion": {},

View File

@@ -1,4 +1,4 @@
# 服务器配置
cd "D:\idea project\ai_game\server"# 服务器配置
PORT=3000
# MySQL数据库配置

View File

@@ -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
View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

37
server/app/config.py Normal file
View 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
View 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
View 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
)

View File

@@ -0,0 +1,2 @@
from app.models.story import Story, StoryNode, StoryChoice
from app.models.user import User, UserProgress, UserEnding

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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
View 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'),
)

View File

@@ -0,0 +1 @@
from app.routers import story, user

Binary file not shown.

Binary file not shown.

221
server/app/routers/story.py Normal file
View 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
View 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
}
}

View File

@@ -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;

View File

@@ -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;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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
View 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

View File

@@ -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;

View File

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