update:1.重构项目数据库初始化和迁移逻辑,使用Alembic数据库管理工具
This commit is contained in:
+4
-15
@@ -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
|
||||
|
||||
# ==========================================
|
||||
# 代理配置(可选)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 限制导致需要重建表,这在大表时会很慢。
|
||||
@@ -0,0 +1,2 @@
|
||||
# 此文件确保 versions 目录被 Git 追踪
|
||||
# 迁移版本文件将存放在此目录
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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 ###
|
||||
@@ -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("✅ 已删除关系类型数据")
|
||||
@@ -0,0 +1,2 @@
|
||||
# 此文件确保 versions 目录被 Git 追踪
|
||||
# 迁移版本文件将存放在此目录
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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 ###
|
||||
@@ -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})")
|
||||
|
||||
|
||||
+3
-20
@@ -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
|
||||
|
||||
+17
-139
@@ -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():
|
||||
|
||||
+1
-17
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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适配器已禁用")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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';
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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))
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 '是否为系统默认模板';
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user