From 03b532dd09e7aed11ef39928cde4dbc9f2980905 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Thu, 9 Jul 2026 16:29:10 +0800 Subject: [PATCH] Implement Alembic for database migrations and update FastAPI initialization - Integrate Alembic for managing database schema migrations, replacing the previous SQL schema management. - Update the FastAPI application to synchronize interface definitions at startup. - Modify the Docker Compose command to run Alembic migrations before starting the API. - Enhance the Makefile with new commands for database migration and revision management. - Remove outdated SQL schema and seed files, transitioning to a more dynamic migration approach. - Add initial migration scripts and configuration for Alembic, ensuring a structured database evolution. --- Makefile | 19 ++- backend/README.md | 18 +++ backend/alembic.ini | 41 ++++++ backend/app.py | 6 +- backend/db/models.py | 1 + backend/db/schema.sql | 91 ------------ backend/db/seed_interface_definitions.sql | 135 ------------------ backend/db/seed_model_resources.sql | 2 +- backend/db/session.py | 12 +- backend/db/sync_interface_definitions.py | 15 ++ backend/migrations/README | 5 + backend/migrations/env.py | 74 ++++++++++ backend/migrations/script.py.mako | 26 ++++ .../versions/20260709_0001_initial_schema.py | 124 ++++++++++++++++ backend/requirements.txt | 1 + docker-compose.yaml | 2 +- 16 files changed, 325 insertions(+), 247 deletions(-) create mode 100644 backend/alembic.ini delete mode 100644 backend/db/schema.sql delete mode 100644 backend/db/seed_interface_definitions.sql create mode 100644 backend/db/sync_interface_definitions.py create mode 100644 backend/migrations/README create mode 100644 backend/migrations/env.py create mode 100644 backend/migrations/script.py.mako create mode 100644 backend/migrations/versions/20260709_0001_initial_schema.py diff --git a/Makefile b/Makefile index 88c94e6..ddd79de 100644 --- a/Makefile +++ b/Makefile @@ -5,9 +5,10 @@ # 容器内执行 psql 的固定前缀(postgres/postgres/postgres,见 docker-compose.yaml) PSQL = docker compose exec -T postgres psql -U postgres -d postgres +UV = UV_CACHE_DIR=/private/tmp/ai-video-uv-cache uv run --with-requirements requirements.txt .DEFAULT_GOAL := help -.PHONY: help up down restart logs api-logs db db-list db-up db-schema db-seed-interface-definitions db-init db-seed db-seed-model-resources db-seed-assistants db-clear db-reset db-drop-schema db-reset-hard +.PHONY: help up down restart logs api-logs db db-list db-up db-migrate db-revision db-current db-sync-interface-definitions db-init db-seed db-seed-model-resources db-seed-assistants db-clear db-reset db-drop-schema db-reset-hard help: ## 列出所有可用目标 @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ @@ -40,13 +41,19 @@ db-list: ## 列出模型资源与助手 db-up: ## 仅启动 postgres docker compose up -d postgres -db-schema: ## 用纯 SQL 建表/补齐开发库表结构 - $(PSQL) < backend/db/schema.sql +db-migrate: ## 执行 Alembic 迁移到最新版 + cd backend && $(UV) alembic upgrade head -db-seed-interface-definitions: ## 灌入接口定义种子(幂等) - $(PSQL) < backend/db/seed_interface_definitions.sql +db-revision: ## 自动生成 Alembic 迁移: make db-revision msg="add xxx" + cd backend && $(UV) alembic revision --autogenerate -m "$(msg)" -db-init: db-schema db-seed-interface-definitions ## 用纯 SQL 建表并同步接口定义 +db-current: ## 查看当前数据库迁移版本 + cd backend && $(UV) alembic current + +db-sync-interface-definitions: ## 从 interface_catalog.py 同步接口定义(幂等) + cd backend && $(UV) python -m db.sync_interface_definitions + +db-init: db-migrate db-sync-interface-definitions ## 用 Alembic 建表并同步接口定义 db-seed-model-resources: ## 灌入 12 条模型资源种子(幂等) $(PSQL) < backend/db/seed_model_resources.sql diff --git a/backend/README.md b/backend/README.md index 42c0af2..3345b27 100644 --- a/backend/README.md +++ b/backend/README.md @@ -98,6 +98,9 @@ uv pip install fastapi "uvicorn[standard]" sqlalchemy asyncpg greenlet python-do cp .env.example .env # CRUD 阶段只需 DATABASE_URL;语音再填模型 key # 起 Postgres:在 ai-video/ 下 docker compose up -d postgres +cd .. +make db-migrate # 首次或表结构变更后执行 Alembic 迁移 +cd backend uv run --with-requirements requirements.txt uvicorn app:app --reload --port 8000 ``` @@ -106,6 +109,21 @@ uv run --with-requirements requirements.txt uvicorn app:app --reload --port 8000 > > 交互式 API 文档:启动后访问 http://localhost:8000/docs(手动戳 CRUD、定 schema 用)。 +## 数据库迁移 + +表结构由 Alembic 管理,SQLAlchemy 模型在 `db/models.py`,迁移文件在 +`migrations/versions/`。 + +```bash +cd ai-video +make db-migrate # 升级到最新版 +make db-revision msg="add xxx" # 根据模型差异生成迁移 +make db-current # 查看当前库版本 +``` + +`interface_definitions` 的默认数据由 `services/interface_catalog.py` 维护,并通过 +启动时同步或 `make db-sync-interface-definitions` 写入数据库;模型资源、知识库和助手示例数据继续走 `db/seed_*.sql`。 + ## Docker(ai-video/docker-compose.yaml)—— 调试主路径 api 服务挂了源码 + `--reload`,前端用 npm dev + HMR,改代码都即时生效。 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..8985bf2 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +path_separator = os +sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/postgres + +[post_write_hooks] + +[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 diff --git a/backend/app.py b/backend/app.py index 7e40b49..3683345 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,4 +1,4 @@ -"""FastAPI 入口。挂载路由,放行前端跨域,启动时建表。 +"""FastAPI 入口。挂载路由,放行前端跨域,启动时同步接口定义。 启动: uv run --with-requirements requirements.txt uvicorn app:app --reload --port 8000 @@ -16,7 +16,7 @@ from contextlib import asynccontextmanager import config import uvicorn -from db.session import init_db +from db.session import sync_interface_definitions from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -33,7 +33,7 @@ from routes import ( @asynccontextmanager async def lifespan(_app: FastAPI): - await init_db() # MVP:启动建表;表稳定后切 alembic + await sync_interface_definitions() yield diff --git a/backend/db/models.py b/backend/db/models.py index ff5e2ba..a245fd4 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -73,6 +73,7 @@ class KnowledgeBase(Base): embedding_model_resource_id: Mapped[str | None] = mapped_column( String(40), ForeignKey("model_resources.id", ondelete="SET NULL"), + index=True, nullable=True, ) status: Mapped[str] = mapped_column(String(16), default="active") # active|archived diff --git a/backend/db/schema.sql b/backend/db/schema.sql deleted file mode 100644 index 221a423..0000000 --- a/backend/db/schema.sql +++ /dev/null @@ -1,91 +0,0 @@ --- Development schema bootstrap. Keep this in sync with backend/db/models.py. - -CREATE TABLE IF NOT EXISTS interface_definitions ( - interface_type VARCHAR(64) PRIMARY KEY, - name VARCHAR(128) NOT NULL, - capability VARCHAR(16) NOT NULL, - field_schema JSONB NOT NULL DEFAULT '{}'::jsonb, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - version INTEGER NOT NULL DEFAULT 1, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS model_resources ( - id VARCHAR(40) PRIMARY KEY, - name VARCHAR(128) NOT NULL DEFAULT '', - capability VARCHAR(16) NOT NULL, - interface_type VARCHAR(64) NOT NULL REFERENCES interface_definitions(interface_type) ON DELETE RESTRICT, - values JSONB NOT NULL DEFAULT '{}'::jsonb, - secrets JSONB NOT NULL DEFAULT '{}'::jsonb, - support_image_input BOOLEAN NOT NULL DEFAULT FALSE, - enabled BOOLEAN NOT NULL DEFAULT TRUE, - is_default BOOLEAN NOT NULL DEFAULT FALSE, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS knowledge_bases ( - id VARCHAR(40) PRIMARY KEY, - name VARCHAR(128) NOT NULL, - description VARCHAR(2048) NOT NULL DEFAULT '', - embedding_model_resource_id VARCHAR(40) REFERENCES model_resources(id) ON DELETE SET NULL, - status VARCHAR(16) NOT NULL DEFAULT 'active', - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS assistants ( - id VARCHAR(40) PRIMARY KEY, - name VARCHAR(128) NOT NULL, - type VARCHAR(16) NOT NULL DEFAULT 'prompt', - runtime_mode VARCHAR(16) NOT NULL DEFAULT 'pipeline', - greeting VARCHAR(2048) NOT NULL DEFAULT '', - enable_interrupt BOOLEAN NOT NULL DEFAULT TRUE, - vision_enabled BOOLEAN NOT NULL DEFAULT FALSE, - vision_model_resource_id VARCHAR(40) REFERENCES model_resources(id) ON DELETE SET NULL, - knowledge_base_id VARCHAR(40) REFERENCES knowledge_bases(id) ON DELETE RESTRICT, - prompt VARCHAR(8192) NOT NULL DEFAULT '', - api_url VARCHAR(512) NOT NULL DEFAULT '', - api_key VARCHAR(512) NOT NULL DEFAULT '', - app_id VARCHAR(128) NOT NULL DEFAULT '', - graph JSON NOT NULL DEFAULT '{}'::json, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE TABLE IF NOT EXISTS assistant_model_bindings ( - assistant_id VARCHAR(40) NOT NULL REFERENCES assistants(id) ON DELETE CASCADE, - capability VARCHAR(16) NOT NULL, - model_resource_id VARCHAR(40) NOT NULL REFERENCES model_resources(id) ON DELETE RESTRICT, - config JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (assistant_id, capability) -); - --- Lightweight forward-compatible patches for existing dev databases. -ALTER TABLE interface_definitions - ALTER COLUMN field_schema TYPE JSONB USING field_schema::jsonb; - -ALTER TABLE assistants - ADD COLUMN IF NOT EXISTS vision_enabled BOOLEAN NOT NULL DEFAULT FALSE, - ADD COLUMN IF NOT EXISTS vision_model_resource_id VARCHAR(40); - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'assistants_vision_model_resource_id_fkey' - ) THEN - ALTER TABLE assistants - ADD CONSTRAINT assistants_vision_model_resource_id_fkey - FOREIGN KEY (vision_model_resource_id) REFERENCES model_resources(id) ON DELETE SET NULL; - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS ix_interface_definitions_capability ON interface_definitions(capability); -CREATE INDEX IF NOT EXISTS ix_model_resources_capability ON model_resources(capability); -CREATE INDEX IF NOT EXISTS ix_model_resources_interface_type ON model_resources(interface_type); -CREATE INDEX IF NOT EXISTS ix_knowledge_bases_embedding_model_resource_id ON knowledge_bases(embedding_model_resource_id); -CREATE INDEX IF NOT EXISTS ix_assistants_type ON assistants(type); -CREATE INDEX IF NOT EXISTS ix_assistant_model_bindings_model_resource_id ON assistant_model_bindings(model_resource_id); diff --git a/backend/db/seed_interface_definitions.sql b/backend/db/seed_interface_definitions.sql deleted file mode 100644 index bfc64f5..0000000 --- a/backend/db/seed_interface_definitions.sql +++ /dev/null @@ -1,135 +0,0 @@ --- Built-in interface definitions. Mirrors backend/services/interface_catalog.py. - -INSERT INTO interface_definitions - (interface_type, name, capability, field_schema, enabled, version) -VALUES - ('openai-llm', 'OpenAI Compatible LLM', 'LLM', - $$ {"fields":[ - {"key":"modelId","label":"Model ID","group":"values","type":"text","required":true}, - {"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}, - {"key":"temperature","label":"Temperature","group":"values","type":"number","required":false,"default":0.7} - ]} $$::jsonb, TRUE, 1), - ('openai-asr', 'OpenAI Compatible ASR', 'ASR', - $$ {"fields":[ - {"key":"modelId","label":"Model ID","group":"values","type":"text","required":true}, - {"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}, - {"key":"language","label":"Language","group":"values","type":"text","required":false,"default":"zh"} - ]} $$::jsonb, TRUE, 1), - ('openai-tts', 'OpenAI Compatible TTS', 'TTS', - $$ {"fields":[ - {"key":"modelId","label":"Model ID","group":"values","type":"text","required":true}, - {"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}, - {"key":"voice","label":"Voice","group":"values","type":"text","required":false}, - {"key":"speed","label":"Speed","group":"values","type":"number","required":false,"default":1.0}, - {"key":"sourceSampleRate","label":"Source Sample Rate","group":"values","type":"number","required":false,"default":24000} - ]} $$::jsonb, TRUE, 1), - ('openai-embedding', 'OpenAI Compatible Embedding', 'Embedding', - $$ {"fields":[ - {"key":"modelId","label":"Model ID","group":"values","type":"text","required":true}, - {"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}, - {"key":"dimensions","label":"Dimensions","group":"values","type":"number","required":false} - ]} $$::jsonb, TRUE, 1), - ('openai-realtime', 'OpenAI Realtime', 'Realtime', - $$ {"fields":[ - {"key":"modelId","label":"Model ID","group":"values","type":"text","required":true}, - {"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}, - {"key":"voice","label":"Voice","group":"values","type":"text","required":false} - ]} $$::jsonb, TRUE, 1), - ('dify', 'Dify Agent', 'Agent', - $$ {"fields":[ - {"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true} - ]} $$::jsonb, TRUE, 1), - ('fastgpt', 'FastGPT Agent', 'Agent', - $$ {"fields":[ - {"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}, - {"key":"appId","label":"App ID","group":"values","type":"text","required":true} - ]} $$::jsonb, TRUE, 1), - ('opencode', 'OpenCode Agent', 'Agent', - $$ {"fields":[ - {"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true} - ]} $$::jsonb, TRUE, 1), - ('stepfun-realtime', 'StepFun StepAudio Realtime', 'Realtime', - $$ {"fields":[ - {"key":"modelId","label":"Model ID","group":"values","type":"text","required":true}, - {"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}, - {"key":"voice","label":"Voice","group":"values","type":"text","required":true,"default":"linjiajiejie"}, - {"key":"inputSampleRate","label":"Input Sample Rate","group":"values","type":"number","required":false,"default":24000}, - {"key":"outputSampleRate","label":"Output Sample Rate","group":"values","type":"number","required":false,"default":24000}, - {"key":"prefixPaddingMs","label":"VAD Prefix Padding (ms)","group":"values","type":"number","required":false,"default":500}, - {"key":"silenceDurationMs","label":"VAD Silence Duration (ms)","group":"values","type":"number","required":false,"default":300}, - {"key":"energyAwakenessThreshold","label":"VAD Energy Threshold","group":"values","type":"number","required":false,"default":2500} - ]} $$::jsonb, TRUE, 1), - ('xfyun-asr', 'Xfyun Streaming ASR', 'ASR', - $$ {"fields":[ - {"key":"apiUrl","label":"WebSocket URL","group":"values","type":"url","required":true}, - {"key":"appId","label":"App ID","group":"secrets","type":"password","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}, - {"key":"apiSecret","label":"API Secret","group":"secrets","type":"password","required":true}, - {"key":"language","label":"Language","group":"values","type":"text","required":false,"default":"zh_cn"}, - {"key":"domain","label":"Domain","group":"values","type":"text","required":false,"default":"iat"}, - {"key":"accent","label":"Accent","group":"values","type":"text","required":false,"default":"mandarin"}, - {"key":"dynamicCorrection","label":"Dynamic Correction","group":"values","type":"boolean","required":false,"default":false}, - {"key":"frameSize","label":"Frame Size","group":"values","type":"number","required":false,"default":1280} - ]} $$::jsonb, TRUE, 1), - ('xfyun-tts', 'Xfyun TTS', 'TTS', - $$ {"fields":[ - {"key":"apiUrl","label":"WebSocket URL","group":"values","type":"url","required":true}, - {"key":"appId","label":"App ID","group":"secrets","type":"password","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}, - {"key":"apiSecret","label":"API Secret","group":"secrets","type":"password","required":true}, - {"key":"voice","label":"Voice","group":"values","type":"text","required":false}, - {"key":"speed","label":"Speed","group":"values","type":"number","required":false,"default":50}, - {"key":"volume","label":"Volume","group":"values","type":"number","required":false,"default":50}, - {"key":"pitch","label":"Pitch","group":"values","type":"number","required":false,"default":50}, - {"key":"sourceSampleRate","label":"Source Sample Rate","group":"values","type":"number","required":false,"default":16000} - ]} $$::jsonb, TRUE, 1), - ('xfyun-super-tts', 'Xfyun Super TTS', 'TTS', - $$ {"fields":[ - {"key":"apiUrl","label":"WebSocket URL","group":"values","type":"url","required":true}, - {"key":"appId","label":"App ID","group":"secrets","type":"password","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}, - {"key":"apiSecret","label":"API Secret","group":"secrets","type":"password","required":true}, - {"key":"voice","label":"Voice","group":"values","type":"text","required":false}, - {"key":"speed","label":"Speed","group":"values","type":"number","required":false,"default":50}, - {"key":"volume","label":"Volume","group":"values","type":"number","required":false,"default":50}, - {"key":"pitch","label":"Pitch","group":"values","type":"number","required":false,"default":50}, - {"key":"oralLevel","label":"Oral Level","group":"values","type":"text","required":false,"default":"mid"}, - {"key":"sourceSampleRate","label":"Source Sample Rate","group":"values","type":"number","required":false,"default":24000}, - {"key":"textAggregationMode","label":"Text Aggregation Mode","group":"values","type":"text","required":false,"default":"token"} - ]} $$::jsonb, TRUE, 1), - ('dashscope-asr', 'DashScope ASR', 'ASR', - $$ {"fields":[ - {"key":"modelId","label":"Model ID","group":"values","type":"text","required":true}, - {"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}, - {"key":"language","label":"Language","group":"values","type":"text","required":false,"default":"zh"} - ]} $$::jsonb, TRUE, 1), - ('dashscope-tts', 'DashScope TTS', 'TTS', - $$ {"fields":[ - {"key":"modelId","label":"Model ID","group":"values","type":"text","required":true}, - {"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true}, - {"key":"voice","label":"Voice","group":"values","type":"text","required":false} - ]} $$::jsonb, TRUE, 1), - ('gemini-realtime', 'Gemini Realtime', 'Realtime', - $$ {"fields":[ - {"key":"modelId","label":"Model ID","group":"values","type":"text","required":true}, - {"key":"apiUrl","label":"API URL","group":"values","type":"url","required":true}, - {"key":"apiKey","label":"API Key","group":"secrets","type":"password","required":true} - ]} $$::jsonb, TRUE, 1) -ON CONFLICT (interface_type) DO UPDATE SET - name = EXCLUDED.name, - capability = EXCLUDED.capability, - field_schema = EXCLUDED.field_schema, - enabled = TRUE, - version = EXCLUDED.version, - updated_at = now(); diff --git a/backend/db/seed_model_resources.sql b/backend/db/seed_model_resources.sql index 1e44289..8c1f725 100644 --- a/backend/db/seed_model_resources.sql +++ b/backend/db/seed_model_resources.sql @@ -1,4 +1,4 @@ --- 模型资源种子数据。依赖 seed_interface_definitions.sql。 +-- 模型资源种子数据。依赖 interface_catalog.py 已同步到 interface_definitions。 INSERT INTO model_resources (id, name, capability, interface_type, values, secrets, support_image_input, enabled, is_default) diff --git a/backend/db/session.py b/backend/db/session.py index af8f9d1..936449e 100644 --- a/backend/db/session.py +++ b/backend/db/session.py @@ -2,14 +2,13 @@ - engine / SessionLocal:全局单例 - get_session:FastAPI 依赖,按请求注入一个会话 -- init_db:启动时建表(MVP 用 create_all;表结构稳定后切 alembic 迁移,对齐 dograh) +- sync_interface_definitions:启动时同步接口定义;表结构由 Alembic 管理 """ from collections.abc import AsyncGenerator import json import config -from db.models import Base from services.interface_catalog import INTERFACE_DEFINITIONS from sqlalchemy import text from sqlalchemy.ext.asyncio import ( @@ -27,15 +26,8 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: yield session -async def init_db() -> None: +async def sync_interface_definitions() -> None: async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - await conn.execute( - text( - "ALTER TABLE interface_definitions " - "ALTER COLUMN field_schema TYPE JSONB USING field_schema::jsonb" - ) - ) for definition in INTERFACE_DEFINITIONS: await conn.execute( text( diff --git a/backend/db/sync_interface_definitions.py b/backend/db/sync_interface_definitions.py new file mode 100644 index 0000000..89f4fa7 --- /dev/null +++ b/backend/db/sync_interface_definitions.py @@ -0,0 +1,15 @@ +"""CLI entry for syncing built-in interface definitions.""" + +from __future__ import annotations + +import asyncio + +from db.session import sync_interface_definitions + + +def main() -> None: + asyncio.run(sync_interface_definitions()) + + +if __name__ == "__main__": + main() diff --git a/backend/migrations/README b/backend/migrations/README new file mode 100644 index 0000000..3afe23d --- /dev/null +++ b/backend/migrations/README @@ -0,0 +1,5 @@ +Alembic migrations for the backend database. + +Run from backend/: + + alembic upgrade head diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..5bdca2a --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +import config as app_config +from db.models import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def get_url() -> str: + return app_config.DATABASE_URL + + +def run_migrations_offline() -> None: + context.configure( + url=get_url(), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = get_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: + import asyncio + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..b74f24c --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + + +revision: str = ${repr(up_revision)} +down_revision: str | Sequence[str] | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/backend/migrations/versions/20260709_0001_initial_schema.py b/backend/migrations/versions/20260709_0001_initial_schema.py new file mode 100644 index 0000000..e8ad9df --- /dev/null +++ b/backend/migrations/versions/20260709_0001_initial_schema.py @@ -0,0 +1,124 @@ +"""initial schema + +Revision ID: 20260709_0001 +Revises: +Create Date: 2026-07-09 00:00:00.000000 + +""" +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "20260709_0001" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "interface_definitions", + sa.Column("interface_type", sa.String(length=64), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("capability", sa.String(length=16), nullable=False), + sa.Column( + "field_schema", + postgresql.JSONB(astext_type=sa.Text()), + server_default=sa.text("'{}'::jsonb"), + nullable=False, + ), + sa.Column("enabled", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("version", sa.Integer(), server_default=sa.text("1"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("interface_type"), + ) + op.create_index("ix_interface_definitions_capability", "interface_definitions", ["capability"]) + + op.create_table( + "model_resources", + sa.Column("id", sa.String(length=40), nullable=False), + sa.Column("name", sa.String(length=128), server_default="", nullable=False), + sa.Column("capability", sa.String(length=16), nullable=False), + sa.Column("interface_type", sa.String(length=64), nullable=False), + sa.Column("values", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("secrets", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("support_image_input", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("enabled", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("is_default", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["interface_type"], ["interface_definitions.interface_type"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_model_resources_capability", "model_resources", ["capability"]) + op.create_index("ix_model_resources_interface_type", "model_resources", ["interface_type"]) + + op.create_table( + "knowledge_bases", + sa.Column("id", sa.String(length=40), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("description", sa.String(length=2048), server_default="", nullable=False), + sa.Column("embedding_model_resource_id", sa.String(length=40), nullable=True), + sa.Column("status", sa.String(length=16), server_default="active", nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["embedding_model_resource_id"], ["model_resources.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_knowledge_bases_embedding_model_resource_id", "knowledge_bases", ["embedding_model_resource_id"]) + + op.create_table( + "assistants", + sa.Column("id", sa.String(length=40), nullable=False), + sa.Column("name", sa.String(length=128), nullable=False), + sa.Column("type", sa.String(length=16), server_default="prompt", nullable=False), + sa.Column("runtime_mode", sa.String(length=16), server_default="pipeline", nullable=False), + sa.Column("greeting", sa.String(length=2048), server_default="", nullable=False), + sa.Column("enable_interrupt", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("vision_enabled", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("vision_model_resource_id", sa.String(length=40), nullable=True), + sa.Column("knowledge_base_id", sa.String(length=40), nullable=True), + sa.Column("prompt", sa.String(length=8192), server_default="", nullable=False), + sa.Column("api_url", sa.String(length=512), server_default="", nullable=False), + sa.Column("api_key", sa.String(length=512), server_default="", nullable=False), + sa.Column("app_id", sa.String(length=128), server_default="", nullable=False), + sa.Column("graph", sa.JSON(), server_default=sa.text("'{}'::json"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["knowledge_base_id"], ["knowledge_bases.id"], ondelete="RESTRICT"), + sa.ForeignKeyConstraint(["vision_model_resource_id"], ["model_resources.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_assistants_type", "assistants", ["type"]) + + op.create_table( + "assistant_model_bindings", + sa.Column("assistant_id", sa.String(length=40), nullable=False), + sa.Column("capability", sa.String(length=16), nullable=False), + sa.Column("model_resource_id", sa.String(length=40), nullable=False), + sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["assistant_id"], ["assistants.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["model_resource_id"], ["model_resources.id"], ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("assistant_id", "capability"), + ) + op.create_index("ix_assistant_model_bindings_model_resource_id", "assistant_model_bindings", ["model_resource_id"]) + + +def downgrade() -> None: + op.drop_index("ix_assistant_model_bindings_model_resource_id", table_name="assistant_model_bindings") + op.drop_table("assistant_model_bindings") + op.drop_index("ix_assistants_type", table_name="assistants") + op.drop_table("assistants") + op.drop_index("ix_knowledge_bases_embedding_model_resource_id", table_name="knowledge_bases") + op.drop_table("knowledge_bases") + op.drop_index("ix_model_resources_interface_type", table_name="model_resources") + op.drop_index("ix_model_resources_capability", table_name="model_resources") + op.drop_table("model_resources") + op.drop_index("ix_interface_definitions_capability", table_name="interface_definitions") + op.drop_table("interface_definitions") diff --git a/backend/requirements.txt b/backend/requirements.txt index 436435d..9b06227 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,5 +18,6 @@ websockets>=13 # 存储:Postgres(SQLAlchemy 2.0 异步 + asyncpg 驱动) sqlalchemy[asyncio]>=2.0 +alembic>=1.13 asyncpg greenlet # SQLAlchemy 异步运行时必需(部分平台不会自动带上) diff --git a/docker-compose.yaml b/docker-compose.yaml index e9638f8..2435472 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -36,7 +36,7 @@ services: api: build: ./backend # 调试:挂源码 + --reload,改代码即时生效,无需重建镜像 - command: uvicorn app:app --host 0.0.0.0 --port 8000 --reload + command: sh -c "alembic upgrade head && uvicorn app:app --host 0.0.0.0 --port 8000 --reload" volumes: - ./backend:/app - /app/.venv # 屏蔽宿主机的 venv(容器用镜像内的依赖)