Files
taiji-AI-PAD/services/mcp-server/database.py
T
2026-01-07 14:46:15 +00:00

491 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
数据库配置和连接管理
"""
import asyncio
import ssl
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
from sqlalchemy.engine import make_url
import logging
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
from config import settings
from models import Base
logger = logging.getLogger(__name__)
def prepare_database_url(url: str) -> str:
"""
处理数据库 URL,移除 asyncpg 不支持的参数(如 sslmode)
"""
if not url or "asyncpg" not in url:
return url
parsed = urlparse(url)
query_params = parse_qs(parsed.query)
# 移除 sslmode 参数(asyncpg 不支持,需要用 ssl connect_args 替代)
if "sslmode" in query_params:
del query_params["sslmode"]
# 重新构建查询字符串
new_query = urlencode(query_params, doseq=True)
new_parsed = parsed._replace(query=new_query)
return urlunparse(new_parsed)
# 处理数据库 URL
database_url = prepare_database_url(settings.database_url)
database_url_obj = make_url(database_url)
engine_kwargs = {
"echo": settings.debug,
"pool_pre_ping": True,
}
if database_url_obj.get_backend_name().startswith("sqlite"):
engine_kwargs["connect_args"] = {"check_same_thread": False}
else:
engine_kwargs.update({
"pool_size": 5,
"max_overflow": 10,
"pool_recycle": 1800,
"pool_timeout": 30,
})
# Azure Database for PostgreSQL 或任何包含 sslmode 的连接都需要 TLS
if (database_url_obj.host and database_url_obj.host.endswith("postgres.database.azure.com")) or \
"sslmode" in settings.database_url:
# Azure Database for PostgreSQL requires TLS; provide a default SSL context.
ssl_context = ssl.create_default_context()
existing_connect_args = engine_kwargs.get("connect_args") or {}
existing_connect_args["ssl"] = ssl_context
engine_kwargs["connect_args"] = existing_connect_args
# 创建异步数据库引擎
engine = create_async_engine(database_url, **engine_kwargs)
# 判断当前是否使用SQLite后端
def is_sqlite_backend() -> bool:
backend = engine.url.get_backend_name()
return backend.startswith("sqlite")
# 创建异步会话工厂
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""获取数据库会话的依赖注入函数"""
async with AsyncSessionLocal() as session:
try:
yield session
except Exception as e:
# HTTPException是正常的业务异常,不应该被当作数据库错误
from fastapi import HTTPException
if isinstance(e, HTTPException):
raise
logger.error(f"数据库会话错误: {e}")
await session.rollback()
raise
finally:
await session.close()
async def init_db():
"""初始化数据库"""
try:
# 创建所有表
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("数据库表创建成功")
# 尝试创建初始数据,失败不阻止启动
try:
await create_initial_data()
except Exception as init_err:
logger.warning(f"创建初始数据失败(服务将继续启动): {init_err}")
except Exception as e:
logger.error(f"数据库初始化失败: {e}")
raise
async def create_initial_data():
"""创建初始数据"""
try:
async with AsyncSessionLocal() as session:
# 检查是否已有数据
result = await session.execute(text("SELECT COUNT(*) FROM users"))
user_count = result.scalar()
if user_count == 0:
# 创建默认管理员用户
from models import User
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
admin_user = User(
name="系统管理员", # 必填字段
username="admin",
email="admin@taiji-ai.com",
password_hash=pwd_context.hash("admin123"), # 必填字段
hashed_password=pwd_context.hash("admin123"),
full_name="系统管理员",
role="super_admin", # 设置为超级管理员
is_active=True,
is_admin=True,
status="active",
)
session.add(admin_user)
await session.commit()
logger.info("默认管理员用户创建成功")
# 创建示例工具
await create_sample_tools(session)
except Exception as e:
logger.error(f"创建初始数据失败: {e}")
raise
async def create_sample_tools(session: AsyncSession):
"""创建示例工具"""
try:
from models import Tool
# 检查是否已有工具
result = await session.execute(text("SELECT COUNT(*) FROM tools"))
tool_count = result.scalar()
if tool_count == 0:
# 创建示例工具
sample_tools = [
{
"name": "web_search",
"description": "网络搜索工具",
"category": "api",
"schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "搜索查询"
},
"limit": {
"type": "integer",
"description": "结果数量限制",
"default": 10
}
},
"required": ["query"]
},
"endpoint": "https://api.example.com/search",
"method": "POST",
"auth_type": "api_key",
"rate_limit": 100,
"cost_per_call": 0.01,
"is_public": True
},
{
"name": "text_completion",
"description": "文本补全工具",
"category": "llm",
"schema": {
"type": "object",
"properties": {
"prompt": {
"type": "string",
"description": "输入提示"
},
"max_tokens": {
"type": "integer",
"description": "最大token数",
"default": 150
},
"temperature": {
"type": "number",
"description": "温度参数",
"default": 0.7
}
},
"required": ["prompt"]
},
"rate_limit": 60,
"cost_per_call": 0.05,
"is_public": True
},
{
"name": "weather_api",
"description": "天气查询API",
"category": "api",
"schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称"
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"default": "celsius"
}
},
"required": ["city"]
},
"endpoint": "https://api.openweathermap.org/data/2.5/weather",
"method": "GET",
"auth_type": "api_key",
"rate_limit": 1000,
"cost_per_call": 0.001,
"is_public": True
}
]
for tool_data in sample_tools:
tool = Tool(**tool_data)
session.add(tool)
await session.commit()
logger.info(f"创建了 {len(sample_tools)} 个示例工具")
except Exception as e:
logger.error(f"创建示例工具失败: {e}")
raise
async def check_db_connection():
"""检查数据库连接"""
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
return True
except Exception as e:
logger.error(f"数据库连接检查失败: {e}")
return False
async def get_db_stats():
"""获取数据库统计信息"""
try:
async with AsyncSessionLocal() as session:
stats = {}
# 获取各表的记录数
tables = ["users", "agents", "tools", "sessions", "executions", "billing"]
for table in tables:
result = await session.execute(text(f"SELECT COUNT(*) FROM {table}"))
stats[table] = result.scalar()
return stats
except Exception as e:
logger.error(f"获取数据库统计失败: {e}")
return {}
async def cleanup_old_records():
"""清理旧记录"""
try:
async with AsyncSessionLocal() as session:
# 清理超过30天的执行记录
if is_sqlite_backend():
result = await session.execute(text("""
DELETE FROM executions
WHERE created_at < datetime('now', '-30 days')
"""))
else:
result = await session.execute(text("""
DELETE FROM executions
WHERE created_at < NOW() - INTERVAL '30 days'
"""))
deleted_executions = result.rowcount
# 清理超过7天的会话记录
if is_sqlite_backend():
result = await session.execute(text("""
DELETE FROM sessions
WHERE created_at < datetime('now', '-7 days')
AND status != 'active'
"""))
else:
result = await session.execute(text("""
DELETE FROM sessions
WHERE created_at < NOW() - INTERVAL '7 days'
AND status != 'active'
"""))
deleted_sessions = result.rowcount
await session.commit()
logger.info(f"清理完成: 删除了 {deleted_executions} 条执行记录, {deleted_sessions} 条会话记录")
return {
"deleted_executions": deleted_executions,
"deleted_sessions": deleted_sessions
}
except Exception as e:
logger.error(f"清理旧记录失败: {e}")
return {}
async def backup_db():
"""数据库备份"""
try:
import subprocess
from datetime import datetime
import os
import shutil
# 生成备份文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_dir = "/app/backups"
os.makedirs(backup_dir, exist_ok=True)
if is_sqlite_backend():
db_path = engine.url.database
if not db_path:
raise ValueError("SQLite数据库路径为空,无法备份")
db_path = os.path.abspath(db_path)
backup_file = os.path.join(backup_dir, f"mcp_sqlite_backup_{timestamp}.db")
shutil.copy2(db_path, backup_file)
logger.info(f"SQLite数据库备份成功: {backup_file}")
return backup_file
backup_file = f"{backup_dir}/taiji_db_backup_{timestamp}.sql"
# 执行pg_dump命令
cmd = [
"pg_dump",
settings.database_url.replace("postgresql+asyncpg://", "postgresql://"),
"-f", backup_file
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
logger.info(f"数据库备份成功: {backup_file}")
return backup_file
else:
logger.error(f"数据库备份失败: {result.stderr}")
return None
except Exception as e:
logger.error(f"数据库备份异常: {e}")
return None
async def close_db():
"""关闭数据库连接"""
try:
await engine.dispose()
logger.info("数据库连接已关闭")
except Exception as e:
logger.error(f"关闭数据库连接失败: {e}")
# 数据库事件处理
async def on_startup():
"""应用启动时的数据库操作"""
await init_db()
async def on_shutdown():
"""应用关闭时的数据库操作"""
await close_db()
# 定期清理任务
async def periodic_cleanup():
"""定期清理任务"""
while True:
try:
await asyncio.sleep(3600) # 每小时执行一次
await cleanup_old_records()
except Exception as e:
logger.error(f"定期清理任务异常: {e}")
# 数据库迁移辅助函数
async def migrate_db():
"""数据库迁移(简化版本)"""
try:
# 这里可以添加数据迁移逻辑
# 在生产环境中应该使用Alembic进行数据库版本管理
logger.info("数据库迁移检查完成")
except Exception as e:
logger.error(f"数据库迁移失败: {e}")
raise
# 性能优化
async def optimize_db():
"""数据库性能优化"""
try:
async with AsyncSessionLocal() as session:
# 更新表统计信息
await session.execute(text("ANALYZE;"))
# 重建索引(如果需要)
# await session.execute(text("REINDEX DATABASE taiji_db;"))
await session.commit()
logger.info("数据库优化完成")
except Exception as e:
logger.error(f"数据库优化失败: {e}")
# 健康检查
async def health_check() -> dict:
"""数据库健康检查"""
health_info = {
"database": "unknown",
"connection_pool": "unknown",
"stats": {}
}
try:
# 检查连接
if await check_db_connection():
health_info["database"] = "healthy"
else:
health_info["database"] = "unhealthy"
# 检查连接池状态
pool = engine.pool
health_info["connection_pool"] = {
"size": pool.size(),
"checked_in": pool.checkedin(),
"checked_out": pool.checkedout()
}
# 获取统计信息
health_info["stats"] = await get_db_stats()
except Exception as e:
logger.error(f"数据库健康检查失败: {e}")
health_info["database"] = "error"
health_info["error"] = str(e)
return health_info