From f32e51b594c0baa343f0917a8388c54aa50d8f99 Mon Sep 17 00:00:00 2001 From: xiamuceer Date: Fri, 26 Dec 2025 15:05:48 +0800 Subject: [PATCH] =?UTF-8?q?update:1.=E9=87=8D=E6=9E=84=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=88=9D=E5=A7=8B=E5=8C=96=E5=92=8C?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BD=BF=E7=94=A8?= =?UTF-8?q?Alembic=E6=95=B0=E6=8D=AE=E5=BA=93=E7=AE=A1=E7=90=86=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 17 +- README.md | 2 +- backend/.env.example | 19 +- backend/alembic-postgres.ini | 48 + backend/alembic-sqlite.ini | 48 + backend/alembic/README | 145 +++ backend/alembic/postgres/.gitkeep | 2 + backend/alembic/postgres/env.py | 101 ++ backend/alembic/postgres/script.py.mako | 26 + ...251226_1008_ee0a189f1532_初始数据库结构.py | 554 +++++++++++ ...251226_1102_e411428f00c0_初始化预置数据.py | 181 ++++ backend/alembic/sqlite/.gitkeep | 2 + backend/alembic/sqlite/env.py | 102 +++ backend/alembic/sqlite/script.py.mako | 26 + ...26_1322_fbeb1038c728_初始化sqlite数据库.py | 616 +++++++++++++ backend/app/api/admin.py | 9 +- backend/app/api/auth.py | 23 +- backend/app/database.py | 156 +--- backend/app/main.py | 18 +- backend/app/mcp/registry.py | 22 +- backend/app/models/__init__.py | 4 +- backend/app/services/ai_service.py | 9 +- backend/app/services/memory_service.py | 55 +- backend/requirements.txt | 1 + .../scripts/add_outline_chapter_relation.sql | 45 - backend/scripts/create_career_tables.sql | 200 ---- .../scripts/create_regeneration_tables.sql | 73 -- backend/scripts/entrypoint.sh | 77 ++ backend/scripts/fix_user_id_length.sql | 9 - backend/scripts/migrate.py | 184 ++++ backend/scripts/migrate_sqlite_to_postgres.py | 859 ------------------ backend/scripts/migrate_users_to_db.py | 224 ----- backend/scripts/migrate_users_to_postgres.py | 300 ------ .../migrate_writing_styles_to_user_level.sql | 36 - .../scripts/migration_add_outline_mode.sql | 25 - .../migration_add_prompt_templates.sql | 34 - backend/scripts/setup_postgres.py | 27 +- docker-compose.yml | 5 + frontend/package.json | 2 +- 39 files changed, 2249 insertions(+), 2037 deletions(-) create mode 100644 backend/alembic-postgres.ini create mode 100644 backend/alembic-sqlite.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/postgres/.gitkeep create mode 100644 backend/alembic/postgres/env.py create mode 100644 backend/alembic/postgres/script.py.mako create mode 100644 backend/alembic/postgres/versions/20251226_1008_ee0a189f1532_初始数据库结构.py create mode 100644 backend/alembic/postgres/versions/20251226_1102_e411428f00c0_初始化预置数据.py create mode 100644 backend/alembic/sqlite/.gitkeep create mode 100644 backend/alembic/sqlite/env.py create mode 100644 backend/alembic/sqlite/script.py.mako create mode 100644 backend/alembic/sqlite/versions/20251226_1322_fbeb1038c728_初始化sqlite数据库.py delete mode 100644 backend/scripts/add_outline_chapter_relation.sql delete mode 100644 backend/scripts/create_career_tables.sql delete mode 100644 backend/scripts/create_regeneration_tables.sql create mode 100644 backend/scripts/entrypoint.sh delete mode 100644 backend/scripts/fix_user_id_length.sql create mode 100644 backend/scripts/migrate.py delete mode 100644 backend/scripts/migrate_sqlite_to_postgres.py delete mode 100644 backend/scripts/migrate_users_to_db.py delete mode 100644 backend/scripts/migrate_users_to_postgres.py delete mode 100644 backend/scripts/migrate_writing_styles_to_user_level.sql delete mode 100644 backend/scripts/migration_add_outline_mode.sql delete mode 100644 backend/scripts/migration_add_prompt_templates.sql diff --git a/Dockerfile b/Dockerfile index ad0cdfa..617b429 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,9 +32,11 @@ WORKDIR /app RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources \ && sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources -# 安装系统依赖 +# 安装系统依赖(添加数据库工具) RUN apt-get update && apt-get install -y \ gcc \ + postgresql-client \ + netcat-traditional \ && rm -rf /var/lib/apt/lists/* # 复制后端依赖文件 @@ -52,6 +54,15 @@ COPY backend/ ./ # 从前端构建阶段复制构建好的静态文件 COPY --from=frontend-builder /frontend/dist ./static +# 复制 Alembic 迁移配置和脚本(PostgreSQL) +COPY backend/alembic-postgres.ini ./alembic.ini +COPY backend/alembic/postgres ./alembic +COPY backend/scripts/entrypoint.sh /app/entrypoint.sh +COPY backend/scripts/migrate.py ./scripts/migrate.py + +# 赋予执行权限 +RUN chmod +x /app/entrypoint.sh + # 创建必要的目录 RUN mkdir -p /app/data /app/logs @@ -73,5 +84,5 @@ ENV SENTENCE_TRANSFORMERS_HOME=/app/embedding HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 -# 启动命令 -CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +# 使用 entrypoint 脚本启动(自动执行迁移) +ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md index 2d62719..7a64c23 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@
-![Version](https://img.shields.io/badge/version-1.1.4-blue.svg) +![Version](https://img.shields.io/badge/version-1.2.0-blue.svg) ![Python](https://img.shields.io/badge/python-3.11-blue.svg) ![FastAPI](https://img.shields.io/badge/FastAPI-0.109.0-green.svg) ![React](https://img.shields.io/badge/react-18.3.1-blue.svg) diff --git a/backend/.env.example b/backend/.env.example index 74a83e6..9d6a4f7 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -27,22 +27,11 @@ POSTGRES_PORT=5432 # 数据库连接 URL(Docker 部署时自动生成) DATABASE_URL=postgresql+asyncpg://mumuai:123456@localhost:5432/mumuai_novel -# PostgreSQL 连接池配置(优化后,支持80-150并发用户) -DATABASE_POOL_SIZE=30 # 核心连接数(默认30,小团队可用20) -DATABASE_MAX_OVERFLOW=20 # 最大溢出连接数(默认20,小团队可用10) -DATABASE_POOL_TIMEOUT=60 # 连接等待超时秒数(默认60) -DATABASE_POOL_RECYCLE=1800 # 连接回收时间秒数(默认1800=30分钟) -DATABASE_POOL_PRE_PING=True # 连接前检测是否有效 -DATABASE_POOL_USE_LIFO=True # 使用LIFO策略提高连接复用率 +# ========================================== +# SQLite 数据库配置 +# ========================================== -# 会话监控配置 -DATABASE_SESSION_MAX_ACTIVE=50 # 活跃会话警告阈值 -DATABASE_SESSION_LEAK_THRESHOLD=100 # 会话泄漏严重告警阈值 - -# 数据库监控配置 -DATABASE_ENABLE_SLOW_QUERY_LOG=True # 启用慢查询日志 -DATABASE_SLOW_QUERY_THRESHOLD=1.0 # 慢查询阈值(秒) -DATABASE_ENABLE_METRICS=True # 启用性能指标收集 +# DATABASE_URL=sqlite+aiosqlite:///data/ai_story.db # ========================================== # 代理配置(可选) diff --git a/backend/alembic-postgres.ini b/backend/alembic-postgres.ini new file mode 100644 index 0000000..b63a174 --- /dev/null +++ b/backend/alembic-postgres.ini @@ -0,0 +1,48 @@ +# Alembic Database Migration Profile - PostgreSQL +# Database version management for the MuMuAINovel project + +[alembic] +# Migration Script storage directory (PostgreSQL) +script_location = alembic/postgres + +# Template File Path (for generating migration scripts) +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s + +# Database connection string +# Note: The actual connection string is read from the environment variable in env.py +# sqlalchemy.url = postgresql+asyncpg://mumuai:password@localhost:5432/mumuai_novel + +# Log Configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/backend/alembic-sqlite.ini b/backend/alembic-sqlite.ini new file mode 100644 index 0000000..4a657cc --- /dev/null +++ b/backend/alembic-sqlite.ini @@ -0,0 +1,48 @@ +# Alembic Database Migration Profile - SQLite +# Database version management for the MuMuAINovel project + +[alembic] +# Migration Script storage directory (SQLite) +script_location = alembic/sqlite + +# Template File Path (for generating migration scripts) +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s + +# Database connection string +# Note: The actual connection string is read from the environment variable in env.py +# sqlalchemy.url = sqlite+aiosqlite:///data/ai_story.db + +# Log Configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..46ff504 --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1,145 @@ +# Alembic 数据库迁移指南 + +本项目支持 **PostgreSQL** 和 **SQLite** 两种数据库,使用独立的 Alembic 配置管理迁移。 + +## 📁 目录结构 + +``` +backend/ +├── alembic-postgres.ini # PostgreSQL 配置文件 +├── alembic-sqlite.ini # SQLite 配置文件 +├── alembic/ +│ ├── postgres/ # PostgreSQL 迁移脚本目录 +│ │ ├── env.py +│ │ ├── script.py.mako +│ │ └── versions/ # PostgreSQL 迁移版本 +│ │ ├── 20251226_1008_ee0a189f1532_初始数据库结构.py +│ │ └── 20251226_1102_e411428f00c0_初始化预置数据.py +│ └── sqlite/ # SQLite 迁移脚本目录 +│ ├── env.py +│ ├── script.py.mako +│ └── versions/ # SQLite 迁移版本 +│ └── 20251226_1322_fbeb1038c728_初始化sqlite数据库.py +``` + +## 🚀 使用方法 + +### 1. PostgreSQL 数据库 + +#### 配置环境变量 +```bash +# .env 文件 +DATABASE_URL=postgresql+asyncpg://username:password@localhost:5432/database_name +``` + +#### 生成迁移脚本 +```bash +cd backend +alembic -c alembic-postgres.ini revision --autogenerate -m "描述信息" +``` + +#### 应用迁移 +```bash +alembic -c alembic-postgres.ini upgrade head +``` + +#### 回退迁移 +```bash +alembic -c alembic-postgres.ini downgrade -1 +``` + +#### 查看迁移历史 +```bash +alembic -c alembic-postgres.ini history +alembic -c alembic-postgres.ini current +``` + +### 2. SQLite 数据库 + +#### 配置环境变量 +```bash +# .env 文件 +DATABASE_URL=sqlite+aiosqlite:///./data/mumuai.db +``` + +#### 生成迁移脚本 +```bash +cd backend +alembic -c alembic-sqlite.ini revision --autogenerate -m "描述信息" +``` + +#### 应用迁移 +```bash +alembic -c alembic-sqlite.ini upgrade head +``` + +#### 回退迁移 +```bash +alembic -c alembic-sqlite.ini downgrade -1 +``` + +#### 查看迁移历史 +```bash +alembic -c alembic-sqlite.ini history +alembic -c alembic-sqlite.ini current +``` + +## ⚙️ 关键配置差异 + +### PostgreSQL (alembic/postgres/env.py) +- `render_as_batch=False` - 直接支持 ALTER TABLE +- 使用 `server_default=sa.text('now()')` + +### SQLite (alembic/sqlite/env.py) +- `render_as_batch=True` - 通过重建表实现 ALTER TABLE +- 使用 `server_default=sa.text('(CURRENT_TIMESTAMP)')` - SQLite 格式 + +## 📝 注意事项 + +### SQLite 限制 +1. **并发写入**:同时只允许一个写操作 +2. **ALTER TABLE 限制**:某些操作需要重建表(Alembic 的批处理模式会自动处理) +3. **类型映射**: + - `JSON` → `TEXT` (SQLAlchemy 自动处理) + - `BOOLEAN` → `INTEGER` (0/1) + - `DEFAULT now()` → `DEFAULT CURRENT_TIMESTAMP` + +### PostgreSQL 优势 +1. **高并发支持**:多用户同时读写 +2. **完整的 ALTER TABLE 支持** +3. **高级特性**:全文搜索、JSON 操作符、数组类型等 + +## 🔄 切换数据库 + +只需修改 `.env` 文件中的 `DATABASE_URL`,然后使用对应的配置文件执行迁移: + +```bash +# 切换到 SQLite +DATABASE_URL=sqlite+aiosqlite:///./data/mumuai.db +alembic -c alembic-sqlite.ini upgrade head + +# 切换到 PostgreSQL +DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/db +alembic -c alembic-postgres.ini upgrade head +``` + +## 💡 最佳实践 + +1. **开发环境**:使用 SQLite(简单、无需额外服务) +2. **生产环境**:使用 PostgreSQL(性能、并发、稳定性) +3. **保持同步**:两个数据库的模型定义必须一致 +4. **测试迁移**:在两种数据库上都测试迁移脚本 + +## 🐛 常见问题 + +### Q: 迁移脚本生成后可以通用吗? +A: 不行。PostgreSQL 和 SQLite 的迁移脚本是独立的,因为: +- SQL 语法差异(如 DEFAULT 值) +- 类型差异(如 JSON、BOOLEAN) +- ALTER TABLE 能力差异 + +### Q: 如何从 PostgreSQL 迁移数据到 SQLite? +A: 需要编写数据导出/导入脚本,不能直接复用迁移脚本。 + +### Q: 为什么 SQLite 迁移这么慢? +A: SQLite 的 ALTER TABLE 限制导致需要重建表,这在大表时会很慢。 \ No newline at end of file diff --git a/backend/alembic/postgres/.gitkeep b/backend/alembic/postgres/.gitkeep new file mode 100644 index 0000000..2294010 --- /dev/null +++ b/backend/alembic/postgres/.gitkeep @@ -0,0 +1,2 @@ +# 此文件确保 versions 目录被 Git 追踪 +# 迁移版本文件将存放在此目录 \ No newline at end of file diff --git a/backend/alembic/postgres/env.py b/backend/alembic/postgres/env.py new file mode 100644 index 0000000..aa55b64 --- /dev/null +++ b/backend/alembic/postgres/env.py @@ -0,0 +1,101 @@ +"""Alembic 环境配置文件 - PostgreSQL""" +import asyncio +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# 添加项目根目录到 Python 路径 +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +# 导入应用配置 +from app.config import settings + +# 导入 Base 和所有模型 +from app.database import Base +from app.models import ( + Project, Outline, Character, Chapter, GenerationHistory, + Settings, WritingStyle, ProjectDefaultStyle, + RelationshipType, CharacterRelationship, Organization, OrganizationMember, + StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask, + RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate +) + +# Alembic Config 对象 +config = context.config + +# 设置数据库连接字符串(从环境变量读取) +config.set_main_option("sqlalchemy.url", settings.database_url) + +# 配置日志 +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 设置 target_metadata 为应用的 Base.metadata +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """在'离线'模式下运行迁移""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + compare_server_default=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """执行迁移的核心函数 - PostgreSQL 专用""" + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + render_as_batch=False, # PostgreSQL 不需要批处理模式 + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """在'在线'模式下运行异步迁移""" + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = settings.database_url + + connectable = async_engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """在'在线'模式下运行迁移""" + asyncio.run(run_async_migrations()) + + +# 根据上下文选择运行模式 +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/backend/alembic/postgres/script.py.mako b/backend/alembic/postgres/script.py.mako new file mode 100644 index 0000000..3cf5352 --- /dev/null +++ b/backend/alembic/postgres/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/backend/alembic/postgres/versions/20251226_1008_ee0a189f1532_初始数据库结构.py b/backend/alembic/postgres/versions/20251226_1008_ee0a189f1532_初始数据库结构.py new file mode 100644 index 0000000..e336cb1 --- /dev/null +++ b/backend/alembic/postgres/versions/20251226_1008_ee0a189f1532_初始数据库结构.py @@ -0,0 +1,554 @@ +"""初始数据库结构 + +Revision ID: ee0a189f1532 +Revises: +Create Date: 2025-12-26 10:08:55.432217 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ee0a189f1532' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """升级数据库结构""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('batch_generation_tasks', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'), + sa.Column('user_id', sa.String(length=100), nullable=False, comment='用户ID'), + sa.Column('start_chapter_number', sa.Integer(), nullable=False, comment='起始章节序号'), + sa.Column('chapter_count', sa.Integer(), nullable=False, comment='生成章节数量'), + sa.Column('chapter_ids', sa.JSON(), nullable=False, comment='待生成的章节ID列表'), + sa.Column('style_id', sa.Integer(), nullable=True, comment='使用的写作风格ID'), + sa.Column('target_word_count', sa.Integer(), nullable=True, comment='目标字数'), + sa.Column('enable_analysis', sa.Boolean(), nullable=True, comment='是否启用同步分析'), + sa.Column('status', sa.String(length=20), nullable=True, comment='任务状态: pending/running/completed/failed/cancelled'), + sa.Column('total_chapters', sa.Integer(), nullable=True, comment='总章节数'), + sa.Column('completed_chapters', sa.Integer(), nullable=True, comment='已完成章节数'), + sa.Column('failed_chapters', sa.JSON(), nullable=True, comment='失败的章节信息列表'), + sa.Column('current_chapter_id', sa.String(length=36), nullable=True, comment='当前正在生成的章节ID'), + sa.Column('current_chapter_number', sa.Integer(), nullable=True, comment='当前正在生成的章节序号'), + sa.Column('current_retry_count', sa.Integer(), nullable=True, comment='当前章节重试次数'), + sa.Column('max_retries', sa.Integer(), nullable=True, comment='最大重试次数'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('started_at', sa.DateTime(), nullable=True, comment='开始时间'), + sa.Column('completed_at', sa.DateTime(), nullable=True, comment='完成时间'), + sa.Column('error_message', sa.String(length=500), nullable=True, comment='错误信息'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('mcp_plugins', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=50), nullable=False, comment='用户ID'), + sa.Column('plugin_name', sa.String(length=100), nullable=False, comment='插件名称(唯一标识)'), + sa.Column('display_name', sa.String(length=200), nullable=False, comment='显示名称'), + sa.Column('description', sa.Text(), nullable=True, comment='插件描述'), + sa.Column('plugin_type', sa.String(length=50), nullable=True, comment='插件类型:http/stdio'), + sa.Column('server_url', sa.String(length=500), nullable=True, comment='服务器URL(HTTP类型)'), + sa.Column('command', sa.String(length=500), nullable=True, comment='启动命令(stdio类型)'), + sa.Column('args', sa.JSON(), nullable=True, comment='命令参数(stdio类型)'), + sa.Column('env', sa.JSON(), nullable=True, comment='环境变量'), + sa.Column('headers', sa.JSON(), nullable=True, comment='HTTP请求头'), + sa.Column('config', sa.JSON(), nullable=True, comment='插件特定配置(JSON)'), + sa.Column('tools', sa.JSON(), nullable=True, comment='提供的工具列表'), + sa.Column('enabled', sa.Boolean(), nullable=True, comment='是否启用'), + sa.Column('status', sa.String(length=50), nullable=True, comment='状态:active/inactive/error'), + sa.Column('last_error', sa.Text(), nullable=True, comment='最后错误信息'), + sa.Column('last_test_at', sa.DateTime(), nullable=True, comment='最后测试时间'), + sa.Column('category', sa.String(length=100), nullable=True, comment='分类'), + sa.Column('sort_order', sa.Integer(), nullable=True, comment='排序顺序'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_user_enabled', 'mcp_plugins', ['user_id', 'enabled'], unique=False) + op.create_index('idx_user_plugin', 'mcp_plugins', ['user_id', 'plugin_name'], unique=True) + op.create_index(op.f('ix_mcp_plugins_user_id'), 'mcp_plugins', ['user_id'], unique=False) + op.create_table('projects', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=100), nullable=False, comment='用户ID'), + sa.Column('title', sa.String(length=200), nullable=False, comment='项目标题'), + sa.Column('description', sa.Text(), nullable=True, comment='项目简介'), + sa.Column('theme', sa.Text(), nullable=True, comment='主题'), + sa.Column('genre', sa.String(length=50), nullable=True, comment='小说类型'), + sa.Column('target_words', sa.Integer(), nullable=True, comment='目标字数'), + sa.Column('current_words', sa.Integer(), nullable=True, comment='当前字数'), + sa.Column('status', sa.String(length=20), nullable=True, comment='创作状态'), + sa.Column('wizard_status', sa.String(length=20), nullable=True, comment='向导完成状态: incomplete/completed'), + sa.Column('wizard_step', sa.Integer(), nullable=True, comment='向导当前步骤: 0-4'), + sa.Column('outline_mode', sa.String(length=20), nullable=False, comment='大纲章节模式: one-to-one(传统模式) 或 one-to-many(细化模式)'), + sa.Column('world_time_period', sa.Text(), nullable=True, comment='时间背景'), + sa.Column('world_location', sa.Text(), nullable=True, comment='地理位置'), + sa.Column('world_atmosphere', sa.Text(), nullable=True, comment='氛围基调'), + sa.Column('world_rules', sa.Text(), nullable=True, comment='世界规则'), + sa.Column('chapter_count', sa.Integer(), nullable=True, comment='章节数量'), + sa.Column('narrative_perspective', sa.String(length=50), nullable=True, comment='叙事视角:first_person/third_person/omniscient'), + sa.Column('character_count', sa.Integer(), nullable=True, comment='角色数量'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.CheckConstraint("outline_mode IN ('one-to-one', 'one-to-many')", name='check_outline_mode'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_projects_user_id'), 'projects', ['user_id'], unique=False) + op.create_table('prompt_templates', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=50), nullable=False, comment='用户ID'), + sa.Column('template_key', sa.String(length=100), nullable=False, comment='模板键名'), + sa.Column('template_name', sa.String(length=200), nullable=False, comment='模板显示名称'), + sa.Column('template_content', sa.Text(), nullable=False, comment='模板内容'), + sa.Column('description', sa.Text(), nullable=True, comment='模板描述'), + sa.Column('category', sa.String(length=50), nullable=True, comment='模板分类'), + sa.Column('parameters', sa.Text(), nullable=True, comment='模板参数定义(JSON)'), + sa.Column('is_active', sa.Boolean(), nullable=True, comment='是否启用'), + sa.Column('is_system_default', sa.Boolean(), nullable=True, comment='是否为系统默认模板'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_user_template', 'prompt_templates', ['user_id', 'template_key'], unique=True) + op.create_index(op.f('ix_prompt_templates_user_id'), 'prompt_templates', ['user_id'], unique=False) + op.create_table('relationship_types', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=50), nullable=False, comment='关系名称'), + sa.Column('category', sa.String(length=20), nullable=False, comment='分类:family/social/hostile/professional'), + sa.Column('reverse_name', sa.String(length=50), nullable=True, comment='反向关系名称'), + sa.Column('intimacy_range', sa.String(length=20), nullable=True, comment='亲密度范围:high/medium/low'), + sa.Column('icon', sa.String(length=50), nullable=True, comment='图标标识'), + sa.Column('description', sa.Text(), nullable=True, comment='关系描述'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_relationship_types_id'), 'relationship_types', ['id'], unique=False) + op.create_table('settings', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=50), nullable=False, comment='用户ID'), + sa.Column('api_provider', sa.String(length=50), nullable=True, comment='API提供商'), + sa.Column('api_key', sa.String(length=500), nullable=True, comment='API密钥'), + sa.Column('api_base_url', sa.String(length=500), nullable=True, comment='自定义API地址'), + sa.Column('llm_model', sa.String(length=100), nullable=True, comment='模型名称'), + sa.Column('temperature', sa.Float(), nullable=True, comment='温度参数'), + sa.Column('max_tokens', sa.Integer(), nullable=True, comment='最大token数'), + sa.Column('preferences', sa.Text(), nullable=True, comment='其他偏好设置(JSON)'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_user_id', 'settings', ['user_id'], unique=False) + op.create_index(op.f('ix_settings_user_id'), 'settings', ['user_id'], unique=True) + op.create_table('user_passwords', + sa.Column('user_id', sa.String(length=100), nullable=False, comment='用户ID'), + sa.Column('username', sa.String(length=100), nullable=False, comment='用户名'), + sa.Column('password_hash', sa.String(length=64), nullable=False, comment='密码哈希(SHA256)'), + sa.Column('has_custom_password', sa.Boolean(), nullable=True, comment='是否为自定义密码'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.PrimaryKeyConstraint('user_id') + ) + op.create_index(op.f('ix_user_passwords_user_id'), 'user_passwords', ['user_id'], unique=False) + op.create_table('users', + sa.Column('user_id', sa.String(length=100), nullable=False, comment='用户ID,格式:linuxdo_{id} 或 local_{id}'), + sa.Column('username', sa.String(length=100), nullable=False, comment='用户名'), + sa.Column('display_name', sa.String(length=200), nullable=False, comment='显示名称'), + sa.Column('avatar_url', sa.String(length=500), nullable=True, comment='头像URL'), + sa.Column('trust_level', sa.Integer(), nullable=True, comment='信任等级(仅用于显示)'), + sa.Column('is_admin', sa.Boolean(), nullable=True, comment='是否为管理员'), + sa.Column('linuxdo_id', sa.String(length=100), nullable=False, comment='LinuxDO用户ID或本地用户ID'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('last_login', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True, comment='最后登录时间'), + sa.PrimaryKeyConstraint('user_id') + ) + op.create_index(op.f('ix_users_linuxdo_id'), 'users', ['linuxdo_id'], unique=True) + op.create_index(op.f('ix_users_user_id'), 'users', ['user_id'], unique=False) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=False) + op.create_table('careers', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False, comment='职业名称'), + sa.Column('type', sa.String(length=20), nullable=False, comment='职业类型: main(主职业)/sub(副职业)'), + sa.Column('description', sa.Text(), nullable=True, comment='职业描述'), + sa.Column('category', sa.String(length=50), nullable=True, comment='职业分类(如:战斗系、生产系、辅助系)'), + sa.Column('stages', sa.Text(), nullable=False, comment="职业阶段列表(JSON): [{level:1, name:'', description:''}, ...]"), + sa.Column('max_stage', sa.Integer(), nullable=False, comment='最大阶段数'), + sa.Column('requirements', sa.Text(), nullable=True, comment='职业要求/限制'), + sa.Column('special_abilities', sa.Text(), nullable=True, comment='特殊能力描述'), + sa.Column('worldview_rules', sa.Text(), nullable=True, comment='世界观规则关联'), + sa.Column('attribute_bonuses', sa.Text(), nullable=True, comment="属性加成(JSON): {strength: '+10%', intelligence: '+5%'}"), + sa.Column('source', sa.String(length=20), nullable=True, comment='来源: ai/manual'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_project_id', 'careers', ['project_id'], unique=False) + op.create_index('idx_type', 'careers', ['type'], unique=False) + op.create_table('outlines', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False, comment='大纲标题'), + sa.Column('content', sa.Text(), nullable=True, comment='大纲内容'), + sa.Column('structure', sa.Text(), nullable=True, comment='结构化大纲数据(JSON)'), + sa.Column('order_index', sa.Integer(), nullable=True, comment='排序序号'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('writing_styles', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.String(length=255), nullable=True, comment='所属用户ID(NULL表示全局预设风格)'), + sa.Column('name', sa.String(length=100), nullable=False, comment='风格名称'), + sa.Column('style_type', sa.String(length=50), nullable=False, comment='风格类型:preset/custom'), + sa.Column('preset_id', sa.String(length=50), nullable=True, comment='预设风格ID:natural/classical/modern等'), + sa.Column('description', sa.Text(), nullable=True, comment='风格描述'), + sa.Column('prompt_content', sa.Text(), nullable=False, comment='风格提示词内容'), + sa.Column('order_index', sa.Integer(), nullable=True, comment='排序序号'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('chapters', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('chapter_number', sa.Integer(), nullable=False, comment='章节序号'), + sa.Column('title', sa.String(length=200), nullable=False, comment='章节标题'), + sa.Column('content', sa.Text(), nullable=True, comment='章节内容'), + sa.Column('summary', sa.Text(), nullable=True, comment='章节摘要'), + sa.Column('word_count', sa.Integer(), nullable=True, comment='字数统计'), + sa.Column('status', sa.String(length=20), nullable=True, comment='章节状态'), + sa.Column('outline_id', sa.String(length=36), nullable=True, comment='关联的大纲ID'), + sa.Column('sub_index', sa.Integer(), nullable=True, comment='大纲下的子章节序号'), + sa.Column('expansion_plan', sa.Text(), nullable=True, comment='展开规划详情(JSON): 包含key_events, character_focus, emotional_tone等'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['outline_id'], ['outlines.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('characters', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False, comment='角色/组织名称'), + sa.Column('age', sa.String(length=50), nullable=True, comment='年龄'), + sa.Column('gender', sa.String(length=50), nullable=True, comment='性别'), + sa.Column('is_organization', sa.Boolean(), nullable=True, comment='是否为组织'), + sa.Column('role_type', sa.String(length=50), nullable=True, comment='角色类型'), + sa.Column('personality', sa.Text(), nullable=True, comment='性格特点/组织特性'), + sa.Column('background', sa.Text(), nullable=True, comment='背景故事'), + sa.Column('appearance', sa.Text(), nullable=True, comment='外貌描述'), + sa.Column('relationships', sa.Text(), nullable=True, comment='人物关系(JSON)'), + sa.Column('organization_type', sa.String(length=100), nullable=True, comment='组织类型'), + sa.Column('organization_purpose', sa.String(length=500), nullable=True, comment='组织目的'), + sa.Column('organization_members', sa.Text(), nullable=True, comment='组织成员(JSON)'), + sa.Column('main_career_id', sa.String(length=36), nullable=True, comment='主职业ID'), + sa.Column('main_career_stage', sa.Integer(), nullable=True, comment='主职业当前阶段'), + sa.Column('sub_careers', sa.Text(), nullable=True, comment='副职业列表(JSON): [{"career_id": "xxx", "stage": 3}, ...]'), + sa.Column('avatar_url', sa.String(length=500), nullable=True, comment='头像URL'), + sa.Column('traits', sa.Text(), nullable=True, comment='特征标签(JSON)'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['main_career_id'], ['careers.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('project_default_styles', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'), + sa.Column('style_id', sa.Integer(), nullable=False, comment='风格ID'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['style_id'], ['writing_styles.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('project_id', name='uix_project_default_style') + ) + op.create_table('analysis_tasks', + sa.Column('id', sa.String(length=36), nullable=False, comment='任务ID'), + sa.Column('chapter_id', sa.String(length=36), nullable=False, comment='章节ID'), + sa.Column('user_id', sa.String(length=50), nullable=False, comment='用户ID'), + sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'), + sa.Column('status', sa.String(length=20), nullable=False, comment='任务状态: pending/running/completed/failed'), + sa.Column('progress', sa.Integer(), nullable=True, comment='进度 0-100'), + sa.Column('error_message', sa.Text(), nullable=True, comment='错误信息'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('started_at', sa.DateTime(), nullable=True, comment='开始执行时间'), + sa.Column('completed_at', sa.DateTime(), nullable=True, comment='完成时间'), + sa.ForeignKeyConstraint(['chapter_id'], ['chapters.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_chapter_id_created', 'analysis_tasks', ['chapter_id', 'created_at'], unique=False) + op.create_index('idx_status', 'analysis_tasks', ['status'], unique=False) + op.create_table('character_careers', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('character_id', sa.String(length=36), nullable=False), + sa.Column('career_id', sa.String(length=36), nullable=False), + sa.Column('career_type', sa.String(length=20), nullable=False, comment='main(主职业)/sub(副职业)'), + sa.Column('current_stage', sa.Integer(), nullable=False, comment='当前阶段(对应职业中的数值)'), + sa.Column('stage_progress', sa.Integer(), nullable=True, comment='阶段内进度(0-100)'), + sa.Column('started_at', sa.String(length=100), nullable=True, comment='开始修炼时间(小说时间线)'), + sa.Column('reached_current_stage_at', sa.String(length=100), nullable=True, comment='到达当前阶段时间'), + sa.Column('notes', sa.Text(), nullable=True, comment='备注(如:修炼心得、特殊事件)'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['career_id'], ['careers.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_career_type', 'character_careers', ['career_type'], unique=False) + op.create_index('idx_character_career', 'character_careers', ['character_id', 'career_id'], unique=True) + op.create_index('idx_character_id', 'character_careers', ['character_id'], unique=False) + op.create_table('character_relationships', + sa.Column('id', sa.String(length=36), nullable=False, comment='关系ID'), + sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'), + sa.Column('character_from_id', sa.String(length=36), nullable=False, comment='角色A的ID'), + sa.Column('character_to_id', sa.String(length=36), nullable=False, comment='角色B的ID'), + sa.Column('relationship_type_id', sa.Integer(), nullable=True, comment='关系类型ID'), + sa.Column('relationship_name', sa.String(length=100), nullable=True, comment='自定义关系名称'), + sa.Column('intimacy_level', sa.Integer(), nullable=True, comment='亲密度:-100到100'), + sa.Column('status', sa.String(length=20), nullable=True, comment='状态:active/broken/past/complicated'), + sa.Column('description', sa.Text(), nullable=True, comment='关系详细描述'), + sa.Column('started_at', sa.String(length=100), nullable=True, comment='关系开始时间(故事时间)'), + sa.Column('ended_at', sa.String(length=100), nullable=True, comment='关系结束时间(故事时间)'), + sa.Column('source', sa.String(length=20), nullable=True, comment='来源:ai/manual/imported'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['character_from_id'], ['characters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['character_to_id'], ['characters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['relationship_type_id'], ['relationship_types.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_character_relationships_character_from_id'), 'character_relationships', ['character_from_id'], unique=False) + op.create_index(op.f('ix_character_relationships_character_to_id'), 'character_relationships', ['character_to_id'], unique=False) + op.create_index(op.f('ix_character_relationships_project_id'), 'character_relationships', ['project_id'], unique=False) + op.create_index(op.f('ix_character_relationships_relationship_type_id'), 'character_relationships', ['relationship_type_id'], unique=False) + op.create_table('generation_history', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('chapter_id', sa.String(length=36), nullable=True), + sa.Column('prompt', sa.Text(), nullable=True, comment='使用的提示词'), + sa.Column('generated_content', sa.Text(), nullable=True, comment='生成的内容'), + sa.Column('model', sa.String(length=50), nullable=True, comment='使用的模型'), + sa.Column('tokens_used', sa.Integer(), nullable=True, comment='消耗的token数'), + sa.Column('generation_time', sa.Float(), nullable=True, comment='生成耗时(秒)'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.ForeignKeyConstraint(['chapter_id'], ['chapters.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('organizations', + sa.Column('id', sa.String(length=36), nullable=False, comment='组织ID'), + sa.Column('character_id', sa.String(length=36), nullable=False, comment='关联的角色ID'), + sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'), + sa.Column('parent_org_id', sa.String(length=36), nullable=True, comment='父组织ID'), + sa.Column('level', sa.Integer(), nullable=True, comment='组织层级'), + sa.Column('power_level', sa.Integer(), nullable=True, comment='势力等级:0-100'), + sa.Column('member_count', sa.Integer(), nullable=True, comment='成员数量'), + sa.Column('location', sa.Text(), nullable=True, comment='所在地'), + sa.Column('motto', sa.String(length=200), nullable=True, comment='宗旨/口号'), + sa.Column('color', sa.String(length=100), nullable=True, comment='代表颜色'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['parent_org_id'], ['organizations.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('character_id') + ) + op.create_index(op.f('ix_organizations_project_id'), 'organizations', ['project_id'], unique=False) + op.create_table('plot_analysis', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('chapter_id', sa.String(length=36), nullable=False), + sa.Column('plot_stage', sa.String(length=50), nullable=True, comment='剧情阶段: 开端/发展/高潮/结局/过渡'), + sa.Column('conflict_level', sa.Integer(), nullable=True, comment='冲突强度 1-10'), + sa.Column('conflict_types', sa.JSON(), nullable=True, comment="冲突类型列表: ['人与人', '人与己', '人与环境']"), + sa.Column('emotional_tone', sa.String(length=100), nullable=True, comment='主导情感: 紧张/温馨/悲伤/激昂/平静'), + sa.Column('emotional_intensity', sa.Float(), nullable=True, comment='情感强度 0.0-1.0'), + sa.Column('emotional_curve', sa.JSON(), nullable=True, comment='情感曲线: {start: 0.3, middle: 0.7, end: 0.5}'), + sa.Column('hooks', sa.JSON(), nullable=True, comment='钩子列表 - 吸引读者的元素: [\n {\n "type": "悬念|情感|冲突|认知",\n "content": "具体内容",\n "strength": 8,\n "position": "开头|中段|结尾"\n }\n ]'), + sa.Column('hooks_count', sa.Integer(), nullable=True, comment='钩子数量'), + sa.Column('hooks_avg_strength', sa.Float(), nullable=True, comment='钩子平均强度'), + sa.Column('foreshadows', sa.JSON(), nullable=True, comment='伏笔列表: [\n {\n "content": "伏笔内容",\n "type": "planted|resolved",\n "strength": 7,\n "subtlety": 8,\n "reference_chapter": 3\n }\n ]'), + sa.Column('foreshadows_planted', sa.Integer(), nullable=True, comment='本章埋下的伏笔数量'), + sa.Column('foreshadows_resolved', sa.Integer(), nullable=True, comment='本章回收的伏笔数量'), + sa.Column('plot_points', sa.JSON(), nullable=True, comment='情节点列表: [\n {\n "content": "情节点描述",\n "importance": 0.9,\n "type": "revelation|conflict|resolution|transition",\n "impact": "对故事的影响描述"\n }\n ]'), + sa.Column('plot_points_count', sa.Integer(), nullable=True, comment='情节点数量'), + sa.Column('character_states', sa.JSON(), nullable=True, comment='角色状态变化: [\n {\n "character_id": "xxx",\n "character_name": "张三",\n "state_before": "犹豫不决",\n "state_after": "坚定信念",\n "psychological_change": "内心描述",\n "key_event": "触发事件",\n "relationship_changes": {"李四": "关系变化"}\n }\n ]'), + sa.Column('scenes', sa.JSON(), nullable=True, comment="场景列表: [{location: '地点', atmosphere: '氛围', duration: '时长'}]"), + sa.Column('pacing', sa.String(length=50), nullable=True, comment='节奏: slow|moderate|fast|varied'), + sa.Column('overall_quality_score', sa.Float(), nullable=True, comment='整体质量评分 0.0-10.0'), + sa.Column('pacing_score', sa.Float(), nullable=True, comment='节奏评分 0.0-10.0'), + sa.Column('engagement_score', sa.Float(), nullable=True, comment='吸引力评分 0.0-10.0'), + sa.Column('coherence_score', sa.Float(), nullable=True, comment='连贯性评分 0.0-10.0'), + sa.Column('analysis_report', sa.Text(), nullable=True, comment='完整的文字分析报告'), + sa.Column('suggestions', sa.JSON(), nullable=True, comment="改进建议列表: ['建议1', '建议2']"), + sa.Column('word_count', sa.Integer(), nullable=True, comment='章节字数'), + sa.Column('dialogue_ratio', sa.Float(), nullable=True, comment='对话占比 0.0-1.0'), + sa.Column('description_ratio', sa.Float(), nullable=True, comment='描写占比 0.0-1.0'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='分析时间'), + sa.ForeignKeyConstraint(['chapter_id'], ['chapters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_plot_analysis_chapter_id'), 'plot_analysis', ['chapter_id'], unique=True) + op.create_index(op.f('ix_plot_analysis_project_id'), 'plot_analysis', ['project_id'], unique=False) + op.create_table('regeneration_tasks', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('chapter_id', sa.String(length=36), nullable=False), + sa.Column('analysis_id', sa.String(length=36), nullable=True, comment='关联的分析结果ID'), + sa.Column('user_id', sa.String(length=50), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('modification_instructions', sa.Text(), nullable=False, comment='综合修改指令'), + sa.Column('original_suggestions', sa.JSON(), nullable=True, comment='来自分析的原始建议列表'), + sa.Column('selected_suggestion_indices', sa.JSON(), nullable=True, comment='用户选择的建议索引'), + sa.Column('custom_instructions', sa.Text(), nullable=True, comment='用户自定义修改意见'), + sa.Column('style_id', sa.Integer(), nullable=True, comment='写作风格ID'), + sa.Column('target_word_count', sa.Integer(), nullable=True, comment='目标字数'), + sa.Column('focus_areas', sa.JSON(), nullable=True, comment='重点优化方向'), + sa.Column('preserve_elements', sa.JSON(), nullable=True, comment='需要保留的元素配置'), + sa.Column('status', sa.String(length=20), nullable=True, comment='pending/running/completed/failed'), + sa.Column('progress', sa.Integer(), nullable=True, comment='进度 0-100'), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('original_content', sa.Text(), nullable=True, comment='原始章节内容快照'), + sa.Column('original_word_count', sa.Integer(), nullable=True, comment='原始字数'), + sa.Column('regenerated_content', sa.Text(), nullable=True, comment='重新生成的内容'), + sa.Column('regenerated_word_count', sa.Integer(), nullable=True, comment='新内容字数'), + sa.Column('version_number', sa.Integer(), nullable=True, comment='版本号'), + sa.Column('version_note', sa.String(length=500), nullable=True, comment='版本说明'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True), + sa.Column('started_at', sa.DateTime(), nullable=True), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['chapter_id'], ['chapters.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_regeneration_tasks_chapter_id'), 'regeneration_tasks', ['chapter_id'], unique=False) + op.create_index(op.f('ix_regeneration_tasks_project_id'), 'regeneration_tasks', ['project_id'], unique=False) + op.create_index(op.f('ix_regeneration_tasks_user_id'), 'regeneration_tasks', ['user_id'], unique=False) + op.create_table('story_memories', + sa.Column('id', sa.String(length=100), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('chapter_id', sa.String(length=36), nullable=True), + sa.Column('memory_type', sa.String(length=50), nullable=False, comment='\n 记忆类型:\n - plot_point: 情节点\n - character_event: 角色事件\n - world_detail: 世界观细节\n - hook: 钩子(悬念/冲突)\n - foreshadow: 伏笔\n - dialogue: 重要对话\n - scene: 场景描写\n '), + sa.Column('title', sa.String(length=200), nullable=True, comment='记忆标题/简述'), + sa.Column('content', sa.Text(), nullable=False, comment='记忆内容摘要(100-500字)'), + sa.Column('full_context', sa.Text(), nullable=True, comment='完整上下文(可选,用于详细记录)'), + sa.Column('related_characters', sa.JSON(), nullable=True, comment="涉及角色ID列表: ['char_id_1', 'char_id_2']"), + sa.Column('related_locations', sa.JSON(), nullable=True, comment="涉及地点列表: ['地点1', '地点2']"), + sa.Column('tags', sa.JSON(), nullable=True, comment="标签列表: ['悬念', '转折', '伏笔', '高潮']"), + sa.Column('importance_score', sa.Float(), nullable=True, comment='重要性评分 0.0-1.0'), + sa.Column('story_timeline', sa.Integer(), nullable=False, comment='故事时间线位置(章节序号)'), + sa.Column('chapter_position', sa.Integer(), nullable=True, comment='章节内位置(字符位置)'), + sa.Column('text_length', sa.Integer(), nullable=True, comment='文本长度(字符数)'), + sa.Column('is_foreshadow', sa.Integer(), nullable=True, comment='伏笔状态: 0=普通记忆, 1=已埋下伏笔, 2=伏笔已回收'), + sa.Column('foreshadow_resolved_at', sa.String(length=100), nullable=True, comment='伏笔回收的章节ID'), + sa.Column('foreshadow_strength', sa.Float(), nullable=True, comment='伏笔强度 0.0-1.0'), + sa.Column('vector_id', sa.String(length=100), nullable=True, comment='向量数据库中的唯一ID'), + sa.Column('embedding_model', sa.String(length=100), nullable=True, comment='使用的embedding模型'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['chapter_id'], ['chapters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['foreshadow_resolved_at'], ['chapters.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('vector_id') + ) + op.create_index(op.f('ix_story_memories_chapter_id'), 'story_memories', ['chapter_id'], unique=False) + op.create_index(op.f('ix_story_memories_memory_type'), 'story_memories', ['memory_type'], unique=False) + op.create_index(op.f('ix_story_memories_project_id'), 'story_memories', ['project_id'], unique=False) + op.create_index(op.f('ix_story_memories_story_timeline'), 'story_memories', ['story_timeline'], unique=False) + op.create_table('organization_members', + sa.Column('id', sa.String(length=36), nullable=False, comment='成员关系ID'), + sa.Column('organization_id', sa.String(length=36), nullable=False, comment='组织ID'), + sa.Column('character_id', sa.String(length=36), nullable=False, comment='角色ID'), + sa.Column('position', sa.String(length=100), nullable=False, comment='职位名称'), + sa.Column('rank', sa.Integer(), nullable=True, comment='职位等级'), + sa.Column('status', sa.String(length=20), nullable=True, comment='状态:active/retired/expelled/deceased'), + sa.Column('joined_at', sa.String(length=100), nullable=True, comment='加入时间(故事时间)'), + sa.Column('left_at', sa.String(length=100), nullable=True, comment='离开时间(故事时间)'), + sa.Column('loyalty', sa.Integer(), nullable=True, comment='忠诚度:0-100'), + sa.Column('contribution', sa.Integer(), nullable=True, comment='贡献度:0-100'), + sa.Column('source', sa.String(length=20), nullable=True, comment='来源:ai/manual'), + sa.Column('notes', sa.Text(), nullable=True, comment='备注'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_organization_members_character_id'), 'organization_members', ['character_id'], unique=False) + op.create_index(op.f('ix_organization_members_organization_id'), 'organization_members', ['organization_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """降级数据库结构""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_organization_members_organization_id'), table_name='organization_members') + op.drop_index(op.f('ix_organization_members_character_id'), table_name='organization_members') + op.drop_table('organization_members') + op.drop_index(op.f('ix_story_memories_story_timeline'), table_name='story_memories') + op.drop_index(op.f('ix_story_memories_project_id'), table_name='story_memories') + op.drop_index(op.f('ix_story_memories_memory_type'), table_name='story_memories') + op.drop_index(op.f('ix_story_memories_chapter_id'), table_name='story_memories') + op.drop_table('story_memories') + op.drop_index(op.f('ix_regeneration_tasks_user_id'), table_name='regeneration_tasks') + op.drop_index(op.f('ix_regeneration_tasks_project_id'), table_name='regeneration_tasks') + op.drop_index(op.f('ix_regeneration_tasks_chapter_id'), table_name='regeneration_tasks') + op.drop_table('regeneration_tasks') + op.drop_index(op.f('ix_plot_analysis_project_id'), table_name='plot_analysis') + op.drop_index(op.f('ix_plot_analysis_chapter_id'), table_name='plot_analysis') + op.drop_table('plot_analysis') + op.drop_index(op.f('ix_organizations_project_id'), table_name='organizations') + op.drop_table('organizations') + op.drop_table('generation_history') + op.drop_index(op.f('ix_character_relationships_relationship_type_id'), table_name='character_relationships') + op.drop_index(op.f('ix_character_relationships_project_id'), table_name='character_relationships') + op.drop_index(op.f('ix_character_relationships_character_to_id'), table_name='character_relationships') + op.drop_index(op.f('ix_character_relationships_character_from_id'), table_name='character_relationships') + op.drop_table('character_relationships') + op.drop_index('idx_character_id', table_name='character_careers') + op.drop_index('idx_character_career', table_name='character_careers') + op.drop_index('idx_career_type', table_name='character_careers') + op.drop_table('character_careers') + op.drop_index('idx_status', table_name='analysis_tasks') + op.drop_index('idx_chapter_id_created', table_name='analysis_tasks') + op.drop_table('analysis_tasks') + op.drop_table('project_default_styles') + op.drop_table('characters') + op.drop_table('chapters') + op.drop_table('writing_styles') + op.drop_table('outlines') + op.drop_index('idx_type', table_name='careers') + op.drop_index('idx_project_id', table_name='careers') + op.drop_table('careers') + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_index(op.f('ix_users_user_id'), table_name='users') + op.drop_index(op.f('ix_users_linuxdo_id'), table_name='users') + op.drop_table('users') + op.drop_index(op.f('ix_user_passwords_user_id'), table_name='user_passwords') + op.drop_table('user_passwords') + op.drop_index(op.f('ix_settings_user_id'), table_name='settings') + op.drop_index('idx_user_id', table_name='settings') + op.drop_table('settings') + op.drop_index(op.f('ix_relationship_types_id'), table_name='relationship_types') + op.drop_table('relationship_types') + op.drop_index(op.f('ix_prompt_templates_user_id'), table_name='prompt_templates') + op.drop_index('idx_user_template', table_name='prompt_templates') + op.drop_table('prompt_templates') + op.drop_index(op.f('ix_projects_user_id'), table_name='projects') + op.drop_table('projects') + op.drop_index(op.f('ix_mcp_plugins_user_id'), table_name='mcp_plugins') + op.drop_index('idx_user_plugin', table_name='mcp_plugins') + op.drop_index('idx_user_enabled', table_name='mcp_plugins') + op.drop_table('mcp_plugins') + op.drop_table('batch_generation_tasks') + # ### end Alembic commands ### diff --git a/backend/alembic/postgres/versions/20251226_1102_e411428f00c0_初始化预置数据.py b/backend/alembic/postgres/versions/20251226_1102_e411428f00c0_初始化预置数据.py new file mode 100644 index 0000000..d4d1c20 --- /dev/null +++ b/backend/alembic/postgres/versions/20251226_1102_e411428f00c0_初始化预置数据.py @@ -0,0 +1,181 @@ +"""初始化预置数据 + +Revision ID: e411428f00c0 +Revises: ee0a189f1532 +Create Date: 2025-12-26 11:02:24.080526 + +""" +from typing import Sequence, Union +from datetime import datetime + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import table, column, String, Integer, Float, Text, Boolean, DateTime + + +# revision identifiers, used by Alembic. +revision: str = 'e411428f00c0' +down_revision: Union[str, None] = 'ee0a189f1532' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """插入预置数据""" + + # ==================== 1. 插入关系类型数据 ==================== + relationship_types_table = table( + 'relationship_types', + column('name', String), + column('category', String), + column('reverse_name', String), + column('intimacy_range', String), + column('icon', String), + column('description', Text), + ) + + relationship_types_data = [ + # 家庭关系 + {"name": "父亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👨", "description": "父子/父女关系"}, + {"name": "母亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👩", "description": "母子/母女关系"}, + {"name": "兄弟", "category": "family", "reverse_name": "兄弟", "intimacy_range": "high", "icon": "👬", "description": "兄弟关系"}, + {"name": "姐妹", "category": "family", "reverse_name": "姐妹", "intimacy_range": "high", "icon": "👭", "description": "姐妹关系"}, + {"name": "子女", "category": "family", "reverse_name": "父母", "intimacy_range": "high", "icon": "👶", "description": "子女关系"}, + {"name": "配偶", "category": "family", "reverse_name": "配偶", "intimacy_range": "high", "icon": "💑", "description": "夫妻关系"}, + {"name": "恋人", "category": "family", "reverse_name": "恋人", "intimacy_range": "high", "icon": "💕", "description": "恋爱关系"}, + + # 社交关系 + {"name": "师父", "category": "social", "reverse_name": "徒弟", "intimacy_range": "high", "icon": "🎓", "description": "师徒关系(师父视角)"}, + {"name": "徒弟", "category": "social", "reverse_name": "师父", "intimacy_range": "high", "icon": "📚", "description": "师徒关系(徒弟视角)"}, + {"name": "朋友", "category": "social", "reverse_name": "朋友", "intimacy_range": "medium", "icon": "🤝", "description": "朋友关系"}, + {"name": "同学", "category": "social", "reverse_name": "同学", "intimacy_range": "medium", "icon": "🎒", "description": "同学关系"}, + {"name": "邻居", "category": "social", "reverse_name": "邻居", "intimacy_range": "low", "icon": "🏘️", "description": "邻居关系"}, + {"name": "知己", "category": "social", "reverse_name": "知己", "intimacy_range": "high", "icon": "💙", "description": "知心好友"}, + + # 职业关系 + {"name": "上司", "category": "professional", "reverse_name": "下属", "intimacy_range": "low", "icon": "👔", "description": "上下级关系(上司视角)"}, + {"name": "下属", "category": "professional", "reverse_name": "上司", "intimacy_range": "low", "icon": "💼", "description": "上下级关系(下属视角)"}, + {"name": "同事", "category": "professional", "reverse_name": "同事", "intimacy_range": "medium", "icon": "🤵", "description": "同事关系"}, + {"name": "合作伙伴", "category": "professional", "reverse_name": "合作伙伴", "intimacy_range": "medium", "icon": "🤜🤛", "description": "合作关系"}, + + # 敌对关系 + {"name": "敌人", "category": "hostile", "reverse_name": "敌人", "intimacy_range": "low", "icon": "⚔️", "description": "敌对关系"}, + {"name": "仇人", "category": "hostile", "reverse_name": "仇人", "intimacy_range": "low", "icon": "💢", "description": "仇恨关系"}, + {"name": "竞争对手", "category": "hostile", "reverse_name": "竞争对手", "intimacy_range": "low", "icon": "🎯", "description": "竞争关系"}, + {"name": "宿敌", "category": "hostile", "reverse_name": "宿敌", "intimacy_range": "low", "icon": "⚡", "description": "宿命之敌"}, + ] + + op.bulk_insert(relationship_types_table, relationship_types_data) + print(f"✅ 已插入 {len(relationship_types_data)} 条关系类型数据") + + + # ==================== 2. 插入全局写作风格预设 ==================== + # 注意:这里需要从 WritingStyleManager 获取预设配置 + # 为了避免导入应用代码,我们直接硬编码预设风格 + + writing_styles_table = table( + 'writing_styles', + column('user_id', String), + column('name', String), + column('style_type', String), + column('preset_id', String), + column('description', Text), + column('prompt_content', Text), + column('order_index', Integer), + ) + + writing_styles_data = [ + { + "user_id": None, # NULL 表示全局预设 + "name": "自然流畅", + "style_type": "preset", + "preset_id": "natural", + "description": "自然流畅的叙事风格,适合现代都市、现实题材", + "prompt_content": """写作风格要求: +1. 语言简洁明快,贴近现代口语 +2. 多用短句,节奏流畅 +3. 注重情感细节的自然流露 +4. 避免过度修饰和复杂句式""", + "order_index": 1 + }, + { + "user_id": None, + "name": "古典优雅", + "style_type": "preset", + "preset_id": "classical", + "description": "古典文雅的写作风格,适合古装、仙侠题材", + "prompt_content": """写作风格要求: +1. 使用文言、半文言或典雅的白话 +2. 适当运用古典诗词意象 +3. 注重意境营造和韵味 +4. 对话和描写保持古典美感""", + "order_index": 2 + }, + { + "user_id": None, + "name": "现代简约", + "style_type": "preset", + "preset_id": "modern", + "description": "现代简约风格,适合轻小说、网文快节奏叙事", + "prompt_content": """写作风格要求: +1. 语言直白简练,信息密度高 +2. 多用对话推进情节 +3. 避免冗长描写,突出关键动作 +4. 节奏明快,适合快速阅读""", + "order_index": 3 + }, + { + "user_id": None, + "name": "文艺细腻", + "style_type": "preset", + "preset_id": "literary", + "description": "文艺细腻风格,注重心理描写和氛围营造", + "prompt_content": """写作风格要求: +1. 注重心理活动和情感细节 +2. 善用环境描写烘托氛围 +3. 语言优美,富有文学性 +4. 适当使用比喻、象征等修辞手法""", + "order_index": 4 + }, + { + "user_id": None, + "name": "紧张悬疑", + "style_type": "preset", + "preset_id": "suspense", + "description": "紧张悬疑风格,适合推理、惊悚题材", + "prompt_content": """写作风格要求: +1. 营造紧张压迫的氛围 +2. 多用短句加快节奏 +3. 善于设置悬念和伏笔 +4. 注重细节描写,为推理埋下线索""", + "order_index": 5 + }, + { + "user_id": None, + "name": "幽默诙谐", + "style_type": "preset", + "preset_id": "humorous", + "description": "幽默诙谐风格,适合轻松搞笑题材", + "prompt_content": """写作风格要求: +1. 语言活泼风趣,善用俏皮话 +2. 注重对话的喜剧效果 +3. 适当夸张和反转制造笑点 +4. 保持轻松愉快的基调""", + "order_index": 6 + }, + ] + + op.bulk_insert(writing_styles_table, writing_styles_data) + print(f"✅ 已插入 {len(writing_styles_data)} 条全局写作风格预设") + + +def downgrade() -> None: + """删除预置数据""" + + # 删除写作风格预设(只删除全局预设) + op.execute("DELETE FROM writing_styles WHERE user_id IS NULL") + print("✅ 已删除全局写作风格预设") + + # 删除关系类型 + op.execute("DELETE FROM relationship_types") + print("✅ 已删除关系类型数据") diff --git a/backend/alembic/sqlite/.gitkeep b/backend/alembic/sqlite/.gitkeep new file mode 100644 index 0000000..2294010 --- /dev/null +++ b/backend/alembic/sqlite/.gitkeep @@ -0,0 +1,2 @@ +# 此文件确保 versions 目录被 Git 追踪 +# 迁移版本文件将存放在此目录 \ No newline at end of file diff --git a/backend/alembic/sqlite/env.py b/backend/alembic/sqlite/env.py new file mode 100644 index 0000000..5a32b7d --- /dev/null +++ b/backend/alembic/sqlite/env.py @@ -0,0 +1,102 @@ +"""Alembic 环境配置文件 - SQLite""" +import asyncio +import os +import sys +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# 添加项目根目录到 Python 路径 +project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +# 导入应用配置 +from app.config import settings + +# 导入 Base 和所有模型 +from app.database import Base +from app.models import ( + Project, Outline, Character, Chapter, GenerationHistory, + Settings, WritingStyle, ProjectDefaultStyle, + RelationshipType, CharacterRelationship, Organization, OrganizationMember, + StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask, + RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate +) + +# Alembic Config 对象 +config = context.config + +# 设置数据库连接字符串(从环境变量读取) +config.set_main_option("sqlalchemy.url", settings.database_url) + +# 配置日志 +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# 设置 target_metadata 为应用的 Base.metadata +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """在'离线'模式下运行迁移""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + compare_server_default=True, + render_as_batch=True, # SQLite 必须启用批处理模式 + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """执行迁移的核心函数 - SQLite 专用""" + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + compare_server_default=True, + render_as_batch=True, # SQLite 必须启用批处理模式 + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """在'在线'模式下运行异步迁移""" + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = settings.database_url + + connectable = async_engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """在'在线'模式下运行迁移""" + asyncio.run(run_async_migrations()) + + +# 根据上下文选择运行模式 +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/backend/alembic/sqlite/script.py.mako b/backend/alembic/sqlite/script.py.mako new file mode 100644 index 0000000..3cf5352 --- /dev/null +++ b/backend/alembic/sqlite/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/backend/alembic/sqlite/versions/20251226_1322_fbeb1038c728_初始化sqlite数据库.py b/backend/alembic/sqlite/versions/20251226_1322_fbeb1038c728_初始化sqlite数据库.py new file mode 100644 index 0000000..6322649 --- /dev/null +++ b/backend/alembic/sqlite/versions/20251226_1322_fbeb1038c728_初始化sqlite数据库.py @@ -0,0 +1,616 @@ +"""初始化SQLite数据库 + +Revision ID: fbeb1038c728 +Revises: +Create Date: 2025-12-26 13:22:53.151546 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'fbeb1038c728' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('batch_generation_tasks', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'), + sa.Column('user_id', sa.String(length=100), nullable=False, comment='用户ID'), + sa.Column('start_chapter_number', sa.Integer(), nullable=False, comment='起始章节序号'), + sa.Column('chapter_count', sa.Integer(), nullable=False, comment='生成章节数量'), + sa.Column('chapter_ids', sa.JSON(), nullable=False, comment='待生成的章节ID列表'), + sa.Column('style_id', sa.Integer(), nullable=True, comment='使用的写作风格ID'), + sa.Column('target_word_count', sa.Integer(), nullable=True, comment='目标字数'), + sa.Column('enable_analysis', sa.Boolean(), nullable=True, comment='是否启用同步分析'), + sa.Column('status', sa.String(length=20), nullable=True, comment='任务状态: pending/running/completed/failed/cancelled'), + sa.Column('total_chapters', sa.Integer(), nullable=True, comment='总章节数'), + sa.Column('completed_chapters', sa.Integer(), nullable=True, comment='已完成章节数'), + sa.Column('failed_chapters', sa.JSON(), nullable=True, comment='失败的章节信息列表'), + sa.Column('current_chapter_id', sa.String(length=36), nullable=True, comment='当前正在生成的章节ID'), + sa.Column('current_chapter_number', sa.Integer(), nullable=True, comment='当前正在生成的章节序号'), + sa.Column('current_retry_count', sa.Integer(), nullable=True, comment='当前章节重试次数'), + sa.Column('max_retries', sa.Integer(), nullable=True, comment='最大重试次数'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('started_at', sa.DateTime(), nullable=True, comment='开始时间'), + sa.Column('completed_at', sa.DateTime(), nullable=True, comment='完成时间'), + sa.Column('error_message', sa.String(length=500), nullable=True, comment='错误信息'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('mcp_plugins', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=50), nullable=False, comment='用户ID'), + sa.Column('plugin_name', sa.String(length=100), nullable=False, comment='插件名称(唯一标识)'), + sa.Column('display_name', sa.String(length=200), nullable=False, comment='显示名称'), + sa.Column('description', sa.Text(), nullable=True, comment='插件描述'), + sa.Column('plugin_type', sa.String(length=50), nullable=True, comment='插件类型:http/stdio'), + sa.Column('server_url', sa.String(length=500), nullable=True, comment='服务器URL(HTTP类型)'), + sa.Column('command', sa.String(length=500), nullable=True, comment='启动命令(stdio类型)'), + sa.Column('args', sa.JSON(), nullable=True, comment='命令参数(stdio类型)'), + sa.Column('env', sa.JSON(), nullable=True, comment='环境变量'), + sa.Column('headers', sa.JSON(), nullable=True, comment='HTTP请求头'), + sa.Column('config', sa.JSON(), nullable=True, comment='插件特定配置(JSON)'), + sa.Column('tools', sa.JSON(), nullable=True, comment='提供的工具列表'), + sa.Column('enabled', sa.Boolean(), nullable=True, comment='是否启用'), + sa.Column('status', sa.String(length=50), nullable=True, comment='状态:active/inactive/error'), + sa.Column('last_error', sa.Text(), nullable=True, comment='最后错误信息'), + sa.Column('last_test_at', sa.DateTime(), nullable=True, comment='最后测试时间'), + sa.Column('category', sa.String(length=100), nullable=True, comment='分类'), + sa.Column('sort_order', sa.Integer(), nullable=True, comment='排序顺序'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('mcp_plugins', schema=None) as batch_op: + batch_op.create_index('idx_user_enabled', ['user_id', 'enabled'], unique=False) + batch_op.create_index('idx_user_plugin', ['user_id', 'plugin_name'], unique=True) + batch_op.create_index(batch_op.f('ix_mcp_plugins_user_id'), ['user_id'], unique=False) + + op.create_table('projects', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=100), nullable=False, comment='用户ID'), + sa.Column('title', sa.String(length=200), nullable=False, comment='项目标题'), + sa.Column('description', sa.Text(), nullable=True, comment='项目简介'), + sa.Column('theme', sa.Text(), nullable=True, comment='主题'), + sa.Column('genre', sa.String(length=50), nullable=True, comment='小说类型'), + sa.Column('target_words', sa.Integer(), nullable=True, comment='目标字数'), + sa.Column('current_words', sa.Integer(), nullable=True, comment='当前字数'), + sa.Column('status', sa.String(length=20), nullable=True, comment='创作状态'), + sa.Column('wizard_status', sa.String(length=20), nullable=True, comment='向导完成状态: incomplete/completed'), + sa.Column('wizard_step', sa.Integer(), nullable=True, comment='向导当前步骤: 0-4'), + sa.Column('outline_mode', sa.String(length=20), nullable=False, comment='大纲章节模式: one-to-one(传统模式) 或 one-to-many(细化模式)'), + sa.Column('world_time_period', sa.Text(), nullable=True, comment='时间背景'), + sa.Column('world_location', sa.Text(), nullable=True, comment='地理位置'), + sa.Column('world_atmosphere', sa.Text(), nullable=True, comment='氛围基调'), + sa.Column('world_rules', sa.Text(), nullable=True, comment='世界规则'), + sa.Column('chapter_count', sa.Integer(), nullable=True, comment='章节数量'), + sa.Column('narrative_perspective', sa.String(length=50), nullable=True, comment='叙事视角:first_person/third_person/omniscient'), + sa.Column('character_count', sa.Integer(), nullable=True, comment='角色数量'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.CheckConstraint("outline_mode IN ('one-to-one', 'one-to-many')", name='check_outline_mode'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('projects', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_projects_user_id'), ['user_id'], unique=False) + + op.create_table('prompt_templates', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=50), nullable=False, comment='用户ID'), + sa.Column('template_key', sa.String(length=100), nullable=False, comment='模板键名'), + sa.Column('template_name', sa.String(length=200), nullable=False, comment='模板显示名称'), + sa.Column('template_content', sa.Text(), nullable=False, comment='模板内容'), + sa.Column('description', sa.Text(), nullable=True, comment='模板描述'), + sa.Column('category', sa.String(length=50), nullable=True, comment='模板分类'), + sa.Column('parameters', sa.Text(), nullable=True, comment='模板参数定义(JSON)'), + sa.Column('is_active', sa.Boolean(), nullable=True, comment='是否启用'), + sa.Column('is_system_default', sa.Boolean(), nullable=True, comment='是否为系统默认模板'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('prompt_templates', schema=None) as batch_op: + batch_op.create_index('idx_user_template', ['user_id', 'template_key'], unique=True) + batch_op.create_index(batch_op.f('ix_prompt_templates_user_id'), ['user_id'], unique=False) + + op.create_table('relationship_types', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('name', sa.String(length=50), nullable=False, comment='关系名称'), + sa.Column('category', sa.String(length=20), nullable=False, comment='分类:family/social/hostile/professional'), + sa.Column('reverse_name', sa.String(length=50), nullable=True, comment='反向关系名称'), + sa.Column('intimacy_range', sa.String(length=20), nullable=True, comment='亲密度范围:high/medium/low'), + sa.Column('icon', sa.String(length=50), nullable=True, comment='图标标识'), + sa.Column('description', sa.Text(), nullable=True, comment='关系描述'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('relationship_types', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_relationship_types_id'), ['id'], unique=False) + + op.create_table('settings', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=50), nullable=False, comment='用户ID'), + sa.Column('api_provider', sa.String(length=50), nullable=True, comment='API提供商'), + sa.Column('api_key', sa.String(length=500), nullable=True, comment='API密钥'), + sa.Column('api_base_url', sa.String(length=500), nullable=True, comment='自定义API地址'), + sa.Column('llm_model', sa.String(length=100), nullable=True, comment='模型名称'), + sa.Column('temperature', sa.Float(), nullable=True, comment='温度参数'), + sa.Column('max_tokens', sa.Integer(), nullable=True, comment='最大token数'), + sa.Column('preferences', sa.Text(), nullable=True, comment='其他偏好设置(JSON)'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.create_index('idx_user_id', ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_settings_user_id'), ['user_id'], unique=True) + + op.create_table('user_passwords', + sa.Column('user_id', sa.String(length=100), nullable=False, comment='用户ID'), + sa.Column('username', sa.String(length=100), nullable=False, comment='用户名'), + sa.Column('password_hash', sa.String(length=64), nullable=False, comment='密码哈希(SHA256)'), + sa.Column('has_custom_password', sa.Boolean(), nullable=True, comment='是否为自定义密码'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.PrimaryKeyConstraint('user_id') + ) + with op.batch_alter_table('user_passwords', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_user_passwords_user_id'), ['user_id'], unique=False) + + op.create_table('users', + sa.Column('user_id', sa.String(length=100), nullable=False, comment='用户ID,格式:linuxdo_{id} 或 local_{id}'), + sa.Column('username', sa.String(length=100), nullable=False, comment='用户名'), + sa.Column('display_name', sa.String(length=200), nullable=False, comment='显示名称'), + sa.Column('avatar_url', sa.String(length=500), nullable=True, comment='头像URL'), + sa.Column('trust_level', sa.Integer(), nullable=True, comment='信任等级(仅用于显示)'), + sa.Column('is_admin', sa.Boolean(), nullable=True, comment='是否为管理员'), + sa.Column('linuxdo_id', sa.String(length=100), nullable=False, comment='LinuxDO用户ID或本地用户ID'), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('last_login', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='最后登录时间'), + sa.PrimaryKeyConstraint('user_id') + ) + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_users_linuxdo_id'), ['linuxdo_id'], unique=True) + batch_op.create_index(batch_op.f('ix_users_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_users_username'), ['username'], unique=False) + + op.create_table('careers', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False, comment='职业名称'), + sa.Column('type', sa.String(length=20), nullable=False, comment='职业类型: main(主职业)/sub(副职业)'), + sa.Column('description', sa.Text(), nullable=True, comment='职业描述'), + sa.Column('category', sa.String(length=50), nullable=True, comment='职业分类(如:战斗系、生产系、辅助系)'), + sa.Column('stages', sa.Text(), nullable=False, comment="职业阶段列表(JSON): [{level:1, name:'', description:''}, ...]"), + sa.Column('max_stage', sa.Integer(), nullable=False, comment='最大阶段数'), + sa.Column('requirements', sa.Text(), nullable=True, comment='职业要求/限制'), + sa.Column('special_abilities', sa.Text(), nullable=True, comment='特殊能力描述'), + sa.Column('worldview_rules', sa.Text(), nullable=True, comment='世界观规则关联'), + sa.Column('attribute_bonuses', sa.Text(), nullable=True, comment="属性加成(JSON): {strength: '+10%', intelligence: '+5%'}"), + sa.Column('source', sa.String(length=20), nullable=True, comment='来源: ai/manual'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('careers', schema=None) as batch_op: + batch_op.create_index('idx_project_id', ['project_id'], unique=False) + batch_op.create_index('idx_type', ['type'], unique=False) + + op.create_table('outlines', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False, comment='大纲标题'), + sa.Column('content', sa.Text(), nullable=True, comment='大纲内容'), + sa.Column('structure', sa.Text(), nullable=True, comment='结构化大纲数据(JSON)'), + sa.Column('order_index', sa.Integer(), nullable=True, comment='排序序号'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('writing_styles', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('user_id', sa.String(length=255), nullable=True, comment='所属用户ID(NULL表示全局预设风格)'), + sa.Column('name', sa.String(length=100), nullable=False, comment='风格名称'), + sa.Column('style_type', sa.String(length=50), nullable=False, comment='风格类型:preset/custom'), + sa.Column('preset_id', sa.String(length=50), nullable=True, comment='预设风格ID:natural/classical/modern等'), + sa.Column('description', sa.Text(), nullable=True, comment='风格描述'), + sa.Column('prompt_content', sa.Text(), nullable=False, comment='风格提示词内容'), + sa.Column('order_index', sa.Integer(), nullable=True, comment='排序序号'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['user_id'], ['users.user_id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('chapters', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('chapter_number', sa.Integer(), nullable=False, comment='章节序号'), + sa.Column('title', sa.String(length=200), nullable=False, comment='章节标题'), + sa.Column('content', sa.Text(), nullable=True, comment='章节内容'), + sa.Column('summary', sa.Text(), nullable=True, comment='章节摘要'), + sa.Column('word_count', sa.Integer(), nullable=True, comment='字数统计'), + sa.Column('status', sa.String(length=20), nullable=True, comment='章节状态'), + sa.Column('outline_id', sa.String(length=36), nullable=True, comment='关联的大纲ID'), + sa.Column('sub_index', sa.Integer(), nullable=True, comment='大纲下的子章节序号'), + sa.Column('expansion_plan', sa.Text(), nullable=True, comment='展开规划详情(JSON): 包含key_events, character_focus, emotional_tone等'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['outline_id'], ['outlines.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('characters', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=100), nullable=False, comment='角色/组织名称'), + sa.Column('age', sa.String(length=50), nullable=True, comment='年龄'), + sa.Column('gender', sa.String(length=50), nullable=True, comment='性别'), + sa.Column('is_organization', sa.Boolean(), nullable=True, comment='是否为组织'), + sa.Column('role_type', sa.String(length=50), nullable=True, comment='角色类型'), + sa.Column('personality', sa.Text(), nullable=True, comment='性格特点/组织特性'), + sa.Column('background', sa.Text(), nullable=True, comment='背景故事'), + sa.Column('appearance', sa.Text(), nullable=True, comment='外貌描述'), + sa.Column('relationships', sa.Text(), nullable=True, comment='人物关系(JSON)'), + sa.Column('organization_type', sa.String(length=100), nullable=True, comment='组织类型'), + sa.Column('organization_purpose', sa.String(length=500), nullable=True, comment='组织目的'), + sa.Column('organization_members', sa.Text(), nullable=True, comment='组织成员(JSON)'), + sa.Column('main_career_id', sa.String(length=36), nullable=True, comment='主职业ID'), + sa.Column('main_career_stage', sa.Integer(), nullable=True, comment='主职业当前阶段'), + sa.Column('sub_careers', sa.Text(), nullable=True, comment='副职业列表(JSON): [{"career_id": "xxx", "stage": 3}, ...]'), + sa.Column('avatar_url', sa.String(length=500), nullable=True, comment='头像URL'), + sa.Column('traits', sa.Text(), nullable=True, comment='特征标签(JSON)'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['main_career_id'], ['careers.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('project_default_styles', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'), + sa.Column('style_id', sa.Integer(), nullable=False, comment='风格ID'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['style_id'], ['writing_styles.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('project_id', name='uix_project_default_style') + ) + op.create_table('analysis_tasks', + sa.Column('id', sa.String(length=36), nullable=False, comment='任务ID'), + sa.Column('chapter_id', sa.String(length=36), nullable=False, comment='章节ID'), + sa.Column('user_id', sa.String(length=50), nullable=False, comment='用户ID'), + sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'), + sa.Column('status', sa.String(length=20), nullable=False, comment='任务状态: pending/running/completed/failed'), + sa.Column('progress', sa.Integer(), nullable=True, comment='进度 0-100'), + sa.Column('error_message', sa.Text(), nullable=True, comment='错误信息'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('started_at', sa.DateTime(), nullable=True, comment='开始执行时间'), + sa.Column('completed_at', sa.DateTime(), nullable=True, comment='完成时间'), + sa.ForeignKeyConstraint(['chapter_id'], ['chapters.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('analysis_tasks', schema=None) as batch_op: + batch_op.create_index('idx_chapter_id_created', ['chapter_id', 'created_at'], unique=False) + batch_op.create_index('idx_status', ['status'], unique=False) + + op.create_table('character_careers', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('character_id', sa.String(length=36), nullable=False), + sa.Column('career_id', sa.String(length=36), nullable=False), + sa.Column('career_type', sa.String(length=20), nullable=False, comment='main(主职业)/sub(副职业)'), + sa.Column('current_stage', sa.Integer(), nullable=False, comment='当前阶段(对应职业中的数值)'), + sa.Column('stage_progress', sa.Integer(), nullable=True, comment='阶段内进度(0-100)'), + sa.Column('started_at', sa.String(length=100), nullable=True, comment='开始修炼时间(小说时间线)'), + sa.Column('reached_current_stage_at', sa.String(length=100), nullable=True, comment='到达当前阶段时间'), + sa.Column('notes', sa.Text(), nullable=True, comment='备注(如:修炼心得、特殊事件)'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['career_id'], ['careers.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('character_careers', schema=None) as batch_op: + batch_op.create_index('idx_career_type', ['career_type'], unique=False) + batch_op.create_index('idx_character_career', ['character_id', 'career_id'], unique=True) + batch_op.create_index('idx_character_id', ['character_id'], unique=False) + + op.create_table('character_relationships', + sa.Column('id', sa.String(length=36), nullable=False, comment='关系ID'), + sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'), + sa.Column('character_from_id', sa.String(length=36), nullable=False, comment='角色A的ID'), + sa.Column('character_to_id', sa.String(length=36), nullable=False, comment='角色B的ID'), + sa.Column('relationship_type_id', sa.Integer(), nullable=True, comment='关系类型ID'), + sa.Column('relationship_name', sa.String(length=100), nullable=True, comment='自定义关系名称'), + sa.Column('intimacy_level', sa.Integer(), nullable=True, comment='亲密度:-100到100'), + sa.Column('status', sa.String(length=20), nullable=True, comment='状态:active/broken/past/complicated'), + sa.Column('description', sa.Text(), nullable=True, comment='关系详细描述'), + sa.Column('started_at', sa.String(length=100), nullable=True, comment='关系开始时间(故事时间)'), + sa.Column('ended_at', sa.String(length=100), nullable=True, comment='关系结束时间(故事时间)'), + sa.Column('source', sa.String(length=20), nullable=True, comment='来源:ai/manual/imported'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['character_from_id'], ['characters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['character_to_id'], ['characters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['relationship_type_id'], ['relationship_types.id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('character_relationships', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_character_relationships_character_from_id'), ['character_from_id'], unique=False) + batch_op.create_index(batch_op.f('ix_character_relationships_character_to_id'), ['character_to_id'], unique=False) + batch_op.create_index(batch_op.f('ix_character_relationships_project_id'), ['project_id'], unique=False) + batch_op.create_index(batch_op.f('ix_character_relationships_relationship_type_id'), ['relationship_type_id'], unique=False) + + op.create_table('generation_history', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('chapter_id', sa.String(length=36), nullable=True), + sa.Column('prompt', sa.Text(), nullable=True, comment='使用的提示词'), + sa.Column('generated_content', sa.Text(), nullable=True, comment='生成的内容'), + sa.Column('model', sa.String(length=50), nullable=True, comment='使用的模型'), + sa.Column('tokens_used', sa.Integer(), nullable=True, comment='消耗的token数'), + sa.Column('generation_time', sa.Float(), nullable=True, comment='生成耗时(秒)'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.ForeignKeyConstraint(['chapter_id'], ['chapters.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('organizations', + sa.Column('id', sa.String(length=36), nullable=False, comment='组织ID'), + sa.Column('character_id', sa.String(length=36), nullable=False, comment='关联的角色ID'), + sa.Column('project_id', sa.String(length=36), nullable=False, comment='项目ID'), + sa.Column('parent_org_id', sa.String(length=36), nullable=True, comment='父组织ID'), + sa.Column('level', sa.Integer(), nullable=True, comment='组织层级'), + sa.Column('power_level', sa.Integer(), nullable=True, comment='势力等级:0-100'), + sa.Column('member_count', sa.Integer(), nullable=True, comment='成员数量'), + sa.Column('location', sa.Text(), nullable=True, comment='所在地'), + sa.Column('motto', sa.String(length=200), nullable=True, comment='宗旨/口号'), + sa.Column('color', sa.String(length=100), nullable=True, comment='代表颜色'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['parent_org_id'], ['organizations.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('character_id') + ) + with op.batch_alter_table('organizations', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_organizations_project_id'), ['project_id'], unique=False) + + op.create_table('plot_analysis', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('chapter_id', sa.String(length=36), nullable=False), + sa.Column('plot_stage', sa.String(length=50), nullable=True, comment='剧情阶段: 开端/发展/高潮/结局/过渡'), + sa.Column('conflict_level', sa.Integer(), nullable=True, comment='冲突强度 1-10'), + sa.Column('conflict_types', sa.JSON(), nullable=True, comment="冲突类型列表: ['人与人', '人与己', '人与环境']"), + sa.Column('emotional_tone', sa.String(length=100), nullable=True, comment='主导情感: 紧张/温馨/悲伤/激昂/平静'), + sa.Column('emotional_intensity', sa.Float(), nullable=True, comment='情感强度 0.0-1.0'), + sa.Column('emotional_curve', sa.JSON(), nullable=True, comment='情感曲线: {start: 0.3, middle: 0.7, end: 0.5}'), + sa.Column('hooks', sa.JSON(), nullable=True, comment='钩子列表 - 吸引读者的元素: [\n {\n "type": "悬念|情感|冲突|认知",\n "content": "具体内容",\n "strength": 8,\n "position": "开头|中段|结尾"\n }\n ]'), + sa.Column('hooks_count', sa.Integer(), nullable=True, comment='钩子数量'), + sa.Column('hooks_avg_strength', sa.Float(), nullable=True, comment='钩子平均强度'), + sa.Column('foreshadows', sa.JSON(), nullable=True, comment='伏笔列表: [\n {\n "content": "伏笔内容",\n "type": "planted|resolved",\n "strength": 7,\n "subtlety": 8,\n "reference_chapter": 3\n }\n ]'), + sa.Column('foreshadows_planted', sa.Integer(), nullable=True, comment='本章埋下的伏笔数量'), + sa.Column('foreshadows_resolved', sa.Integer(), nullable=True, comment='本章回收的伏笔数量'), + sa.Column('plot_points', sa.JSON(), nullable=True, comment='情节点列表: [\n {\n "content": "情节点描述",\n "importance": 0.9,\n "type": "revelation|conflict|resolution|transition",\n "impact": "对故事的影响描述"\n }\n ]'), + sa.Column('plot_points_count', sa.Integer(), nullable=True, comment='情节点数量'), + sa.Column('character_states', sa.JSON(), nullable=True, comment='角色状态变化: [\n {\n "character_id": "xxx",\n "character_name": "张三",\n "state_before": "犹豫不决",\n "state_after": "坚定信念",\n "psychological_change": "内心描述",\n "key_event": "触发事件",\n "relationship_changes": {"李四": "关系变化"}\n }\n ]'), + sa.Column('scenes', sa.JSON(), nullable=True, comment="场景列表: [{location: '地点', atmosphere: '氛围', duration: '时长'}]"), + sa.Column('pacing', sa.String(length=50), nullable=True, comment='节奏: slow|moderate|fast|varied'), + sa.Column('overall_quality_score', sa.Float(), nullable=True, comment='整体质量评分 0.0-10.0'), + sa.Column('pacing_score', sa.Float(), nullable=True, comment='节奏评分 0.0-10.0'), + sa.Column('engagement_score', sa.Float(), nullable=True, comment='吸引力评分 0.0-10.0'), + sa.Column('coherence_score', sa.Float(), nullable=True, comment='连贯性评分 0.0-10.0'), + sa.Column('analysis_report', sa.Text(), nullable=True, comment='完整的文字分析报告'), + sa.Column('suggestions', sa.JSON(), nullable=True, comment="改进建议列表: ['建议1', '建议2']"), + sa.Column('word_count', sa.Integer(), nullable=True, comment='章节字数'), + sa.Column('dialogue_ratio', sa.Float(), nullable=True, comment='对话占比 0.0-1.0'), + sa.Column('description_ratio', sa.Float(), nullable=True, comment='描写占比 0.0-1.0'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='分析时间'), + sa.ForeignKeyConstraint(['chapter_id'], ['chapters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('plot_analysis', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_plot_analysis_chapter_id'), ['chapter_id'], unique=True) + batch_op.create_index(batch_op.f('ix_plot_analysis_project_id'), ['project_id'], unique=False) + + op.create_table('regeneration_tasks', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('chapter_id', sa.String(length=36), nullable=False), + sa.Column('analysis_id', sa.String(length=36), nullable=True, comment='关联的分析结果ID'), + sa.Column('user_id', sa.String(length=50), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('modification_instructions', sa.Text(), nullable=False, comment='综合修改指令'), + sa.Column('original_suggestions', sa.JSON(), nullable=True, comment='来自分析的原始建议列表'), + sa.Column('selected_suggestion_indices', sa.JSON(), nullable=True, comment='用户选择的建议索引'), + sa.Column('custom_instructions', sa.Text(), nullable=True, comment='用户自定义修改意见'), + sa.Column('style_id', sa.Integer(), nullable=True, comment='写作风格ID'), + sa.Column('target_word_count', sa.Integer(), nullable=True, comment='目标字数'), + sa.Column('focus_areas', sa.JSON(), nullable=True, comment='重点优化方向'), + sa.Column('preserve_elements', sa.JSON(), nullable=True, comment='需要保留的元素配置'), + sa.Column('status', sa.String(length=20), nullable=True, comment='pending/running/completed/failed'), + sa.Column('progress', sa.Integer(), nullable=True, comment='进度 0-100'), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('original_content', sa.Text(), nullable=True, comment='原始章节内容快照'), + sa.Column('original_word_count', sa.Integer(), nullable=True, comment='原始字数'), + sa.Column('regenerated_content', sa.Text(), nullable=True, comment='重新生成的内容'), + sa.Column('regenerated_word_count', sa.Integer(), nullable=True, comment='新内容字数'), + sa.Column('version_number', sa.Integer(), nullable=True, comment='版本号'), + sa.Column('version_note', sa.String(length=500), nullable=True, comment='版本说明'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True), + sa.Column('started_at', sa.DateTime(), nullable=True), + sa.Column('completed_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['chapter_id'], ['chapters.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('regeneration_tasks', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_regeneration_tasks_chapter_id'), ['chapter_id'], unique=False) + batch_op.create_index(batch_op.f('ix_regeneration_tasks_project_id'), ['project_id'], unique=False) + batch_op.create_index(batch_op.f('ix_regeneration_tasks_user_id'), ['user_id'], unique=False) + + op.create_table('story_memories', + sa.Column('id', sa.String(length=100), nullable=False), + sa.Column('project_id', sa.String(length=36), nullable=False), + sa.Column('chapter_id', sa.String(length=36), nullable=True), + sa.Column('memory_type', sa.String(length=50), nullable=False, comment='\n 记忆类型:\n - plot_point: 情节点\n - character_event: 角色事件\n - world_detail: 世界观细节\n - hook: 钩子(悬念/冲突)\n - foreshadow: 伏笔\n - dialogue: 重要对话\n - scene: 场景描写\n '), + sa.Column('title', sa.String(length=200), nullable=True, comment='记忆标题/简述'), + sa.Column('content', sa.Text(), nullable=False, comment='记忆内容摘要(100-500字)'), + sa.Column('full_context', sa.Text(), nullable=True, comment='完整上下文(可选,用于详细记录)'), + sa.Column('related_characters', sa.JSON(), nullable=True, comment="涉及角色ID列表: ['char_id_1', 'char_id_2']"), + sa.Column('related_locations', sa.JSON(), nullable=True, comment="涉及地点列表: ['地点1', '地点2']"), + sa.Column('tags', sa.JSON(), nullable=True, comment="标签列表: ['悬念', '转折', '伏笔', '高潮']"), + sa.Column('importance_score', sa.Float(), nullable=True, comment='重要性评分 0.0-1.0'), + sa.Column('story_timeline', sa.Integer(), nullable=False, comment='故事时间线位置(章节序号)'), + sa.Column('chapter_position', sa.Integer(), nullable=True, comment='章节内位置(字符位置)'), + sa.Column('text_length', sa.Integer(), nullable=True, comment='文本长度(字符数)'), + sa.Column('is_foreshadow', sa.Integer(), nullable=True, comment='伏笔状态: 0=普通记忆, 1=已埋下伏笔, 2=伏笔已回收'), + sa.Column('foreshadow_resolved_at', sa.String(length=100), nullable=True, comment='伏笔回收的章节ID'), + sa.Column('foreshadow_strength', sa.Float(), nullable=True, comment='伏笔强度 0.0-1.0'), + sa.Column('vector_id', sa.String(length=100), nullable=True, comment='向量数据库中的唯一ID'), + sa.Column('embedding_model', sa.String(length=100), nullable=True, comment='使用的embedding模型'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['chapter_id'], ['chapters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['foreshadow_resolved_at'], ['chapters.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('vector_id') + ) + with op.batch_alter_table('story_memories', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_story_memories_chapter_id'), ['chapter_id'], unique=False) + batch_op.create_index(batch_op.f('ix_story_memories_memory_type'), ['memory_type'], unique=False) + batch_op.create_index(batch_op.f('ix_story_memories_project_id'), ['project_id'], unique=False) + batch_op.create_index(batch_op.f('ix_story_memories_story_timeline'), ['story_timeline'], unique=False) + + op.create_table('organization_members', + sa.Column('id', sa.String(length=36), nullable=False, comment='成员关系ID'), + sa.Column('organization_id', sa.String(length=36), nullable=False, comment='组织ID'), + sa.Column('character_id', sa.String(length=36), nullable=False, comment='角色ID'), + sa.Column('position', sa.String(length=100), nullable=False, comment='职位名称'), + sa.Column('rank', sa.Integer(), nullable=True, comment='职位等级'), + sa.Column('status', sa.String(length=20), nullable=True, comment='状态:active/retired/expelled/deceased'), + sa.Column('joined_at', sa.String(length=100), nullable=True, comment='加入时间(故事时间)'), + sa.Column('left_at', sa.String(length=100), nullable=True, comment='离开时间(故事时间)'), + sa.Column('loyalty', sa.Integer(), nullable=True, comment='忠诚度:0-100'), + sa.Column('contribution', sa.Integer(), nullable=True, comment='贡献度:0-100'), + sa.Column('source', sa.String(length=20), nullable=True, comment='来源:ai/manual'), + sa.Column('notes', sa.Text(), nullable=True, comment='备注'), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='创建时间'), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=True, comment='更新时间'), + sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('organization_members', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_organization_members_character_id'), ['character_id'], unique=False) + batch_op.create_index(batch_op.f('ix_organization_members_organization_id'), ['organization_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('organization_members', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_organization_members_organization_id')) + batch_op.drop_index(batch_op.f('ix_organization_members_character_id')) + + op.drop_table('organization_members') + with op.batch_alter_table('story_memories', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_story_memories_story_timeline')) + batch_op.drop_index(batch_op.f('ix_story_memories_project_id')) + batch_op.drop_index(batch_op.f('ix_story_memories_memory_type')) + batch_op.drop_index(batch_op.f('ix_story_memories_chapter_id')) + + op.drop_table('story_memories') + with op.batch_alter_table('regeneration_tasks', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_regeneration_tasks_user_id')) + batch_op.drop_index(batch_op.f('ix_regeneration_tasks_project_id')) + batch_op.drop_index(batch_op.f('ix_regeneration_tasks_chapter_id')) + + op.drop_table('regeneration_tasks') + with op.batch_alter_table('plot_analysis', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_plot_analysis_project_id')) + batch_op.drop_index(batch_op.f('ix_plot_analysis_chapter_id')) + + op.drop_table('plot_analysis') + with op.batch_alter_table('organizations', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_organizations_project_id')) + + op.drop_table('organizations') + op.drop_table('generation_history') + with op.batch_alter_table('character_relationships', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_character_relationships_relationship_type_id')) + batch_op.drop_index(batch_op.f('ix_character_relationships_project_id')) + batch_op.drop_index(batch_op.f('ix_character_relationships_character_to_id')) + batch_op.drop_index(batch_op.f('ix_character_relationships_character_from_id')) + + op.drop_table('character_relationships') + with op.batch_alter_table('character_careers', schema=None) as batch_op: + batch_op.drop_index('idx_character_id') + batch_op.drop_index('idx_character_career') + batch_op.drop_index('idx_career_type') + + op.drop_table('character_careers') + with op.batch_alter_table('analysis_tasks', schema=None) as batch_op: + batch_op.drop_index('idx_status') + batch_op.drop_index('idx_chapter_id_created') + + op.drop_table('analysis_tasks') + op.drop_table('project_default_styles') + op.drop_table('characters') + op.drop_table('chapters') + op.drop_table('writing_styles') + op.drop_table('outlines') + with op.batch_alter_table('careers', schema=None) as batch_op: + batch_op.drop_index('idx_type') + batch_op.drop_index('idx_project_id') + + op.drop_table('careers') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_users_username')) + batch_op.drop_index(batch_op.f('ix_users_user_id')) + batch_op.drop_index(batch_op.f('ix_users_linuxdo_id')) + + op.drop_table('users') + with op.batch_alter_table('user_passwords', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_user_passwords_user_id')) + + op.drop_table('user_passwords') + with op.batch_alter_table('settings', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_settings_user_id')) + batch_op.drop_index('idx_user_id') + + op.drop_table('settings') + with op.batch_alter_table('relationship_types', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_relationship_types_id')) + + op.drop_table('relationship_types') + with op.batch_alter_table('prompt_templates', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_prompt_templates_user_id')) + batch_op.drop_index('idx_user_template') + + op.drop_table('prompt_templates') + with op.batch_alter_table('projects', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_projects_user_id')) + + op.drop_table('projects') + with op.batch_alter_table('mcp_plugins', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_mcp_plugins_user_id')) + batch_op.drop_index('idx_user_plugin') + batch_op.drop_index('idx_user_enabled') + + op.drop_table('mcp_plugins') + op.drop_table('batch_generation_tasks') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 2edd5f0..b42db08 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -8,7 +8,7 @@ from datetime import datetime import hashlib from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.database import get_db, init_db +from app.database import get_db from app.models.user import User from app.user_manager import user_manager from app.user_password import password_manager @@ -160,12 +160,7 @@ async def create_user( password=data.password ) - # 初始化用户数据库 - try: - await init_db(new_user.user_id) - logger.info(f"用户 {new_user.user_id} 数据库初始化成功") - except Exception as e: - logger.error(f"用户 {new_user.user_id} 数据库初始化失败: {e}") + # Settings 将在首次访问设置页面时自动创建(延迟初始化) logger.info(f"管理员 {admin.user_id} 创建了新用户 {new_user.user_id} ({data.username})") diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index a300742..c84b840 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -10,7 +10,6 @@ from datetime import datetime, timedelta, timezone from app.services.oauth_service import LinuxDOOAuthService from app.user_manager import user_manager from app.user_password import password_manager -from app.database import init_db from app.logger import get_logger from app.config import settings @@ -152,12 +151,7 @@ async def local_login(request: LocalLoginRequest, response: Response): logger.info(f"[本地登录] 管理员用户 {user.user_id} 登录成功") - # 初始化用户数据库 - try: - await init_db(user.user_id) - logger.info(f"本地用户 {user.user_id} 数据库初始化成功") - except Exception as e: - logger.error(f"本地用户 {user.user_id} 数据库初始化失败: {e}") + # Settings 将在首次访问设置页面时自动创建(延迟初始化) # 设置 Cookie(2小时有效) max_age = settings.SESSION_EXPIRE_MINUTES * 60 @@ -261,13 +255,7 @@ async def _handle_callback( default_password = await password_manager.set_password(user.user_id, username) logger.info(f"用户 {user.user_id} ({username}) 自动绑定默认密码: {default_password}") - # 3.5. 初始化用户数据库(如果是新用户) - try: - await init_db(user.user_id) - logger.info(f"用户 {user.user_id} 数据库初始化成功") - except Exception as e: - logger.error(f"用户 {user.user_id} 数据库初始化失败: {e}") - # 继续执行,不影响登录流程(可能是已存在的用户) + # Settings 将在首次访问设置页面时自动创建(延迟初始化) # 4. 设置 Cookie 并重定向到前端回调页面 # 使用配置的前端URL,支持不同的部署环境 @@ -495,12 +483,7 @@ async def bind_account_login(request: LocalLoginRequest, response: Response): if not is_valid: raise HTTPException(status_code=401, detail="用户名或密码错误") - # 初始化用户数据库 - try: - await init_db(target_user.user_id) - logger.info(f"绑定账号用户 {target_user.user_id} 数据库初始化成功") - except Exception as e: - logger.error(f"绑定账号用户 {target_user.user_id} 数据库初始化失败: {e}") + # Settings 将在首次访问设置页面时自动创建(延迟初始化) # 设置 Cookie(2小时有效) max_age = settings.SESSION_EXPIRE_MINUTES * 60 diff --git a/backend/app/database.py b/backend/app/database.py index 5e442c5..3101a98 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -21,7 +21,7 @@ from app.models import ( Settings, WritingStyle, ProjectDefaultStyle, RelationshipType, CharacterRelationship, Organization, OrganizationMember, StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask, - RegenerationTask, Career, CharacterCareer + RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate ) # 引擎缓存:每个用户一个引擎 @@ -223,147 +223,25 @@ async def get_db(request: Request): except: pass -async def _init_relationship_types(user_id: str): - """为指定用户初始化预置的关系类型数据 +async def init_db(user_id: str = None): + """ + 初始化数据库(已弃用) + + ⚠️ 此函数已弃用,仅保留用于向后兼容 + + 新的最佳实践: + - 表结构管理: 使用 'alembic upgrade head' + - 用户配置: Settings 在首次访问时自动创建(延迟初始化) Args: - user_id: 用户ID + user_id: 用户ID (已不再使用) """ - from app.models.relationship import RelationshipType - - relationship_types = [ - {"name": "父亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👨"}, - {"name": "母亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👩"}, - {"name": "兄弟", "category": "family", "reverse_name": "兄弟", "intimacy_range": "high", "icon": "👬"}, - {"name": "姐妹", "category": "family", "reverse_name": "姐妹", "intimacy_range": "high", "icon": "👭"}, - {"name": "子女", "category": "family", "reverse_name": "父母", "intimacy_range": "high", "icon": "👶"}, - {"name": "配偶", "category": "family", "reverse_name": "配偶", "intimacy_range": "high", "icon": "💑"}, - {"name": "恋人", "category": "family", "reverse_name": "恋人", "intimacy_range": "high", "icon": "💕"}, - - {"name": "师父", "category": "social", "reverse_name": "徒弟", "intimacy_range": "high", "icon": "🎓"}, - {"name": "徒弟", "category": "social", "reverse_name": "师父", "intimacy_range": "high", "icon": "📚"}, - {"name": "朋友", "category": "social", "reverse_name": "朋友", "intimacy_range": "medium", "icon": "🤝"}, - {"name": "同学", "category": "social", "reverse_name": "同学", "intimacy_range": "medium", "icon": "🎒"}, - {"name": "邻居", "category": "social", "reverse_name": "邻居", "intimacy_range": "low", "icon": "🏘️"}, - {"name": "知己", "category": "social", "reverse_name": "知己", "intimacy_range": "high", "icon": "💙"}, - - {"name": "上司", "category": "professional", "reverse_name": "下属", "intimacy_range": "low", "icon": "👔"}, - {"name": "下属", "category": "professional", "reverse_name": "上司", "intimacy_range": "low", "icon": "💼"}, - {"name": "同事", "category": "professional", "reverse_name": "同事", "intimacy_range": "medium", "icon": "🤵"}, - {"name": "合作伙伴", "category": "professional", "reverse_name": "合作伙伴", "intimacy_range": "medium", "icon": "🤜🤛"}, - - {"name": "敌人", "category": "hostile", "reverse_name": "敌人", "intimacy_range": "low", "icon": "⚔️"}, - {"name": "仇人", "category": "hostile", "reverse_name": "仇人", "intimacy_range": "low", "icon": "💢"}, - {"name": "竞争对手", "category": "hostile", "reverse_name": "竞争对手", "intimacy_range": "low", "icon": "🎯"}, - {"name": "宿敌", "category": "hostile", "reverse_name": "宿敌", "intimacy_range": "low", "icon": "⚡"}, - ] - - try: - engine = await get_engine(user_id) - AsyncSessionLocal = async_sessionmaker( - engine, - class_=AsyncSession, - expire_on_commit=False - ) - - async with AsyncSessionLocal() as session: - result = await session.execute(select(RelationshipType)) - existing = result.scalars().first() - - if existing: - logger.info(f"用户 {user_id} 的关系类型数据已存在,跳过初始化") - return - - logger.info(f"开始为用户 {user_id} 插入关系类型数据...") - for rt_data in relationship_types: - relationship_type = RelationshipType(**rt_data) - session.add(relationship_type) - - await session.commit() - logger.info(f"成功为用户 {user_id} 插入 {len(relationship_types)} 条关系类型数据") - - except Exception as e: - logger.error(f"用户 {user_id} 初始化关系类型数据失败: {str(e)}", exc_info=True) - raise - - - -async def _init_global_writing_styles(user_id: str): - """为指定用户初始化全局预设写作风格 - - 全局预设风格的 project_id 为 NULL,所有用户共享 - 只在第一次创建数据库时插入一次 - - Args: - user_id: 用户ID - """ - from app.models.writing_style import WritingStyle - from app.services.prompt_service import WritingStyleManager - - try: - engine = await get_engine(user_id) - AsyncSessionLocal = async_sessionmaker( - engine, - class_=AsyncSession, - expire_on_commit=False - ) - - async with AsyncSessionLocal() as session: - # 检查是否已存在全局预设风格 - result = await session.execute( - select(WritingStyle).where(WritingStyle.user_id.is_(None)) - ) - existing = result.scalars().first() - - if existing: - logger.info(f"用户 {user_id} 的全局预设风格已存在,跳过初始化") - return - - logger.info(f"开始为用户 {user_id} 插入全局预设写作风格...") - - # 获取所有预设风格配置 - presets = WritingStyleManager.get_all_presets() - - for index, (preset_id, preset_data) in enumerate(presets.items(), start=1): - style = WritingStyle( - user_id=None, # NULL 表示全局预设 - name=preset_data["name"], - style_type="preset", - preset_id=preset_id, - description=preset_data["description"], - prompt_content=preset_data["prompt_content"], - order_index=index - ) - session.add(style) - - await session.commit() - logger.info(f"成功为用户 {user_id} 插入 {len(presets)} 个全局预设写作风格") - - except Exception as e: - logger.error(f"用户 {user_id} 初始化全局预设写作风格失败: {str(e)}", exc_info=True) - raise - - -async def init_db(user_id: str): - """初始化指定用户的数据库,创建所有表并插入预置数据 - - Args: - user_id: 用户ID - """ - try: - logger.info(f"开始初始化用户 {user_id} 的数据库...") - engine = await get_engine(user_id) - - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - await _init_relationship_types(user_id) - await _init_global_writing_styles(user_id) - - logger.info(f"用户 {user_id} 的数据库初始化成功") - except Exception as e: - logger.error(f"用户 {user_id} 的数据库初始化失败: {str(e)}", exc_info=True) - raise + logger.warning( + "⚠️ init_db() 已弃用且无实际作用!\n" + " - 表结构: 由 Alembic 管理\n" + " - 用户配置: Settings API 自动创建\n" + " 建议移除此调用" + ) async def close_db(): diff --git a/backend/app/main.py b/backend/app/main.py index 3f4d8b2..5a3a57e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -27,23 +27,7 @@ logger = get_logger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): """应用生命周期管理""" - logger.info("应用启动,初始化数据库表结构...") - - # 在应用启动时初始化数据库表结构 - try: - from app.database import get_engine, Base - - # 使用全局引擎创建所有表 - engine = await get_engine("_global_init_") - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - logger.info("✅ 数据库表结构初始化成功") - except Exception as e: - logger.error(f"❌ 数据库表结构初始化失败: {str(e)}", exc_info=True) - # 不阻止应用启动,允许在后续操作中重试 - - logger.info("应用启动完成,等待用户登录...") + logger.info("应用启动完成") yield diff --git a/backend/app/mcp/registry.py b/backend/app/mcp/registry.py index e9a4b9d..e6ac981 100644 --- a/backend/app/mcp/registry.py +++ b/backend/app/mcp/registry.py @@ -149,13 +149,23 @@ class MCPPluginRegistry: session.status = "active" logger.info(f"✅ 会话 {plugin_id} 恢复正常") - # 检查长时间无活动的会话 + # 检查即将过期的会话(最后1分钟提醒) idle_time = time.time() - session.last_access - if idle_time > mcp_config.IDLE_TIMEOUT_SECONDS: - logger.info( - f"💤 会话 {plugin_id} 空闲 {idle_time/60:.1f} 分钟," - f"准备清理" - ) + time_until_expiry = self._client_ttl - idle_time + + # 仅在最后1分钟(60秒)内提醒一次 + if 0 < time_until_expiry <= 60: + # 使用会话属性避免重复提醒 + if not hasattr(session, '_expiry_warned') or not session._expiry_warned: + logger.warning( + f"⏰ 会话 {plugin_id} 即将过期 " + f"(剩余 {time_until_expiry:.0f} 秒)" + ) + session._expiry_warned = True + elif time_until_expiry > 60: + # 重置警告标志(如果会话被重新使用) + if hasattr(session, '_expiry_warned'): + session._expiry_warned = False async def _get_user_lock(self, user_id: str) -> asyncio.Lock: """ diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index f077796..bdec837 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -15,6 +15,7 @@ from app.models.mcp_plugin import MCPPlugin from app.models.user import User, UserPassword from app.models.regeneration_task import RegenerationTask from app.models.career import Career, CharacterCareer +from app.models.prompt_template import PromptTemplate __all__ = [ "Project", @@ -38,5 +39,6 @@ __all__ = [ "UserPassword", "RegenerationTask", "Career", - "CharacterCareer" + "CharacterCareer", + "PromptTemplate" ] \ No newline at end of file diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 569a195..2506bf7 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -4,7 +4,8 @@ from openai import AsyncOpenAI from anthropic import AsyncAnthropic from app.config import settings as app_settings from app.logger import get_logger -from app.mcp.adapters import UniversalMCPAdapter, PromptInjectionAdapter +from app.mcp.adapters import PromptInjectionAdapter +from app.mcp.adapters.universal import universal_mcp_adapter import httpx import json import hashlib @@ -145,11 +146,11 @@ class AIService: self.default_temperature = default_temperature or app_settings.default_temperature self.default_max_tokens = default_max_tokens or app_settings.default_max_tokens - # 初始化MCP适配器 + # 使用全局MCP适配器单例 self.enable_mcp_adapter = enable_mcp_adapter if enable_mcp_adapter: - self.mcp_adapter = UniversalMCPAdapter() - logger.info("✅ MCP通用适配器已启用") + self.mcp_adapter = universal_mcp_adapter + logger.info("✅ MCP通用适配器已启用(使用全局单例)") else: self.mcp_adapter = None logger.info("⚠️ MCP适配器已禁用") diff --git a/backend/app/services/memory_service.py b/backend/app/services/memory_service.py index 7ac3345..189a5fe 100644 --- a/backend/app/services/memory_service.py +++ b/backend/app/services/memory_service.py @@ -18,21 +18,52 @@ from pathlib import Path if 'SENTENCE_TRANSFORMERS_HOME' not in os.environ: # 根据运行环境确定模型目录 if getattr(sys, 'frozen', False): - # PyInstaller 打包后 - base_dir = Path(sys.executable).parent + # PyInstaller 打包后 - 需要检查多个可能的位置 + exe_dir = Path(sys.executable).parent + + # 检查顺序: + # 1. _MEIPASS/backend/embedding (临时解压目录) + # 2. exe同级/_internal/backend/embedding + # 3. exe同级/backend/embedding + possible_paths = [] + + if hasattr(sys, '_MEIPASS'): + possible_paths.append(Path(sys._MEIPASS) / 'backend' / 'embedding') + + possible_paths.extend([ + exe_dir / '_internal' / 'backend' / 'embedding', + exe_dir / 'backend' / 'embedding', + exe_dir / '_internal' / 'embedding', + exe_dir / 'embedding' + ]) + + model_dir = None + for path in possible_paths: + if path.exists(): + model_dir = path + logger.info(f"🔧 找到打包环境模型目录: {model_dir}") + break + + if model_dir: + os.environ['SENTENCE_TRANSFORMERS_HOME'] = str(model_dir) + else: + # 最后降级方案 + fallback_dir = exe_dir / 'embedding' + os.environ['SENTENCE_TRANSFORMERS_HOME'] = str(fallback_dir) + logger.warning(f"⚠️ 未找到预打包模型,使用降级目录: {fallback_dir}") + logger.warning(f" 检查过的路径: {[str(p) for p in possible_paths]}") else: # 开发模式,从当前文件位置向上找到项目根目录 base_dir = Path(__file__).parent.parent.parent - - model_dir = base_dir / 'backend' / 'embedding' - if model_dir.exists(): - os.environ['SENTENCE_TRANSFORMERS_HOME'] = str(model_dir) - logger.info(f"🔧 设置模型目录: {model_dir}") - else: - # 降级到项目根目录的 embedding - fallback_dir = base_dir / 'embedding' - os.environ['SENTENCE_TRANSFORMERS_HOME'] = str(fallback_dir) - logger.info(f"🔧 使用降级模型目录: {fallback_dir}") + model_dir = base_dir / 'backend' / 'embedding' + if model_dir.exists(): + os.environ['SENTENCE_TRANSFORMERS_HOME'] = str(model_dir) + logger.info(f"🔧 设置开发环境模型目录: {model_dir}") + else: + # 降级到项目根目录的 embedding + fallback_dir = base_dir / 'embedding' + os.environ['SENTENCE_TRANSFORMERS_HOME'] = str(fallback_dir) + logger.info(f"🔧 使用降级模型目录: {fallback_dir}") class MemoryService: diff --git a/backend/requirements.txt b/backend/requirements.txt index bef0b5f..8025cf9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -7,6 +7,7 @@ python-multipart==0.0.20 sqlalchemy==2.0.25 asyncpg==0.29.0 # PostgreSQL异步驱动 psycopg2-binary==2.9.9 # PostgreSQL同步驱动(用于迁移脚本) +alembic==1.14.0 # 数据库迁移工具 # 数据验证 pydantic==2.12.4 diff --git a/backend/scripts/add_outline_chapter_relation.sql b/backend/scripts/add_outline_chapter_relation.sql deleted file mode 100644 index c41172f..0000000 --- a/backend/scripts/add_outline_chapter_relation.sql +++ /dev/null @@ -1,45 +0,0 @@ --- 为Chapter表添加与Outline的关联关系 --- 实现大纲到章节的一对多关系 - --- 添加outline_id外键字段 -ALTER TABLE chapters -ADD COLUMN outline_id VARCHAR(36) NULL; - --- 添加sub_index字段,表示在该大纲下的子章节序号 -ALTER TABLE chapters -ADD COLUMN sub_index INTEGER DEFAULT 1; - --- 添加字段注释(PostgreSQL语法) -COMMENT ON COLUMN chapters.outline_id IS '关联的大纲ID'; -COMMENT ON COLUMN chapters.sub_index IS '大纲下的子章节序号'; - --- 添加外键约束 -ALTER TABLE chapters -ADD CONSTRAINT fk_chapter_outline - FOREIGN KEY (outline_id) - REFERENCES outlines(id) - ON DELETE SET NULL; - --- 创建索引优化查询性能 -CREATE INDEX idx_chapters_outline_id ON chapters(outline_id); -CREATE INDEX idx_chapters_outline_sub ON chapters(outline_id, sub_index); - --- 说明: --- outline_id为NULL表示旧数据或独立章节 --- outline_id有值表示该章节由某个大纲展开生成 --- sub_index表示在该大纲下的第几个子章节(从1开始) - --- 为 chapters 表添加 expansion_plan 字段 --- 用于存储大纲展开规划的详细数据(JSON格式) - --- 添加字段 -ALTER TABLE chapters ADD COLUMN IF NOT EXISTS expansion_plan TEXT; - --- 添加注释 -COMMENT ON COLUMN chapters.expansion_plan IS '展开规划详情(JSON): 包含key_events, character_focus, emotional_tone等'; - --- 查看修改结果 -SELECT column_name, data_type, is_nullable, column_default -FROM information_schema.columns -WHERE table_name = 'chapters' -ORDER BY ordinal_position; \ No newline at end of file diff --git a/backend/scripts/create_career_tables.sql b/backend/scripts/create_career_tables.sql deleted file mode 100644 index fd72e43..0000000 --- a/backend/scripts/create_career_tables.sql +++ /dev/null @@ -1,200 +0,0 @@ --- 职业体系模块数据库迁移脚本(PostgreSQL版本) --- 创建时间: 2025-12-20 --- 说明: 添加职业表和角色职业关联表 - --- ===== 1. 创建职业表 ===== -CREATE TABLE IF NOT EXISTS careers ( - id VARCHAR(36) PRIMARY KEY, - project_id VARCHAR(36) NOT NULL, - - -- 基本信息 - name VARCHAR(100) NOT NULL, - type VARCHAR(20) NOT NULL, -- 职业类型: main(主职业)/sub(副职业) - description TEXT, -- 职业描述 - category VARCHAR(50), -- 职业分类(如:战斗系、生产系、辅助系) - - -- 阶段设定 - stages TEXT NOT NULL, -- 职业阶段列表(JSON): [{"level":1, "name":"", "description":""}, ...] - max_stage INT NOT NULL DEFAULT 10, -- 最大阶段数 - - -- 职业特性 - requirements TEXT, -- 职业要求/限制 - special_abilities TEXT, -- 特殊能力描述 - worldview_rules TEXT, -- 世界观规则关联 - - -- 职业属性加成(可选,JSON格式) - attribute_bonuses TEXT, -- 属性加成(JSON): {"strength": "+10%", "intelligence": "+5%"} - - -- 元数据 - source VARCHAR(20) DEFAULT 'ai', -- 来源: ai/manual - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间 - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间 - - -- 外键约束 - CONSTRAINT fk_career_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_careers_project_id ON careers(project_id); -CREATE INDEX IF NOT EXISTS idx_careers_type ON careers(type); - --- 创建更新时间触发器 -CREATE OR REPLACE FUNCTION update_careers_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_careers_updated_at - BEFORE UPDATE ON careers - FOR EACH ROW - EXECUTE FUNCTION update_careers_updated_at(); - --- 添加表注释 -COMMENT ON TABLE careers IS '职业表'; -COMMENT ON COLUMN careers.name IS '职业名称'; -COMMENT ON COLUMN careers.type IS '职业类型: main(主职业)/sub(副职业)'; -COMMENT ON COLUMN careers.description IS '职业描述'; -COMMENT ON COLUMN careers.category IS '职业分类(如:战斗系、生产系、辅助系)'; -COMMENT ON COLUMN careers.stages IS '职业阶段列表(JSON)'; -COMMENT ON COLUMN careers.max_stage IS '最大阶段数'; -COMMENT ON COLUMN careers.requirements IS '职业要求/限制'; -COMMENT ON COLUMN careers.special_abilities IS '特殊能力描述'; -COMMENT ON COLUMN careers.worldview_rules IS '世界观规则关联'; -COMMENT ON COLUMN careers.attribute_bonuses IS '属性加成(JSON)'; -COMMENT ON COLUMN careers.source IS '来源: ai/manual'; -COMMENT ON COLUMN careers.created_at IS '创建时间'; -COMMENT ON COLUMN careers.updated_at IS '更新时间'; - --- ===== 2. 创建角色职业关联表 ===== -CREATE TABLE IF NOT EXISTS character_careers ( - id VARCHAR(36) PRIMARY KEY, - character_id VARCHAR(36) NOT NULL, - career_id VARCHAR(36) NOT NULL, - career_type VARCHAR(20) NOT NULL, -- main(主职业)/sub(副职业) - - -- 阶段进度 - current_stage INT NOT NULL DEFAULT 1, -- 当前阶段(对应职业中的数值) - stage_progress INT DEFAULT 0, -- 阶段内进度(0-100) - - -- 时间记录 - started_at VARCHAR(100), -- 开始修炼时间(小说时间线) - reached_current_stage_at VARCHAR(100), -- 到达当前阶段时间 - - -- 备注 - notes TEXT, -- 备注(如:修炼心得、特殊事件) - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 创建时间 - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 更新时间 - - -- 外键约束 - CONSTRAINT fk_charcareer_character FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE, - CONSTRAINT fk_charcareer_career FOREIGN KEY (career_id) REFERENCES careers(id) ON DELETE CASCADE, - - -- 唯一约束:一个角色不能重复拥有同一个职业 - CONSTRAINT uk_character_career UNIQUE (character_id, career_id) -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_character_careers_character_id ON character_careers(character_id); -CREATE INDEX IF NOT EXISTS idx_character_careers_career_type ON character_careers(career_type); - --- 创建更新时间触发器 -CREATE OR REPLACE FUNCTION update_character_careers_updated_at() -RETURNS TRIGGER AS $$ -BEGIN - NEW.updated_at = CURRENT_TIMESTAMP; - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - -CREATE TRIGGER trigger_character_careers_updated_at - BEFORE UPDATE ON character_careers - FOR EACH ROW - EXECUTE FUNCTION update_character_careers_updated_at(); - --- 添加表注释 -COMMENT ON TABLE character_careers IS '角色职业关联表'; -COMMENT ON COLUMN character_careers.career_type IS 'main(主职业)/sub(副职业)'; -COMMENT ON COLUMN character_careers.current_stage IS '当前阶段(对应职业中的数值)'; -COMMENT ON COLUMN character_careers.stage_progress IS '阶段内进度(0-100)'; -COMMENT ON COLUMN character_careers.started_at IS '开始修炼时间(小说时间线)'; -COMMENT ON COLUMN character_careers.reached_current_stage_at IS '到达当前阶段时间'; -COMMENT ON COLUMN character_careers.notes IS '备注(如:修炼心得、特殊事件)'; - --- ===== 3. 扩展角色表(添加冗余字段,可选) ===== --- 注意:这部分是可选的,用于提升查询性能 --- 检查字段是否存在,如果不存在则添加 - -DO $$ -BEGIN - -- 添加 main_career_id 字段 - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='characters' AND column_name='main_career_id') THEN - ALTER TABLE characters ADD COLUMN main_career_id VARCHAR(36); - COMMENT ON COLUMN characters.main_career_id IS '主职业ID'; - END IF; - - -- 添加 main_career_stage 字段 - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='characters' AND column_name='main_career_stage') THEN - ALTER TABLE characters ADD COLUMN main_career_stage INT; - COMMENT ON COLUMN characters.main_career_stage IS '主职业当前阶段'; - END IF; - - -- 添加 sub_careers 字段 - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='characters' AND column_name='sub_careers') THEN - ALTER TABLE characters ADD COLUMN sub_careers TEXT; - COMMENT ON COLUMN characters.sub_careers IS '副职业列表(JSON): [{"career_id": "xxx", "stage": 3}, ...]'; - END IF; -END $$; - --- 添加外键约束(如果需要) -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints - WHERE constraint_name='fk_main_career' AND table_name='characters') THEN - ALTER TABLE characters - ADD CONSTRAINT fk_main_career - FOREIGN KEY (main_career_id) REFERENCES careers(id) ON DELETE SET NULL; - END IF; -END $$; - --- ===== 4. 创建视图(可选,便于查询) ===== -CREATE OR REPLACE VIEW v_character_career_details AS -SELECT - cc.id AS relation_id, - cc.character_id, - c.name AS character_name, - cc.career_id, - ca.name AS career_name, - ca.type AS career_type_name, - cc.career_type, - cc.current_stage, - ca.max_stage, - cc.stage_progress, - cc.started_at, - cc.reached_current_stage_at, - cc.notes, - ca.description AS career_description, - ca.category AS career_category, - ca.stages AS career_stages_json, - cc.created_at, - cc.updated_at -FROM character_careers cc -JOIN characters c ON cc.character_id = c.id -JOIN careers ca ON cc.career_id = ca.id -ORDER BY cc.career_type DESC, cc.created_at; - -COMMENT ON VIEW v_character_career_details IS '角色职业详细信息视图'; - --- ===== 完成提示 ===== -DO $$ -BEGIN - RAISE NOTICE '职业体系数据库表创建完成!'; - RAISE NOTICE '职业表记录数: %', (SELECT COUNT(*) FROM careers); - RAISE NOTICE '角色职业关联表记录数: %', (SELECT COUNT(*) FROM character_careers); -END $$; \ No newline at end of file diff --git a/backend/scripts/create_regeneration_tables.sql b/backend/scripts/create_regeneration_tables.sql deleted file mode 100644 index 559c910..0000000 --- a/backend/scripts/create_regeneration_tables.sql +++ /dev/null @@ -1,73 +0,0 @@ --- 创建章节重新生成任务表 --- 用于支持根据AI分析建议重新生成章节内容的功能 - --- 创建重新生成任务表 -CREATE TABLE IF NOT EXISTS regeneration_tasks ( - id VARCHAR(36) PRIMARY KEY, - chapter_id VARCHAR(36) NOT NULL, - analysis_id VARCHAR(36), - user_id VARCHAR(100) NOT NULL, - project_id VARCHAR(36) NOT NULL, - - -- 修改指令 - modification_instructions TEXT NOT NULL, - original_suggestions JSON, - selected_suggestion_indices JSON, - custom_instructions TEXT, - - -- 生成配置 - style_id INTEGER, - target_word_count INTEGER DEFAULT 3000, - focus_areas JSON, - preserve_elements JSON, - - -- 任务状态 - status VARCHAR(20) DEFAULT 'pending', - progress INTEGER DEFAULT 0, - error_message TEXT, - - -- 内容数据 - original_content TEXT, - original_word_count INTEGER, - regenerated_content TEXT, - regenerated_word_count INTEGER, - - -- 版本信息 - version_number INTEGER DEFAULT 1, - version_note TEXT, - - -- 时间戳 - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - started_at TIMESTAMP, - completed_at TIMESTAMP, - - -- 外键约束 - CONSTRAINT fk_regeneration_chapter FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE, - CONSTRAINT fk_regeneration_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, - CONSTRAINT fk_regeneration_analysis FOREIGN KEY (analysis_id) REFERENCES analysis_tasks(id) ON DELETE SET NULL, - CONSTRAINT fk_regeneration_style FOREIGN KEY (style_id) REFERENCES writing_styles(id) ON DELETE SET NULL -); - --- 创建索引以提升查询性能 -CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_chapter ON regeneration_tasks(chapter_id); -CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_project ON regeneration_tasks(project_id); -CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_user ON regeneration_tasks(user_id); -CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_status ON regeneration_tasks(status); -CREATE INDEX IF NOT EXISTS idx_regeneration_tasks_created ON regeneration_tasks(created_at DESC); - --- 添加注释 -COMMENT ON TABLE regeneration_tasks IS '章节重新生成任务表,记录每次根据AI建议重新生成章节的任务'; - -COMMENT ON COLUMN regeneration_tasks.modification_instructions IS '合并后的完整修改指令'; -COMMENT ON COLUMN regeneration_tasks.original_suggestions IS '原始AI分析建议列表'; -COMMENT ON COLUMN regeneration_tasks.selected_suggestion_indices IS '用户选择的建议索引'; -COMMENT ON COLUMN regeneration_tasks.preserve_elements IS '需要保留的元素配置(JSON)'; -COMMENT ON COLUMN regeneration_tasks.focus_areas IS '重点优化方向列表(JSON)'; - --- 修复外键约束(合并自 fix_all_missing_columns.sql) --- 删除可能存在问题的外键约束 -ALTER TABLE regeneration_tasks -DROP CONSTRAINT IF EXISTS fk_regeneration_analysis; - --- 完成提示 -SELECT '✅ 重新生成任务表创建完成,外键约束已修复' AS status; diff --git a/backend/scripts/entrypoint.sh b/backend/scripts/entrypoint.sh new file mode 100644 index 0000000..6d80f8a --- /dev/null +++ b/backend/scripts/entrypoint.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# Docker 容器启动入口脚本 +# 功能:等待数据库就绪,执行迁移,启动应用 + +set -e # 遇到错误立即退出 + +echo "================================================" +echo "🚀 MuMuAINovel 启动中..." +echo "================================================" + +# 数据库配置(从环境变量读取) +DB_HOST="${DB_HOST:-postgres}" +DB_PORT="${DB_PORT:-5432}" +DB_USER="${POSTGRES_USER:-mumuai}" +DB_NAME="${POSTGRES_DB:-mumuai_novel}" + +# 等待数据库就绪 +echo "⏳ 等待数据库启动..." +MAX_RETRIES=30 +RETRY_COUNT=0 + +while ! nc -z "$DB_HOST" "$DB_PORT" 2>/dev/null; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "❌ 错误: 数据库连接超时(${MAX_RETRIES}秒)" + exit 1 + fi + echo " 等待数据库... ($RETRY_COUNT/$MAX_RETRIES)" + sleep 1 +done + +echo "✅ 数据库连接成功" + +# 额外等待,确保数据库完全就绪 +echo "⏳ 等待数据库完全就绪..." +sleep 3 + +# 检查数据库是否可以接受连接 +echo "🔍 检查数据库状态..." +if ! PGPASSWORD="${POSTGRES_PASSWORD}" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" > /dev/null 2>&1; then + echo "❌ 数据库尚未就绪,继续等待..." + sleep 5 +fi + +echo "✅ 数据库已就绪" + +# 运行数据库迁移 +echo "================================================" +echo "🔄 执行数据库迁移..." +echo "================================================" + +cd /app + +# 统一使用 alembic upgrade head +# Alembic 会自动处理首次部署和增量迁移 +echo "🔄 升级数据库到最新版本..." +alembic upgrade head + +if [ $? -eq 0 ]; then + echo "✅ 数据库迁移成功" +else + echo "❌ 数据库迁移失败" + exit 1 +fi + +echo "================================================" +echo "🎉 启动应用服务..." +echo "================================================" + +# 启动应用(使用 exec 替换当前进程,确保信号正确传递) +cd /app +exec uvicorn app.main:app \ + --host "${APP_HOST:-0.0.0.0}" \ + --port "${APP_PORT:-8000}" \ + --log-level info \ + --access-log \ + --use-colors \ No newline at end of file diff --git a/backend/scripts/fix_user_id_length.sql b/backend/scripts/fix_user_id_length.sql deleted file mode 100644 index 22ff19a..0000000 --- a/backend/scripts/fix_user_id_length.sql +++ /dev/null @@ -1,9 +0,0 @@ --- 修复 projects 表中 user_id 字段长度不足的问题 --- 将 user_id 从 VARCHAR(36) 扩展到 VARCHAR(100) - -ALTER TABLE projects ALTER COLUMN user_id TYPE VARCHAR(100); - --- 验证修改 -SELECT column_name, data_type, character_maximum_length -FROM information_schema.columns -WHERE table_name = 'projects' AND column_name = 'user_id'; \ No newline at end of file diff --git a/backend/scripts/migrate.py b/backend/scripts/migrate.py new file mode 100644 index 0000000..98f4d07 --- /dev/null +++ b/backend/scripts/migrate.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +数据库自动迁移脚本 +用于开发和生产环境的数据库迁移管理 +""" +import subprocess +import sys +import os +from pathlib import Path + +# 添加项目路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from app.logger import get_logger + +logger = get_logger(__name__) + + +def run_command(cmd: list, description: str) -> bool: + """运行命令并返回是否成功""" + try: + logger.info(f"🚀 {description}...") + result = subprocess.run( + cmd, + cwd=project_root, + capture_output=True, + text=True, + check=False + ) + + if result.returncode == 0: + logger.info(f"✅ {description}成功") + if result.stdout: + print(result.stdout) + return True + else: + logger.error(f"❌ {description}失败") + if result.stderr: + print(result.stderr, file=sys.stderr) + return False + except Exception as e: + logger.error(f"❌ {description}异常: {e}") + return False + + +def create_migration(message: str = None): + """创建新的迁移版本""" + if not message: + message = input("请输入迁移描述: ").strip() + if not message: + message = "auto_migration" + + cmd = ["alembic", "revision", "--autogenerate", "-m", message] + return run_command(cmd, f"生成迁移: {message}") + + +def upgrade_database(revision: str = "head"): + """升级数据库到指定版本""" + cmd = ["alembic", "upgrade", revision] + return run_command(cmd, f"升级数据库到: {revision}") + + +def downgrade_database(revision: str = "-1"): + """降级数据库到指定版本""" + cmd = ["alembic", "downgrade", revision] + return run_command(cmd, f"降级数据库到: {revision}") + + +def show_current(): + """显示当前数据库版本""" + cmd = ["alembic", "current"] + return run_command(cmd, "查看当前版本") + + +def show_history(): + """显示迁移历史""" + cmd = ["alembic", "history", "--verbose"] + return run_command(cmd, "查看迁移历史") + + +def show_heads(): + """显示最新版本""" + cmd = ["alembic", "heads"] + return run_command(cmd, "查看最新版本") + + +def stamp_database(revision: str = "head"): + """标记数据库版本(不执行迁移)""" + cmd = ["alembic", "stamp", revision] + return run_command(cmd, f"标记数据库版本: {revision}") + + +def auto_migrate(): + """自动迁移:生成并执行迁移""" + logger.info("=" * 60) + logger.info("🔄 开始自动迁移流程") + logger.info("=" * 60) + + # 1. 创建迁移 + if not create_migration("auto_migration"): + logger.error("❌ 自动迁移失败:无法生成迁移") + return False + + # 2. 执行迁移 + if not upgrade_database(): + logger.error("❌ 自动迁移失败:无法执行迁移") + return False + + logger.info("=" * 60) + logger.info("✅ 自动迁移完成") + logger.info("=" * 60) + return True + + +def init_database(): + """初始化数据库(首次部署)""" + logger.info("=" * 60) + logger.info("🔧 初始化数据库") + logger.info("=" * 60) + + # 创建初始迁移 + if not create_migration("initial_migration"): + logger.warning("⚠️ 无法创建初始迁移,可能已存在") + + # 执行迁移 + if not upgrade_database(): + logger.error("❌ 初始化失败") + return False + + logger.info("=" * 60) + logger.info("✅ 数据库初始化完成") + logger.info("=" * 60) + return True + + +def main(): + """主函数""" + if len(sys.argv) < 2: + print("使用方法:") + print(" python migrate.py create [message] - 创建新迁移") + print(" python migrate.py upgrade [revision] - 升级数据库(默认: head)") + print(" python migrate.py downgrade [revision] - 降级数据库(默认: -1)") + print(" python migrate.py current - 显示当前版本") + print(" python migrate.py history - 显示迁移历史") + print(" python migrate.py heads - 显示最新版本") + print(" python migrate.py stamp [revision] - 标记版本(默认: head)") + print(" python migrate.py auto - 自动迁移(生成+执行)") + print(" python migrate.py init - 初始化数据库") + sys.exit(1) + + command = sys.argv[1] + + if command == "create": + message = sys.argv[2] if len(sys.argv) > 2 else None + success = create_migration(message) + elif command == "upgrade": + revision = sys.argv[2] if len(sys.argv) > 2 else "head" + success = upgrade_database(revision) + elif command == "downgrade": + revision = sys.argv[2] if len(sys.argv) > 2 else "-1" + success = downgrade_database(revision) + elif command == "current": + success = show_current() + elif command == "history": + success = show_history() + elif command == "heads": + success = show_heads() + elif command == "stamp": + revision = sys.argv[2] if len(sys.argv) > 2 else "head" + success = stamp_database(revision) + elif command == "auto": + success = auto_migrate() + elif command == "init": + success = init_database() + else: + logger.error(f"❌ 未知命令: {command}") + success = False + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/scripts/migrate_sqlite_to_postgres.py b/backend/scripts/migrate_sqlite_to_postgres.py deleted file mode 100644 index ddc5edc..0000000 --- a/backend/scripts/migrate_sqlite_to_postgres.py +++ /dev/null @@ -1,859 +0,0 @@ -#!/usr/bin/env python3 -""" -SQLite to PostgreSQL 数据迁移脚本 - -使用方法: - python backend/scripts/migrate_sqlite_to_postgres.py - -前置条件: - 1. PostgreSQL数据库已创建 - 2. .env文件中DATABASE_URL已配置为PostgreSQL - 3. SQLite数据文件存在于 backend/data/ 目录 -""" -import asyncio -import sys -from pathlib import Path -from typing import List, Dict, Any -import logging -from datetime import datetime - -# 添加项目根目录到Python路径 -sys.path.insert(0, str(Path(__file__).parent.parent)) - -from sqlalchemy import create_engine, text, select -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from app.database import Base -from app.models import ( - Project, Outline, Character, Chapter, GenerationHistory, - Settings, WritingStyle, ProjectDefaultStyle, - RelationshipType, CharacterRelationship, Organization, OrganizationMember, - StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask, - MCPPlugin -) -from app.config import settings - -# 创建日志目录 -log_dir = Path(__file__).parent.parent / "logs" -log_dir.mkdir(exist_ok=True) - -# 生成日志文件名(带时间戳) -log_filename = log_dir / f"migration_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" - -# 设置日志 - 同时输出到控制台和文件 -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.StreamHandler(), # 控制台输出 - logging.FileHandler(log_filename, encoding='utf-8') # 文件输出 - ] -) -logger = logging.getLogger(__name__) -logger.info(f"📝 日志文件: {log_filename}") - - -class SQLiteToPostgresMigrator: - """SQLite到PostgreSQL的数据迁移器""" - - def __init__(self, sqlite_dir: Path, target_user_id: str): - """ - 初始化迁移器 - - Args: - sqlite_dir: SQLite数据库文件目录 - target_user_id: 目标用户ID(迁移后的数据归属) - """ - self.sqlite_dir = sqlite_dir - self.target_user_id = target_user_id - self.sqlite_files = list(sqlite_dir.glob("ai_story_user_*.db")) - - # PostgreSQL连接 - if "postgresql" not in settings.database_url: - raise ValueError("DATABASE_URL必须配置为PostgreSQL") - - self.pg_engine = create_async_engine( - settings.database_url, - echo=False, - pool_pre_ping=True - ) - - self.pg_session_maker = async_sessionmaker( - self.pg_engine, - class_=AsyncSession, - expire_on_commit=False - ) - - async def migrate_all(self): - """迁移所有SQLite数据库""" - if not self.sqlite_files: - logger.warning(f"未找到SQLite数据库文件: {self.sqlite_dir}") - return - - logger.info(f"找到 {len(self.sqlite_files)} 个SQLite数据库文件") - - # 创建PostgreSQL表结构 - await self._create_tables() - - # 初始化关系类型数据 - await self._init_relationship_types() - - # 逐个迁移 - for sqlite_file in self.sqlite_files: - await self._migrate_single_db(sqlite_file) - - # 重置自增序列 - await self._reset_sequences() - - logger.info("✅ 所有数据迁移完成") - - async def _create_tables(self): - """创建PostgreSQL表结构""" - logger.info("创建PostgreSQL表结构...") - async with self.pg_engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - logger.info("✅ 表结构创建完成") - - async def _init_relationship_types(self): - """初始化关系类型数据""" - logger.info("初始化关系类型数据...") - - # 预置关系类型数据 - relationship_types = [ - # 家族关系 - {"name": "父亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👨"}, - {"name": "母亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👩"}, - {"name": "兄弟", "category": "family", "reverse_name": "兄弟", "intimacy_range": "high", "icon": "👬"}, - {"name": "姐妹", "category": "family", "reverse_name": "姐妹", "intimacy_range": "high", "icon": "👭"}, - {"name": "子女", "category": "family", "reverse_name": "父母", "intimacy_range": "high", "icon": "👶"}, - {"name": "配偶", "category": "family", "reverse_name": "配偶", "intimacy_range": "high", "icon": "💑"}, - {"name": "恋人", "category": "family", "reverse_name": "恋人", "intimacy_range": "high", "icon": "💕"}, - - # 社交关系 - {"name": "师父", "category": "social", "reverse_name": "徒弟", "intimacy_range": "high", "icon": "🎓"}, - {"name": "徒弟", "category": "social", "reverse_name": "师父", "intimacy_range": "high", "icon": "📚"}, - {"name": "朋友", "category": "social", "reverse_name": "朋友", "intimacy_range": "medium", "icon": "🤝"}, - {"name": "同学", "category": "social", "reverse_name": "同学", "intimacy_range": "medium", "icon": "🎒"}, - {"name": "邻居", "category": "social", "reverse_name": "邻居", "intimacy_range": "low", "icon": "🏘️"}, - {"name": "知己", "category": "social", "reverse_name": "知己", "intimacy_range": "high", "icon": "💙"}, - - # 职业关系 - {"name": "上司", "category": "professional", "reverse_name": "下属", "intimacy_range": "low", "icon": "👔"}, - {"name": "下属", "category": "professional", "reverse_name": "上司", "intimacy_range": "low", "icon": "💼"}, - {"name": "同事", "category": "professional", "reverse_name": "同事", "intimacy_range": "medium", "icon": "🤵"}, - {"name": "合作伙伴", "category": "professional", "reverse_name": "合作伙伴", "intimacy_range": "medium", "icon": "🤜🤛"}, - - # 敌对关系 - {"name": "敌人", "category": "hostile", "reverse_name": "敌人", "intimacy_range": "low", "icon": "⚔️"}, - {"name": "仇人", "category": "hostile", "reverse_name": "仇人", "intimacy_range": "low", "icon": "💢"}, - {"name": "竞争对手", "category": "hostile", "reverse_name": "竞争对手", "intimacy_range": "low", "icon": "🎯"}, - {"name": "宿敌", "category": "hostile", "reverse_name": "宿敌", "intimacy_range": "low", "icon": "⚡"}, - ] - - try: - async with self.pg_session_maker() as session: - # 检查是否已经有数据 - result = await session.execute(select(RelationshipType)) - existing = result.scalars().first() - - if existing: - logger.info("关系类型数据已存在,跳过初始化") - return - - # 插入预置数据 - logger.info("开始插入关系类型数据...") - for rt_data in relationship_types: - relationship_type = RelationshipType(**rt_data) - session.add(relationship_type) - - await session.commit() - logger.info(f"✅ 成功插入 {len(relationship_types)} 条关系类型数据") - - except Exception as e: - logger.error(f"初始化关系类型数据失败: {str(e)}", exc_info=True) - # 不抛出异常,继续迁移流程 - logger.warning("关系类型初始化失败,将跳过有外键依赖的记录") - - async def _migrate_single_db(self, sqlite_file: Path): - """迁移单个SQLite数据库""" - # 从文件名提取user_id - filename = sqlite_file.stem # ai_story_user_xxx - if filename.startswith("ai_story_user_"): - user_id = filename.replace("ai_story_user_", "") - else: - user_id = self.target_user_id - - logger.info(f"\n{'='*60}") - logger.info(f"开始迁移: {sqlite_file.name} -> user_id: {user_id}") - logger.info(f"{'='*60}") - - # 创建SQLite连接 - sqlite_url = f"sqlite+aiosqlite:///{sqlite_file.absolute()}" - sqlite_engine = create_async_engine(sqlite_url, echo=False) - sqlite_session_maker = async_sessionmaker( - sqlite_engine, - class_=AsyncSession, - expire_on_commit=False - ) - - try: - # 迁移各个表 - async with sqlite_session_maker() as sqlite_session: - async with self.pg_session_maker() as pg_session: - # 按照依赖顺序迁移 - await self._migrate_table( - sqlite_session, pg_session, user_id, Settings, "设置" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, Project, "项目" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, Character, "角色" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, Outline, "大纲" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, Chapter, "章节" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, CharacterRelationship, "角色关系" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, Organization, "组织" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, OrganizationMember, "组织成员" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, GenerationHistory, "生成历史" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, WritingStyle, "写作风格" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, ProjectDefaultStyle, "项目默认风格" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, StoryMemory, "记忆" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, PlotAnalysis, "剧情分析" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, AnalysisTask, "分析任务" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, BatchGenerationTask, "批量生成任务" - ) - await self._migrate_table( - sqlite_session, pg_session, user_id, MCPPlugin, "MCP插件" - ) - - await pg_session.commit() - - logger.info(f"✅ {sqlite_file.name} 迁移完成") - - except Exception as e: - logger.error(f"❌ 迁移失败: {e}", exc_info=True) - finally: - await sqlite_engine.dispose() - - async def _migrate_table( - self, - sqlite_session: AsyncSession, - pg_session: AsyncSession, - user_id: str, - model_class, - table_name: str - ): - """迁移单个表的数据""" - try: - # 获取SQLite表中实际存在的列 - sqlite_table = model_class.__table__ - sqlite_conn = await sqlite_session.connection() - - # 查询SQLite表结构 - inspect_result = await sqlite_conn.execute( - text(f"PRAGMA table_info({sqlite_table.name})") - ) - sqlite_columns = {row[1] for row in inspect_result.fetchall()} # row[1]是列名 - - # 构建只包含SQLite中存在的列的查询 - available_columns = [ - c for c in model_class.__table__.columns - if c.name in sqlite_columns - ] - - if not available_columns: - logger.warning(f" ⚠️ {table_name}: 表结构不匹配,跳过") - return - - # 从SQLite读取数据(只查询存在的列) - result = await sqlite_session.execute( - select(*available_columns) - ) - records = result.all() - - if not records: - logger.info(f" - {table_name}: 无数据") - return - - # 为每条记录创建字典并添加user_id - migrated_count = 0 - skipped_count = 0 - - for record in records: - # 从查询结果构建字典 - record_dict = {} - for i, col in enumerate(available_columns): - record_dict[col.name] = record[i] - - # 添加user_id(如果PostgreSQL模型有这个字段但SQLite没有) - if hasattr(model_class, 'user_id') and 'user_id' not in record_dict: - record_dict['user_id'] = user_id - - # 验证字段长度(防止超长字段导致插入失败) - if not self._validate_field_lengths(model_class, record_dict, table_name): - skipped_count += 1 - record_id = record_dict.get('id', 'unknown') - logger.warning(f" ⚠️ [{table_name}] 跳过超长字段记录 ID={record_id}") - continue - - # 验证外键引用(针对有外键的表) - validation_result = await self._validate_foreign_keys(pg_session, model_class, record_dict) - if not validation_result: - skipped_count += 1 - record_id = record_dict.get('id', 'unknown') - logger.warning(f" ⚠️ [{table_name}] 跳过无效外键记录 ID={record_id}") - # 输出记录详情以便调试 - if model_class.__tablename__ == 'story_memories': - logger.warning(f" 记忆详情: project_id={record_dict.get('project_id')}, " - f"chapter_id={record_dict.get('chapter_id')}, " - f"type={record_dict.get('memory_type')}") - elif model_class.__tablename__ == 'character_relationships': - logger.warning(f" 关系详情: project_id={record_dict.get('project_id')}, " - f"from={record_dict.get('character_from_id')}, " - f"to={record_dict.get('character_to_id')}, " - f"type_id={record_dict.get('relationship_type_id')}") - elif model_class.__tablename__ == 'organizations': - logger.warning(f" 组织详情: project_id={record_dict.get('project_id')}, " - f"character_id={record_dict.get('character_id')}") - elif model_class.__tablename__ == 'organization_members': - logger.warning(f" 成员详情: org_id={record_dict.get('organization_id')}, " - f"character_id={record_dict.get('character_id')}") - elif model_class.__tablename__ == 'writing_styles': - logger.warning(f" 写作风格详情: project_id={record_dict.get('project_id')}, " - f"name={record_dict.get('name')}, " - f"style_type={record_dict.get('style_type')}") - elif model_class.__tablename__ == 'characters': - logger.warning(f" 角色详情: project_id={record_dict.get('project_id')}, " - f"name={record_dict.get('name')}, " - f"is_organization={record_dict.get('is_organization')}") - elif model_class.__tablename__ == 'outlines': - logger.warning(f" 大纲详情: project_id={record_dict.get('project_id')}, " - f"title={record_dict.get('title')}") - elif model_class.__tablename__ == 'chapters': - logger.warning(f" 章节详情: project_id={record_dict.get('project_id')}, " - f"title={record_dict.get('title')}, " - f"chapter_number={record_dict.get('chapter_number')}") - elif model_class.__tablename__ == 'generation_history': - logger.warning(f" 生成历史详情: project_id={record_dict.get('project_id')}, " - f"chapter_id={record_dict.get('chapter_id')}, " - f"model={record_dict.get('model')}") - elif model_class.__tablename__ == 'plot_analysis': - logger.warning(f" 剧情分析详情: project_id={record_dict.get('project_id')}, " - f"chapter_id={record_dict.get('chapter_id')}, " - f"plot_stage={record_dict.get('plot_stage')}") - elif model_class.__tablename__ == 'analysis_tasks': - logger.warning(f" 分析任务详情: chapter_id={record_dict.get('chapter_id')}, " - f"project_id={record_dict.get('project_id')}, " - f"status={record_dict.get('status')}") - elif model_class.__tablename__ == 'batch_generation_tasks': - logger.warning(f" 批量生成任务详情: project_id={record_dict.get('project_id')}, " - f"status={record_dict.get('status')}, " - f"completed={record_dict.get('completed_chapters')}/{record_dict.get('total_chapters')}") - elif model_class.__tablename__ == 'project_default_styles': - logger.warning(f" 项目默认风格详情: project_id={record_dict.get('project_id')}, " - f"style_id={record_dict.get('style_id')}") - continue - - # 检查记录是否已存在(避免主键冲突) - record_id = record_dict.get('id') - if record_id and await self._record_exists(pg_session, model_class, record_id): - skipped_count += 1 - logger.debug(f" 跳过已存在的记录: {record_id}") - continue - - # 创建新记录 - try: - new_record = model_class(**record_dict) - pg_session.add(new_record) - migrated_count += 1 - except Exception as e: - logger.warning(f" ⚠️ 跳过无效记录: {str(e)[:100]}") - skipped_count += 1 - continue - - await pg_session.flush() - - if skipped_count > 0: - logger.info(f" ✅ {table_name}: {migrated_count} 条记录(跳过 {skipped_count} 条无效记录)") - else: - logger.info(f" ✅ {table_name}: {migrated_count} 条记录") - - except Exception as e: - logger.error(f" ❌ {table_name} 迁移失败: {e}") - raise - - async def _record_exists( - self, - pg_session: AsyncSession, - model_class, - record_id: Any - ) -> bool: - """ - 检查记录是否已存在 - - Args: - pg_session: PostgreSQL会话 - model_class: 模型类 - record_id: 记录ID - - Returns: - bool: 记录是否存在 - """ - try: - # 获取主键列 - pk_column = list(model_class.__table__.primary_key.columns)[0] - result = await pg_session.execute( - select(pk_column).where(pk_column == record_id) - ) - return result.scalar_one_or_none() is not None - except Exception: - return False - - async def _validate_foreign_keys( - self, - pg_session: AsyncSession, - model_class, - record_dict: Dict[str, Any] - ) -> bool: - """ - 验证记录的外键是否有效 - - Args: - pg_session: PostgreSQL会话 - model_class: 模型类 - record_dict: 记录字典 - - Returns: - bool: 外键是否全部有效 - """ - from app.models import Character, Project, Chapter - - # 使用no_autoflush防止过早flush - with pg_session.no_autoflush: - # 针对StoryMemory表验证外键 - if model_class.__tablename__ == 'story_memories': - # 验证project_id - project_id = record_dict.get('project_id') - if project_id: - result = await pg_session.execute( - select(Project.id).where(Project.id == project_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [记忆] 无效的project_id: {project_id}") - return False - - # 验证chapter_id(可选) - chapter_id = record_dict.get('chapter_id') - if chapter_id: - result = await pg_session.execute( - select(Chapter.id).where(Chapter.id == chapter_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [记忆] 无效的chapter_id: {chapter_id}") - return False - - # 针对CharacterRelationship表验证外键 - elif model_class.__tablename__ == 'character_relationships': - # 验证project_id - project_id = record_dict.get('project_id') - if project_id: - result = await pg_session.execute( - select(Project.id).where(Project.id == project_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ 无效的project_id: {project_id}") - return False - - # 验证character_from_id - char_from_id = record_dict.get('character_from_id') - if char_from_id: - result = await pg_session.execute( - select(Character.id).where(Character.id == char_from_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ 无效的character_from_id: {char_from_id}") - return False - - # 验证character_to_id - char_to_id = record_dict.get('character_to_id') - if char_to_id: - result = await pg_session.execute( - select(Character.id).where(Character.id == char_to_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ 无效的character_to_id: {char_to_id}") - return False - - # 验证relationship_type_id - rel_type_id = record_dict.get('relationship_type_id') - if rel_type_id: - result = await pg_session.execute( - select(RelationshipType.id).where(RelationshipType.id == rel_type_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ 无效的relationship_type_id: {rel_type_id}") - return False - - # 针对Organization表验证外键 - elif model_class.__tablename__ == 'organizations': - # 验证character_id - char_id = record_dict.get('character_id') - if char_id: - result = await pg_session.execute( - select(Character.id).where(Character.id == char_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [组织] 无效的character_id: {char_id}") - return False - - # 针对OrganizationMember表验证外键 - elif model_class.__tablename__ == 'organization_members': - from app.models import Organization - - # 验证organization_id - org_id = record_dict.get('organization_id') - if org_id: - result = await pg_session.execute( - select(Organization.id).where(Organization.id == org_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ 无效的organization_id: {org_id}") - return False - - # 验证character_id - char_id = record_dict.get('character_id') - if char_id: - result = await pg_session.execute( - select(Character.id).where(Character.id == char_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [组织成员] 无效的character_id: {char_id}") - return False - - # 针对Character表验证外键 - elif model_class.__tablename__ == 'characters': - # 验证project_id - project_id = record_dict.get('project_id') - if project_id: - result = await pg_session.execute( - select(Project.id).where(Project.id == project_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [角色] 无效的project_id: {project_id}") - return False - - # 针对Outline表验证外键 - elif model_class.__tablename__ == 'outlines': - # 验证project_id - project_id = record_dict.get('project_id') - if project_id: - result = await pg_session.execute( - select(Project.id).where(Project.id == project_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [大纲] 无效的project_id: {project_id}") - return False - - # 针对Chapter表验证外键 - elif model_class.__tablename__ == 'chapters': - # 验证project_id - project_id = record_dict.get('project_id') - if project_id: - result = await pg_session.execute( - select(Project.id).where(Project.id == project_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [章节] 无效的project_id: {project_id}") - return False - - # 针对WritingStyle表验证外键 - elif model_class.__tablename__ == 'writing_styles': - # 验证project_id(可选) - project_id = record_dict.get('project_id') - if project_id: - result = await pg_session.execute( - select(Project.id).where(Project.id == project_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [写作风格] 无效的project_id: {project_id}") - return False - - # 针对GenerationHistory表验证外键 - elif model_class.__tablename__ == 'generation_history': - # 验证project_id - project_id = record_dict.get('project_id') - if project_id: - result = await pg_session.execute( - select(Project.id).where(Project.id == project_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [生成历史] 无效的project_id: {project_id}") - return False - - # 验证chapter_id(可选) - chapter_id = record_dict.get('chapter_id') - if chapter_id: - result = await pg_session.execute( - select(Chapter.id).where(Chapter.id == chapter_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [生成历史] 无效的chapter_id: {chapter_id}") - return False - - # 针对PlotAnalysis表验证外键 - elif model_class.__tablename__ == 'plot_analysis': - # 验证project_id(必需) - project_id = record_dict.get('project_id') - if project_id: - result = await pg_session.execute( - select(Project.id).where(Project.id == project_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [剧情分析] 无效的project_id: {project_id}") - return False - - # 验证chapter_id(必需) - chapter_id = record_dict.get('chapter_id') - if chapter_id: - result = await pg_session.execute( - select(Chapter.id).where(Chapter.id == chapter_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [剧情分析] 无效的chapter_id: {chapter_id}") - return False - - # 针对AnalysisTask表验证外键 - elif model_class.__tablename__ == 'analysis_tasks': - # 验证chapter_id(必需) - chapter_id = record_dict.get('chapter_id') - if chapter_id: - result = await pg_session.execute( - select(Chapter.id).where(Chapter.id == chapter_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [分析任务] 无效的chapter_id: {chapter_id}") - return False - - # 验证project_id - project_id = record_dict.get('project_id') - if project_id: - result = await pg_session.execute( - select(Project.id).where(Project.id == project_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [分析任务] 无效的project_id: {project_id}") - return False - - # 针对BatchGenerationTask表验证外键 - elif model_class.__tablename__ == 'batch_generation_tasks': - # 验证project_id(必需) - project_id = record_dict.get('project_id') - if project_id: - result = await pg_session.execute( - select(Project.id).where(Project.id == project_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [批量生成任务] 无效的project_id: {project_id}") - return False - - # 针对ProjectDefaultStyle表验证外键 - elif model_class.__tablename__ == 'project_default_styles': - from app.models import WritingStyle - - # 验证project_id(必需) - project_id = record_dict.get('project_id') - if project_id: - result = await pg_session.execute( - select(Project.id).where(Project.id == project_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [项目默认风格] 无效的project_id: {project_id}") - return False - - # 验证style_id(必需) - style_id = record_dict.get('style_id') - if style_id: - result = await pg_session.execute( - select(WritingStyle.id).where(WritingStyle.id == style_id) - ) - if not result.scalar_one_or_none(): - logger.warning(f" ❌ [项目默认风格] 无效的style_id: {style_id}") - return False - - return True - - def _validate_field_lengths( - self, - model_class, - record_dict: Dict[str, Any], - table_name: str - ) -> bool: - """ - 验证记录的字段长度是否符合模型定义 - - Args: - model_class: 模型类 - record_dict: 记录字典 - table_name: 表名(用于日志) - - Returns: - bool: 字段长度是否全部有效 - """ - from sqlalchemy import String - - # 检查所有字符串类型字段 - for column in model_class.__table__.columns: - # 只检查有长度限制的String类型字段 - if isinstance(column.type, String) and column.type.length: - field_name = column.name - field_value = record_dict.get(field_name) - max_length = column.type.length - - # 如果字段有值且超过最大长度 - if field_value and isinstance(field_value, str) and len(field_value) > max_length: - logger.warning( - f" ❌ [{table_name}] 字段 '{field_name}' 超长: " - f"{len(field_value)} > {max_length} (截断了 {len(field_value) - max_length} 字符)" - ) - # 对于敏感字段如API密钥,记录部分内容 - if field_name in ['api_key', 'api_base_url']: - preview = field_value[:50] + "..." + field_value[-20:] if len(field_value) > 70 else field_value - logger.warning(f" 值预览: {preview}") - return False - - return True - - async def _reset_sequences(self): - """重置PostgreSQL的自增序列到正确的值""" - logger.info("\n" + "="*60) - logger.info("重置自增序列...") - logger.info("="*60) - - # 需要重置序列的表(使用Integer自增主键的表) - tables_with_sequences = [ - ('relationship_types', 'id'), - ('writing_styles', 'id'), - ('project_default_styles', 'id'), - ] - - async with self.pg_session_maker() as session: - for table_name, id_column in tables_with_sequences: - try: - # 获取表中当前最大ID - result = await session.execute( - text(f"SELECT MAX({id_column}) FROM {table_name}") - ) - max_id = result.scalar() - - if max_id is not None: - # 重置序列到 max_id + 1 - sequence_name = f"{table_name}_{id_column}_seq" - await session.execute( - text(f"SELECT setval('{sequence_name}', :max_id, true)"), - {"max_id": max_id} - ) - logger.info(f" ✅ {table_name}: 序列重置到 {max_id}") - else: - logger.info(f" - {table_name}: 表为空,跳过序列重置") - - except Exception as e: - logger.warning(f" ⚠️ {table_name}: 序列重置失败 - {str(e)}") - - await session.commit() - - logger.info("✅ 序列重置完成") - - async def cleanup(self): - """清理资源""" - await self.pg_engine.dispose() - - -async def main(): - """主函数""" - banner = """ -╔══════════════════════════════════════════════════════════════╗ -║ SQLite to PostgreSQL 数据迁移工具 ║ -║ ║ -║ 此工具将SQLite数据迁移到PostgreSQL ║ -║ 请确保: ║ -║ 1. PostgreSQL数据库已创建 ║ -║ 2. .env中DATABASE_URL已配置为PostgreSQL ║ -║ 3. SQLite数据文件存在 ║ -╚══════════════════════════════════════════════════════════════╝ - """ - print(banner) - logger.info(banner) - - # 配置 - sqlite_dir = Path(__file__).parent.parent / "data" - target_user_id = "migrated_user" # 默认用户ID - - config_info = f""" -配置信息: - SQLite目录: {sqlite_dir} - PostgreSQL: {settings.database_url} - 目标用户ID: {target_user_id} - 日志文件: {log_filename} -""" - print(config_info) - logger.info(config_info) - - # 确认 - response = input("是否继续迁移? (yes/no): ") - if response.lower() not in ['yes', 'y']: - print("已取消迁移") - return - - # 执行迁移 - migrator = SQLiteToPostgresMigrator(sqlite_dir, target_user_id) - - try: - await migrator.migrate_all() - success_msg = """ -🎉 数据迁移成功完成! - -下一步: - 1. 测试应用功能 - 2. 验证数据完整性 - 3. 备份SQLite文件后可删除 - -详细日志已保存到: {} - """.format(log_filename) - print(success_msg) - logger.info(success_msg) - - except Exception as e: - error_msg = f"\n❌ 迁移失败: {e}\n详细日志已保存到: {log_filename}" - print(error_msg) - logger.error("迁移过程出错", exc_info=True) - - finally: - await migrator.cleanup() - logger.info(f"🔒 数据库连接已关闭,日志文件: {log_filename}") - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/backend/scripts/migrate_users_to_db.py b/backend/scripts/migrate_users_to_db.py deleted file mode 100644 index 2658b4f..0000000 --- a/backend/scripts/migrate_users_to_db.py +++ /dev/null @@ -1,224 +0,0 @@ -""" -用户数据迁移脚本 - 从JSON文件迁移到数据库 -""" -import asyncio -import json -import os -import sys -from pathlib import Path - -# 添加项目根目录到 Python 路径 -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -from app.user_manager import user_manager -from app.user_password import password_manager -from app.config import DATA_DIR - - -async def migrate_users(): - """迁移用户数据""" - users_file = DATA_DIR / "users.json" - - if not users_file.exists(): - print("❌ 用户数据文件不存在,跳过迁移") - return 0 - - try: - with open(users_file, "r", encoding="utf-8") as f: - users_data = json.load(f) - - if not users_data: - print("ℹ️ 用户数据为空,跳过迁移") - return 0 - - migrated_count = 0 - for user_id, user_info in users_data.items(): - try: - # 迁移用户基本信息 - await user_manager.create_or_update_from_linuxdo( - linuxdo_id=user_info["linuxdo_id"], - username=user_info["username"], - display_name=user_info["display_name"], - avatar_url=user_info.get("avatar_url"), - trust_level=user_info.get("trust_level", 0) - ) - - # 如果用户是管理员,设置管理员权限 - if user_info.get("is_admin", False): - await user_manager.set_admin(user_id, True) - - migrated_count += 1 - print(f"✅ 迁移用户: {user_info['username']} ({user_id})") - - except Exception as e: - print(f"❌ 迁移用户 {user_id} 失败: {e}") - - print(f"\n✅ 用户数据迁移完成: {migrated_count}/{len(users_data)} 个用户") - - # 备份原文件 - backup_file = DATA_DIR / "users.json.backup" - os.rename(users_file, backup_file) - print(f"📦 原文件已备份到: {backup_file}") - - return migrated_count - - except Exception as e: - print(f"❌ 迁移用户数据失败: {e}") - return 0 - - -async def migrate_passwords(): - """迁移密码数据""" - passwords_file = DATA_DIR / "user_passwords.json" - - if not passwords_file.exists(): - print("❌ 密码数据文件不存在,跳过迁移") - return 0 - - try: - with open(passwords_file, "r", encoding="utf-8") as f: - passwords_data = json.load(f) - - if not passwords_data: - print("ℹ️ 密码数据为空,跳过迁移") - return 0 - - migrated_count = 0 - for user_id, pwd_info in passwords_data.items(): - try: - # 直接插入密码记录(已经是哈希值) - from app.models.user import UserPassword - from app.user_password import password_manager as pm - - async with await pm._get_session() as session: - from sqlalchemy import select - - # 检查是否已存在 - result = await session.execute( - select(UserPassword).where(UserPassword.user_id == user_id) - ) - existing = result.scalar_one_or_none() - - if existing: - print(f"ℹ️ 密码已存在,跳过: {pwd_info['username']} ({user_id})") - continue - - # 创建密码记录 - from datetime import datetime - pwd_record = UserPassword( - user_id=user_id, - username=pwd_info["username"], - password_hash=pwd_info["password_hash"], - has_custom_password=pwd_info.get("has_custom_password", False), - created_at=datetime.now(), - updated_at=datetime.now() - ) - session.add(pwd_record) - await session.commit() - - migrated_count += 1 - print(f"✅ 迁移密码: {pwd_info['username']} ({user_id})") - - except Exception as e: - print(f"❌ 迁移密码 {user_id} 失败: {e}") - - print(f"\n✅ 密码数据迁移完成: {migrated_count}/{len(passwords_data)} 个密码") - - # 备份原文件 - backup_file = DATA_DIR / "user_passwords.json.backup" - os.rename(passwords_file, backup_file) - print(f"📦 原文件已备份到: {backup_file}") - - return migrated_count - - except Exception as e: - print(f"❌ 迁移密码数据失败: {e}") - return 0 - - -async def migrate_admins(): - """迁移管理员列表""" - admins_file = DATA_DIR / "admins.json" - - if not admins_file.exists(): - print("❌ 管理员数据文件不存在,跳过迁移") - return 0 - - try: - with open(admins_file, "r", encoding="utf-8") as f: - admins_data = json.load(f) - - admin_list = admins_data.get("admins", []) - - if not admin_list: - print("ℹ️ 管理员列表为空,跳过迁移") - return 0 - - migrated_count = 0 - for user_id in admin_list: - try: - # 设置管理员权限 - success = await user_manager.set_admin(user_id, True) - if success: - migrated_count += 1 - print(f"✅ 设置管理员: {user_id}") - else: - print(f"⚠️ 用户不存在或已是管理员: {user_id}") - - except Exception as e: - print(f"❌ 设置管理员 {user_id} 失败: {e}") - - print(f"\n✅ 管理员数据迁移完成: {migrated_count}/{len(admin_list)} 个管理员") - - # 备份原文件 - backup_file = DATA_DIR / "admins.json.backup" - os.rename(admins_file, backup_file) - print(f"📦 原文件已备份到: {backup_file}") - - return migrated_count - - except Exception as e: - print(f"❌ 迁移管理员数据失败: {e}") - return 0 - - -async def main(): - """主函数""" - print("=" * 60) - print("用户数据迁移工具 - JSON 到数据库") - print("=" * 60) - print() - - # 迁移用户 - print("📋 步骤 1/3: 迁移用户数据") - print("-" * 60) - user_count = await migrate_users() - print() - - # 迁移密码 - print("📋 步骤 2/3: 迁移密码数据") - print("-" * 60) - pwd_count = await migrate_passwords() - print() - - # 迁移管理员 - print("📋 步骤 3/3: 迁移管理员数据") - print("-" * 60) - admin_count = await migrate_admins() - print() - - # 总结 - print("=" * 60) - print("迁移完成") - print("=" * 60) - print(f"✅ 用户: {user_count}") - print(f"✅ 密码: {pwd_count}") - print(f"✅ 管理员: {admin_count}") - print() - print("💡 提示: 原文件已备份为 .backup 后缀") - print("💡 如需回滚,请删除数据库文件并恢复 .backup 文件") - - -if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file diff --git a/backend/scripts/migrate_users_to_postgres.py b/backend/scripts/migrate_users_to_postgres.py deleted file mode 100644 index fc063ad..0000000 --- a/backend/scripts/migrate_users_to_postgres.py +++ /dev/null @@ -1,300 +0,0 @@ -""" -用户数据迁移脚本 - 从JSON文件迁移到PostgreSQL数据库 - -使用方法: - python migrate_users_to_postgres.py - python migrate_users_to_postgres.py --db-url postgresql+asyncpg://user:pass@localhost/dbname -""" -import asyncio -import json -import os -import sys -import argparse -from pathlib import Path -from datetime import datetime - -# 添加项目根目录到 Python 路径 -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -from sqlalchemy import select, text -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from app.config import settings, DATA_DIR - - -async def create_tables(engine): - """创建用户相关表""" - from app.database import Base - from app.models.user import User, UserPassword - - print("📋 创建数据库表...") - async with engine.begin() as conn: - # 只创建用户相关的表 - await conn.run_sync(User.metadata.create_all) - await conn.run_sync(UserPassword.metadata.create_all) - print("✅ 表创建成功") - - -async def migrate_users(session): - """迁移用户数据""" - from app.models.user import User as UserModel - - users_file = DATA_DIR / "users.json" - - if not users_file.exists(): - print("ℹ️ 用户数据文件不存在,跳过迁移") - return 0 - - try: - with open(users_file, "r", encoding="utf-8") as f: - users_data = json.load(f) - - if not users_data: - print("ℹ️ 用户数据为空,跳过迁移") - return 0 - - migrated_count = 0 - for user_id, user_info in users_data.items(): - try: - # 检查用户是否已存在 - result = await session.execute( - select(UserModel).where(UserModel.user_id == user_id) - ) - existing = result.scalar_one_or_none() - - if existing: - print(f"ℹ️ 用户已存在,跳过: {user_info['username']} ({user_id})") - continue - - # 创建用户记录 - user = UserModel( - user_id=user_id, - username=user_info["username"], - display_name=user_info["display_name"], - avatar_url=user_info.get("avatar_url"), - trust_level=user_info.get("trust_level", 0), - is_admin=user_info.get("is_admin", False), - linuxdo_id=user_info["linuxdo_id"], - created_at=datetime.fromisoformat(user_info.get("created_at", datetime.now().isoformat())), - last_login=datetime.fromisoformat(user_info.get("last_login", datetime.now().isoformat())) - ) - session.add(user) - - migrated_count += 1 - print(f"✅ 迁移用户: {user_info['username']} ({user_id})") - - except Exception as e: - print(f"❌ 迁移用户 {user_id} 失败: {e}") - - await session.commit() - print(f"\n✅ 用户数据迁移完成: {migrated_count}/{len(users_data)} 个用户") - - return migrated_count - - except Exception as e: - print(f"❌ 迁移用户数据失败: {e}") - await session.rollback() - return 0 - - -async def migrate_passwords(session): - """迁移密码数据""" - from app.models.user import UserPassword - - passwords_file = DATA_DIR / "user_passwords.json" - - if not passwords_file.exists(): - print("ℹ️ 密码数据文件不存在,跳过迁移") - return 0 - - try: - with open(passwords_file, "r", encoding="utf-8") as f: - passwords_data = json.load(f) - - if not passwords_data: - print("ℹ️ 密码数据为空,跳过迁移") - return 0 - - migrated_count = 0 - for user_id, pwd_info in passwords_data.items(): - try: - # 检查密码是否已存在 - result = await session.execute( - select(UserPassword).where(UserPassword.user_id == user_id) - ) - existing = result.scalar_one_or_none() - - if existing: - print(f"ℹ️ 密码已存在,跳过: {pwd_info['username']} ({user_id})") - continue - - # 创建密码记录 - pwd_record = UserPassword( - user_id=user_id, - username=pwd_info["username"], - password_hash=pwd_info["password_hash"], - has_custom_password=pwd_info.get("has_custom_password", False), - created_at=datetime.now(), - updated_at=datetime.now() - ) - session.add(pwd_record) - - migrated_count += 1 - print(f"✅ 迁移密码: {pwd_info['username']} ({user_id})") - - except Exception as e: - print(f"❌ 迁移密码 {user_id} 失败: {e}") - - await session.commit() - print(f"\n✅ 密码数据迁移完成: {migrated_count}/{len(passwords_data)} 个密码") - - return migrated_count - - except Exception as e: - print(f"❌ 迁移密码数据失败: {e}") - await session.rollback() - return 0 - - -async def backup_json_files(): - """备份原始JSON文件""" - files_to_backup = ["users.json", "user_passwords.json", "admins.json"] - - print("\n📦 备份原始文件...") - for filename in files_to_backup: - source = DATA_DIR / filename - if source.exists(): - backup = DATA_DIR / f"{filename}.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}" - import shutil - shutil.copy2(source, backup) - print(f"✅ 备份: {filename} -> {backup.name}") - - -async def main(db_url=None): - """主函数 - - Args: - db_url: 可选的数据库URL,如果不提供则使用配置文件中的 - """ - print("=" * 70) - print("用户数据迁移工具 - JSON 到 PostgreSQL") - print("=" * 70) - print() - - # 确定使用的数据库URL - target_db_url = db_url if db_url else settings.database_url - - # 检查数据库配置 - if "postgresql" not in target_db_url: - print("❌ 错误: 未指定 PostgreSQL 数据库") - if not db_url: - print(f" 当前配置: {settings.database_url}") - print(" 请使用 --db-url 参数指定PostgreSQL数据库,或在 .env 中配置 DATABASE_URL") - else: - print(f" 提供的URL: {target_db_url}") - print() - print("示例:") - print(" python migrate_users_to_postgres.py --db-url postgresql+asyncpg://user:pass@localhost/dbname") - return - - # 隐藏密码部分显示 - display_url = target_db_url - if '@' in display_url: - parts = display_url.split('@') - if ':' in parts[0]: - user_part = parts[0].split(':')[0] - display_url = f"{user_part}:****@{parts[1]}" - - print(f"📊 目标数据库: {display_url}") - print() - - try: - # 创建数据库引擎 - engine = create_async_engine( - target_db_url, - echo=False, - future=True, - pool_pre_ping=True, - ) - - # 创建表 - await create_tables(engine) - print() - - # 创建会话 - async_session = async_sessionmaker( - engine, - class_=AsyncSession, - expire_on_commit=False - ) - - # 迁移用户 - print("📋 步骤 1/2: 迁移用户数据") - print("-" * 70) - async with async_session() as session: - user_count = await migrate_users(session) - print() - - # 迁移密码 - print("📋 步骤 2/2: 迁移密码数据") - print("-" * 70) - async with async_session() as session: - pwd_count = await migrate_passwords(session) - print() - - # 备份原文件 - await backup_json_files() - print() - - # 总结 - print("=" * 70) - print("迁移完成") - print("=" * 70) - print(f"✅ 用户: {user_count}") - print(f"✅ 密码: {pwd_count}") - print() - print("💡 提示:") - print(" - 原文件已备份(带时间戳)") - print(" - 可以安全删除 users.json 和 user_passwords.json") - print(" - 如需回滚,请从备份文件恢复") - print() - - # 关闭引擎 - await engine.dispose() - - except Exception as e: - print(f"\n❌ 迁移过程出错: {e}") - import traceback - traceback.print_exc() - sys.exit(1) - - -if __name__ == "__main__": - # 解析命令行参数 - parser = argparse.ArgumentParser( - description="迁移用户数据从JSON到PostgreSQL数据库", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -示例: - # 使用 .env 配置的数据库 - python migrate_users_to_postgres.py - - # 指定数据库URL - python migrate_users_to_postgres.py --db-url postgresql+asyncpg://user:pass@localhost/dbname - - # 使用环境变量 - DATABASE_URL=postgresql+asyncpg://user:pass@localhost/db python migrate_users_to_postgres.py - """ - ) - - parser.add_argument( - "--db-url", - type=str, - help="PostgreSQL数据库连接URL (格式: postgresql+asyncpg://user:password@host:port/database)", - default=None - ) - - args = parser.parse_args() - - # 运行迁移 - asyncio.run(main(db_url=args.db_url)) \ No newline at end of file diff --git a/backend/scripts/migrate_writing_styles_to_user_level.sql b/backend/scripts/migrate_writing_styles_to_user_level.sql deleted file mode 100644 index 07a1c3a..0000000 --- a/backend/scripts/migrate_writing_styles_to_user_level.sql +++ /dev/null @@ -1,36 +0,0 @@ --- 迁移写作风格从项目级别到用户级别 --- 将 writing_styles 表的 project_id 字段改为 user_id - --- 步骤1: 添加新的 user_id 字段 -ALTER TABLE writing_styles ADD COLUMN user_id VARCHAR(255); - --- 步骤2: 将现有数据从 project_id 映射到 user_id --- 通过 projects 表关联,将项目的用户ID填充到风格的 user_id -UPDATE writing_styles ws -SET user_id = ( - SELECT p.user_id - FROM projects p - WHERE p.id = ws.project_id -) -WHERE ws.project_id IS NOT NULL; - --- 步骤3: 添加外键约束 -ALTER TABLE writing_styles -ADD CONSTRAINT fk_writing_styles_user -FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE; - --- 步骤4: 删除旧的 project_id 外键约束 -ALTER TABLE writing_styles DROP CONSTRAINT IF EXISTS writing_styles_project_id_fkey; - --- 步骤5: 删除 project_id 列 -ALTER TABLE writing_styles DROP COLUMN project_id; - --- 步骤6: 更新注释 -COMMENT ON COLUMN writing_styles.user_id IS '所属用户ID(NULL表示全局预设风格)'; - --- 验证迁移结果 -SELECT - COUNT(*) as total_styles, - COUNT(user_id) as user_styles, - COUNT(*) FILTER (WHERE user_id IS NULL) as preset_styles -FROM writing_styles; diff --git a/backend/scripts/migration_add_outline_mode.sql b/backend/scripts/migration_add_outline_mode.sql deleted file mode 100644 index 24c0e88..0000000 --- a/backend/scripts/migration_add_outline_mode.sql +++ /dev/null @@ -1,25 +0,0 @@ --- Migration: Add outline_mode to projects table --- Description: 为项目表添加大纲模式字段,支持一对一和一对多两种模式 --- Date: 2025-11-27 - --- 1. 添加 outline_mode 字段 -ALTER TABLE projects -ADD COLUMN outline_mode VARCHAR(20) NOT NULL DEFAULT 'one-to-many'; - --- 2. 添加检查约束,确保只能是两个有效值之一 -ALTER TABLE projects -ADD CONSTRAINT check_outline_mode -CHECK (outline_mode IN ('one-to-one', 'one-to-many')); - --- 3. 创建索引以提高查询性能 -CREATE INDEX idx_projects_outline_mode ON projects(outline_mode); - --- 4. 为现有项目设置默认模式为一对多(细化模式) --- 这是因为现有项目大多使用展开功能 -UPDATE projects SET outline_mode = 'one-to-many' WHERE outline_mode IS NULL; - --- 5. 添加注释 -COMMENT ON COLUMN projects.outline_mode IS '大纲章节模式: one-to-one(传统模式,1大纲→1章节) 或 one-to-many(细化模式,1大纲→N章节)'; - --- 验证迁移结果 --- SELECT id, title, outline_mode FROM projects LIMIT 10; \ No newline at end of file diff --git a/backend/scripts/migration_add_prompt_templates.sql b/backend/scripts/migration_add_prompt_templates.sql deleted file mode 100644 index 28247e5..0000000 --- a/backend/scripts/migration_add_prompt_templates.sql +++ /dev/null @@ -1,34 +0,0 @@ --- 创建提示词模板表 -CREATE TABLE IF NOT EXISTS prompt_templates ( - id VARCHAR(36) PRIMARY KEY, - user_id VARCHAR(50) NOT NULL, - template_key VARCHAR(100) NOT NULL, - template_name VARCHAR(200) NOT NULL, - template_content TEXT NOT NULL, - description TEXT, - category VARCHAR(50), - parameters TEXT, - is_active BOOLEAN DEFAULT TRUE, - is_system_default BOOLEAN DEFAULT FALSE, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT uk_user_template UNIQUE (user_id, template_key) -); - --- 创建索引 -CREATE INDEX IF NOT EXISTS idx_user_template ON prompt_templates(user_id, template_key); -CREATE INDEX IF NOT EXISTS idx_user_id ON prompt_templates(user_id); -CREATE INDEX IF NOT EXISTS idx_category ON prompt_templates(category); - --- 添加注释 -COMMENT ON TABLE prompt_templates IS '提示词模板表'; -COMMENT ON COLUMN prompt_templates.user_id IS '用户ID'; -COMMENT ON COLUMN prompt_templates.template_key IS '模板键名'; -COMMENT ON COLUMN prompt_templates.template_name IS '模板显示名称'; -COMMENT ON COLUMN prompt_templates.template_content IS '模板内容'; -COMMENT ON COLUMN prompt_templates.description IS '模板描述'; -COMMENT ON COLUMN prompt_templates.category IS '模板分类'; -COMMENT ON COLUMN prompt_templates.parameters IS '模板参数定义(JSON)'; -COMMENT ON COLUMN prompt_templates.is_active IS '是否启用'; -COMMENT ON COLUMN prompt_templates.is_system_default IS '是否为系统默认模板'; \ No newline at end of file diff --git a/backend/scripts/setup_postgres.py b/backend/scripts/setup_postgres.py index 9ae6a32..7a486d8 100644 --- a/backend/scripts/setup_postgres.py +++ b/backend/scripts/setup_postgres.py @@ -36,7 +36,8 @@ except ImportError: from psycopg2 import sql from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT -from app.database import init_db +# 注意: 表结构应由 Alembic 管理 +from pathlib import Path # 设置日志 logging.basicConfig( @@ -277,12 +278,26 @@ class PostgreSQLSetup: return False async def initialize_tables(self) -> bool: - """初始化数据库表结构""" + """初始化数据库表结构(使用 Alembic)""" try: - logger.info(f"📋 初始化数据库表结构...") - await init_db('system') - logger.info(f"✅ 表结构初始化成功") - return True + import subprocess + logger.info(f"📋 使用 Alembic 初始化数据库表结构...") + + # 运行 Alembic 迁移 + result = subprocess.run( + ["alembic", "upgrade", "head"], + capture_output=True, + text=True, + cwd=Path(__file__).parent.parent + ) + + if result.returncode == 0: + logger.info(f"✅ 表结构初始化成功") + return True + else: + logger.error(f"❌ Alembic 迁移失败: {result.stderr}") + return False + except Exception as e: logger.error(f"❌ 初始化表结构失败: {e}") return False diff --git a/docker-compose.yml b/docker-compose.yml index 9aab5f2..aab90e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -74,6 +74,11 @@ services: # 数据库配置 - DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-mumuai}:${POSTGRES_PASSWORD:-123456}@postgres:5432/${POSTGRES_DB:-mumuai_novel} + # 数据库连接信息(用于 entrypoint.sh) + - DB_HOST=postgres + - DB_PORT=5432 + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-123456} + # PostgreSQL 连接池配置 - DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE:-30} - DATABASE_MAX_OVERFLOW=${DATABASE_MAX_OVERFLOW:-20} diff --git a/frontend/package.json b/frontend/package.json index c4176d5..76cf3b4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.1.4", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite",