Build lightweight AI agent admin

This commit is contained in:
Codex
2026-06-08 18:14:59 +08:00
commit e164840f43
2530 changed files with 435693 additions and 0 deletions
@@ -0,0 +1,5 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from core.database_connection.api import router
__all__ = ["router"]
@@ -0,0 +1,189 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""数据库连接管理 API"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.base_schema import PaginatedResponse, ResponseModel
from app.config import settings
from app.database import get_db
from core.application.model import Application
from core.database_connection.schema import (
DatabaseConnectionCreate,
DatabaseConnectionResponse,
DatabaseConnectionSimpleOut,
DatabaseConnectionTestRequest,
DatabaseConnectionTestResponse,
DatabaseConnectionUpdate,
)
from core.database_connection.service import DatabaseConnectionService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/database-connection", tags=["数据库连接管理"])
@router.get("", response_model=PaginatedResponse[DatabaseConnectionResponse], summary="获取连接列表")
async def list_connections(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=settings.PAGE_SIZE, ge=1, le=settings.PAGE_MAX_SIZE, alias="pageSize"),
application_id: Optional[str] = Query(default=None, alias="applicationId"),
name: Optional[str] = None,
code: Optional[str] = None,
status: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
):
items, total = await DatabaseConnectionService.get_list(
db,
page=page,
page_size=page_size,
application_id=application_id,
name=name,
code=code,
status=status,
)
app_ids = list({item.application_id for item in items if item.application_id})
app_name_map = {}
if app_ids:
app_result = await db.execute(
select(Application.id, Application.name).where(Application.id.in_(app_ids))
)
app_name_map = {row.id: row.name for row in app_result}
result_items = []
for item in items:
item_dict = DatabaseConnectionService.to_response_dict(item)
item_dict["application_name"] = app_name_map.get(item.application_id, "")
result_items.append(item_dict)
return PaginatedResponse(items=result_items, total=total)
@router.post("", response_model=DatabaseConnectionResponse, summary="创建连接")
async def create_connection(
data: DatabaseConnectionCreate,
db: AsyncSession = Depends(get_db),
):
try:
DatabaseConnectionService.validate_code(data.code)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if await DatabaseConnectionService.check_code_exists(db, data.code):
raise HTTPException(status_code=400, detail=f"编码已存在: {data.code}")
try:
row = await DatabaseConnectionService.create(db, data.model_dump())
await db.commit()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return DatabaseConnectionService.to_response_dict(row)
@router.get("/get/all", response_model=List[DatabaseConnectionSimpleOut], summary="获取全部启用连接")
async def get_all_connections(
application_id: Optional[str] = Query(default=None, alias="applicationId"),
db: AsyncSession = Depends(get_db),
):
configs = await DatabaseConnectionService.get_manager_configs(db, application_id)
return [
DatabaseConnectionSimpleOut(
code=item["db_name"],
name=item.get("display_name") or item["name"],
db_type=item["db_type"],
host=item["host"],
port=item["port"],
database=item["database"],
is_system=item.get("is_system", False),
)
for item in configs
]
@router.get("/check-code/{code}", summary="检查编码是否可用")
async def check_code_available(code: str, db: AsyncSession = Depends(get_db)):
if code == "default":
return {"available": False, "message": "default is reserved"}
exists = await DatabaseConnectionService.check_code_exists(db, code)
return {"available": not exists}
@router.post("/test", response_model=DatabaseConnectionTestResponse, summary="测试未保存连接")
async def test_unsaved_connection(data: DatabaseConnectionTestRequest):
try:
result = await DatabaseConnectionService.test_config(data.model_dump())
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not result.get("success"):
logger.error(
"Database connection test failed (unsaved): host=%s port=%s db_type=%s message=%s",
data.host,
data.port,
data.db_type,
result.get("message"),
)
return DatabaseConnectionTestResponse(**result)
@router.get("/{conn_id}", response_model=DatabaseConnectionResponse, summary="获取连接详情")
async def get_connection(conn_id: str, db: AsyncSession = Depends(get_db)):
row = await DatabaseConnectionService.get_by_id(db, conn_id)
if not row:
raise HTTPException(status_code=404, detail="连接不存在")
return DatabaseConnectionService.to_response_dict(row)
@router.put("/{conn_id}", response_model=DatabaseConnectionResponse, summary="更新连接")
async def update_connection(
conn_id: str,
data: DatabaseConnectionUpdate,
db: AsyncSession = Depends(get_db),
):
row = await DatabaseConnectionService.get_by_id(db, conn_id)
if not row:
raise HTTPException(status_code=404, detail="连接不存在")
update_data = data.model_dump(exclude_unset=True)
try:
row = await DatabaseConnectionService.update(db, row, update_data)
await db.commit()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return DatabaseConnectionService.to_response_dict(row)
@router.delete("/{conn_id}", response_model=ResponseModel, summary="删除连接")
async def delete_connection(conn_id: str, db: AsyncSession = Depends(get_db)):
row = await DatabaseConnectionService.get_by_id(db, conn_id)
if not row:
raise HTTPException(status_code=404, detail="连接不存在")
try:
await DatabaseConnectionService.delete(db, row)
await db.commit()
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return ResponseModel(message="删除成功")
@router.post("/{conn_id}/test", response_model=DatabaseConnectionTestResponse, summary="测试已保存连接")
async def test_saved_connection(conn_id: str, db: AsyncSession = Depends(get_db)):
row = await DatabaseConnectionService.get_by_id(db, conn_id)
if not row:
raise HTTPException(status_code=404, detail="连接不存在")
result = await DatabaseConnectionService.test_saved(db, row)
if not result.get("success"):
logger.error(
"Database connection test failed (saved): id=%s code=%s name=%s message=%s",
conn_id,
row.code,
row.name,
result.get("message"),
)
return DatabaseConnectionTestResponse(**result)
@@ -0,0 +1,30 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""数据库连接配置模型"""
from sqlalchemy import Boolean, Column, Index, Integer, JSON, String, Text
from app.base_model import BaseModel
class DatabaseConnection(BaseModel):
"""外部数据库连接配置"""
__tablename__ = "core_database_connection"
application_id = Column(String(21), nullable=True, index=True, comment="所属应用ID")
code = Column(String(50), unique=True, nullable=False, index=True, comment="连接编码")
name = Column(String(100), nullable=False, comment="连接名称")
db_type = Column(String(20), nullable=False, comment="数据库类型: postgresql/mysql/sqlserver/oracle")
host = Column(String(255), nullable=False, comment="主机")
port = Column(Integer, nullable=False, default=5432, comment="端口")
user = Column(String(100), nullable=False, default="", comment="用户名")
password_enc = Column(Text, default="", comment="加密密码")
default_database = Column(String(100), nullable=False, default="", comment="默认数据库名")
description = Column(Text, default="", comment="描述")
status = Column(Boolean, default=True, index=True, comment="是否启用")
is_system = Column(Boolean, default=False, comment="是否系统内置连接")
extra_options = Column(JSON, default=dict, comment="扩展选项")
__table_args__ = (
Index("ix_core_database_connection_app_status", "application_id", "status"),
)
@@ -0,0 +1,104 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""数据库连接解析器"""
import logging
import time
from typing import Dict, Optional, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import AsyncSessionLocal
from core.database_connection.model import DatabaseConnection
from core.database_connection.types import ConnectionInfo
from core.database_manager.service import parse_database_url
from utils.secret_crypto import decrypt_secret
logger = logging.getLogger(__name__)
_CACHE: Dict[str, Tuple[ConnectionInfo, float]] = {}
_CACHE_TTL = 60.0
class ConnectionResolver:
@staticmethod
def invalidate_cache(code: Optional[str] = None) -> None:
if code:
_CACHE.pop(code, None)
else:
_CACHE.clear()
@staticmethod
def default_connection_info() -> ConnectionInfo:
db_info = parse_database_url(settings.DATABASE_URL or "")
if not db_info:
raise ValueError("Invalid DATABASE_URL configuration")
return ConnectionInfo(
code="default",
db_type=db_info["db_type"],
host=db_info["host"],
port=db_info["port"],
user=db_info["user"],
password=db_info["password"],
database=db_info["database"],
is_system=True,
display_name=db_info["database"] or "default",
)
@classmethod
async def resolve(
cls,
code: str,
db: Optional[AsyncSession] = None,
) -> ConnectionInfo:
if code == "default":
return cls.default_connection_info()
now = time.time()
cached = _CACHE.get(code)
if cached and now - cached[1] < _CACHE_TTL:
return cached[0]
if db is None:
async with AsyncSessionLocal() as session:
info = await cls._resolve_from_db(session, code)
else:
info = await cls._resolve_from_db(db, code)
_CACHE[code] = (info, now)
return info
@classmethod
async def _resolve_from_db(cls, db: AsyncSession, code: str) -> ConnectionInfo:
result = await db.execute(
select(DatabaseConnection).where(
DatabaseConnection.code == code,
DatabaseConnection.is_deleted == False,
)
)
row = result.scalar_one_or_none()
if not row or not row.status:
raise ValueError(f"Database connection '{code}' not found or disabled")
if row.is_system:
return cls.default_connection_info()
password = decrypt_secret(row.password_enc or "")
if row.password_enc and not password:
logger.error(
"Failed to decrypt password for connection code=%s (check DB_CONN_SECRET_KEY / JWT_SECRET_KEY)",
row.code,
)
return ConnectionInfo(
code=row.code,
db_type=row.db_type,
host=row.host,
port=row.port,
user=row.user or "",
password=password,
database=row.default_database or "",
is_system=False,
display_name=row.name,
extra_options=row.extra_options or {},
)
@@ -0,0 +1,94 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""数据库连接 Schema"""
from typing import Any, Dict, Optional
from pydantic import BaseModel, Field
from app.base_schema import CSTDatetime
class DatabaseConnectionBase(BaseModel):
name: str = Field(..., max_length=100, description="连接名称")
code: str = Field(..., max_length=50, description="连接编码")
db_type: str = Field(..., description="postgresql / mysql / sqlserver / oracle")
host: str = Field(..., max_length=255)
port: int = Field(default=5432, ge=1, le=65535)
user: str = Field(default="", max_length=100)
default_database: str = Field(default="", max_length=100, alias="defaultDatabase")
description: str = Field(default="")
status: bool = Field(default=True)
application_id: Optional[str] = Field(default=None, alias="applicationId")
extra_options: Optional[Dict[str, Any]] = Field(default_factory=dict, alias="extraOptions")
model_config = {"populate_by_name": True}
class DatabaseConnectionCreate(DatabaseConnectionBase):
password: str = Field(default="", description="密码(明文,仅创建时传入)")
class DatabaseConnectionUpdate(BaseModel):
name: Optional[str] = None
db_type: Optional[str] = None
host: Optional[str] = None
port: Optional[int] = Field(default=None, ge=1, le=65535)
user: Optional[str] = None
password: Optional[str] = Field(default=None, description="空则不修改")
default_database: Optional[str] = Field(default=None, alias="defaultDatabase")
description: Optional[str] = None
status: Optional[bool] = None
extra_options: Optional[Dict[str, Any]] = Field(default=None, alias="extraOptions")
model_config = {"populate_by_name": True}
class DatabaseConnectionResponse(BaseModel):
id: str
code: str
name: str
db_type: str
host: str
port: int
user: str
default_database: str
description: str
status: bool
is_system: bool
has_password: bool
application_id: Optional[str] = None
application_name: str = ""
extra_options: Dict[str, Any] = Field(default_factory=dict)
sort: int = 0
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
model_config = {"from_attributes": True}
class DatabaseConnectionSimpleOut(BaseModel):
code: str
name: str
db_type: str
host: str
port: int
database: str
is_system: bool = False
class DatabaseConnectionTestRequest(BaseModel):
db_type: str
host: str
port: int = 5432
user: str = ""
password: str = ""
default_database: str = Field(default="", alias="defaultDatabase")
model_config = {"populate_by_name": True}
class DatabaseConnectionTestResponse(BaseModel):
success: bool
message: str
db_name: Optional[str] = None
db_type: Optional[str] = None
@@ -0,0 +1,274 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""数据库连接服务"""
import logging
import re
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.timezone import format_datetime
from core.database_connection.model import DatabaseConnection
from core.database_connection.resolver import ConnectionResolver
from core.database_connection.types import ConnectionInfo
from core.database_manager.service import AsyncDatabaseManagerService, parse_database_url
from utils.secret_crypto import encrypt_secret
logger = logging.getLogger(__name__)
RESOURCE_TYPE = "database_connection"
RESOURCE_DISPLAY_NAME = "数据库连接管理"
_CODE_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]{0,49}$")
_ALLOWED_DB_TYPES = {"postgresql", "mysql", "sqlserver", "oracle"}
_DEFAULT_PORTS = {
"postgresql": 5432,
"mysql": 3306,
"sqlserver": 1433,
"oracle": 1521,
}
def _default_port(db_type: str) -> int:
return _DEFAULT_PORTS.get(db_type, 5432)
class DatabaseConnectionService:
@staticmethod
def validate_code(code: str) -> None:
if code == "default":
raise ValueError("code 'default' is reserved for system connection")
if not _CODE_PATTERN.match(code):
raise ValueError("Invalid connection code format")
@staticmethod
def validate_db_type(db_type: str) -> None:
if db_type not in _ALLOWED_DB_TYPES:
raise ValueError(f"Unsupported db_type: {db_type}")
@classmethod
async def check_code_exists(cls, db: AsyncSession, code: str, exclude_id: str = None) -> bool:
stmt = select(DatabaseConnection.id).where(
DatabaseConnection.code == code,
DatabaseConnection.is_deleted == False,
)
if exclude_id:
stmt = stmt.where(DatabaseConnection.id != exclude_id)
result = await db.execute(stmt)
return result.scalar_one_or_none() is not None
@classmethod
async def get_list(
cls,
db: AsyncSession,
page: int = 1,
page_size: int = 20,
application_id: str = None,
name: str = None,
code: str = None,
status: bool = None,
) -> Tuple[List[DatabaseConnection], int]:
stmt = select(DatabaseConnection).where(DatabaseConnection.is_deleted == False)
if application_id:
stmt = stmt.where(DatabaseConnection.application_id == application_id)
else:
stmt = stmt.where(DatabaseConnection.application_id.is_(None))
if name:
stmt = stmt.where(DatabaseConnection.name.contains(name))
if code:
stmt = stmt.where(DatabaseConnection.code.contains(code))
if status is not None:
stmt = stmt.where(DatabaseConnection.status == status)
count_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(count_stmt)).scalar() or 0
stmt = stmt.order_by(
DatabaseConnection.sort.desc(),
DatabaseConnection.sys_create_datetime.desc(),
)
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
items = list((await db.execute(stmt)).scalars().all())
return items, total
@classmethod
async def get_all_enabled(
cls,
db: AsyncSession,
application_id: str = None,
) -> List[DatabaseConnection]:
stmt = select(DatabaseConnection).where(
DatabaseConnection.is_deleted == False,
DatabaseConnection.status == True,
DatabaseConnection.is_system == False,
)
if application_id:
stmt = stmt.where(DatabaseConnection.application_id == application_id)
else:
stmt = stmt.where(DatabaseConnection.application_id.is_(None))
stmt = stmt.order_by(DatabaseConnection.sort.desc(), DatabaseConnection.name.asc())
return list((await db.execute(stmt)).scalars().all())
@classmethod
async def get_by_id(cls, db: AsyncSession, conn_id: str) -> Optional[DatabaseConnection]:
result = await db.execute(
select(DatabaseConnection).where(
DatabaseConnection.id == conn_id,
DatabaseConnection.is_deleted == False,
)
)
return result.scalar_one_or_none()
@classmethod
async def get_by_code(cls, db: AsyncSession, code: str) -> Optional[DatabaseConnection]:
result = await db.execute(
select(DatabaseConnection).where(
DatabaseConnection.code == code,
DatabaseConnection.is_deleted == False,
)
)
return result.scalar_one_or_none()
@classmethod
async def create(cls, db: AsyncSession, data: Dict[str, Any]) -> DatabaseConnection:
cls.validate_code(data["code"])
cls.validate_db_type(data["db_type"])
row = DatabaseConnection(
application_id=data.get("application_id"),
code=data["code"],
name=data["name"],
db_type=data["db_type"],
host=data["host"],
port=data.get("port") or _default_port(data["db_type"]),
user=data.get("user") or "",
password_enc=encrypt_secret((data.get("password") or "").strip()),
default_database=data.get("default_database") or "",
description=data.get("description") or "",
status=data.get("status", True),
is_system=False,
extra_options=data.get("extra_options") or {},
)
db.add(row)
await db.flush()
await db.refresh(row)
return row
@classmethod
async def update(cls, db: AsyncSession, row: DatabaseConnection, data: Dict[str, Any]) -> DatabaseConnection:
if row.is_system:
raise ValueError("System connection cannot be modified")
if data.get("db_type"):
cls.validate_db_type(data["db_type"])
for field in ("name", "db_type", "host", "port", "user", "default_database", "description", "status"):
if field in data and data[field] is not None:
setattr(row, field, data[field])
if data.get("extra_options") is not None:
row.extra_options = data["extra_options"]
if data.get("password"):
row.password_enc = encrypt_secret(str(data["password"]).strip())
await db.flush()
await db.refresh(row)
ConnectionResolver.invalidate_cache(row.code)
return row
@classmethod
async def delete(cls, db: AsyncSession, row: DatabaseConnection) -> None:
if row.is_system or row.code == "default":
raise ValueError("System connection cannot be deleted")
row.is_deleted = True
await db.flush()
ConnectionResolver.invalidate_cache(row.code)
@classmethod
async def test_connection_info(cls, info: ConnectionInfo) -> Dict[str, Any]:
service = AsyncDatabaseManagerService.from_connection_info(info)
result = await service.test_connection()
if not result.get("success"):
logger.error(
"Database connection registry test failed: code=%s message=%s",
info.code,
result.get("message"),
)
return result
@classmethod
async def test_config(cls, config: Dict[str, Any]) -> Dict[str, Any]:
db_type = config["db_type"]
cls.validate_db_type(db_type)
info = ConnectionInfo(
code="__test__",
db_type=db_type,
host=config["host"],
port=config.get("port") or _default_port(db_type),
user=config.get("user") or "",
password=config.get("password") or "",
database=config.get("default_database") or "",
)
return await cls.test_connection_info(info)
@classmethod
async def test_saved(cls, db: AsyncSession, row: DatabaseConnection) -> Dict[str, Any]:
info = await ConnectionResolver.resolve(row.code, db)
return await cls.test_connection_info(info)
@classmethod
def to_response_dict(cls, row: DatabaseConnection) -> Dict[str, Any]:
return {
"id": row.id,
"code": row.code,
"name": row.name,
"db_type": row.db_type,
"host": row.host,
"port": row.port,
"user": row.user or "",
"default_database": row.default_database or "",
"description": row.description or "",
"status": row.status,
"is_system": row.is_system,
"has_password": bool(row.password_enc),
"application_id": row.application_id,
"extra_options": row.extra_options or {},
"sort": row.sort or 0,
"sys_create_datetime": format_datetime(row.sys_create_datetime) if row.sys_create_datetime else None,
"sys_update_datetime": format_datetime(row.sys_update_datetime) if row.sys_update_datetime else None,
}
@classmethod
async def get_manager_configs(
cls,
db: AsyncSession,
application_id: str = None,
) -> List[Dict[str, Any]]:
configs: List[Dict[str, Any]] = []
default_info = ConnectionResolver.default_connection_info()
configs.append({
"db_name": "default",
"name": default_info.display_name or default_info.database or "default",
"display_name": default_info.display_name or default_info.database or "default",
"db_type": default_info.db_type,
"host": default_info.host,
"port": default_info.port,
"database": default_info.database,
"user": default_info.user,
"has_password": bool(default_info.password),
"is_system": True,
})
custom_rows = await cls.get_all_enabled(db, application_id)
for row in custom_rows:
configs.append({
"db_name": row.code,
"name": row.name,
"display_name": row.name,
"db_type": row.db_type,
"host": row.host,
"port": row.port,
"database": row.default_database or "",
"user": row.user or "",
"has_password": bool(row.password_enc),
"is_system": False,
})
return configs
@@ -0,0 +1,28 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""数据库连接信息类型"""
from dataclasses import dataclass
from typing import Any, Dict, Optional
@dataclass
class ConnectionInfo:
code: str
db_type: str
host: str
port: int
user: str
password: str
database: str
is_system: bool = False
display_name: str = ""
extra_options: Optional[Dict[str, Any]] = None
def to_handler_kwargs(self) -> Dict[str, Any]:
return {
"host": self.host,
"port": self.port,
"user": self.user,
"password": self.password,
"database": self.database,
}