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.
This commit is contained in:
Xin Wang
2026-07-09 16:29:10 +08:00
parent 54329aa516
commit 03b532dd09
16 changed files with 325 additions and 247 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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