Skip to content

Commit d425e82

Browse files
authored
Merge pull request #550 from fastapiadmin/dev
feat(P2-1): SQL 控制台(仅外部 MySQL/PostgreSQL)
2 parents 63188c6 + 728f1bf commit d425e82

13 files changed

Lines changed: 1245 additions & 1 deletion

File tree

‎backend/app/api/v1/routers.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from app.modules.ai.chat.controller import ChatRouter
66
from app.modules.common.file.controller import FileRouter
7+
from app.modules.dbconsole.controller import DbConsoleRouter
78
from app.modules.generator.gencode.controller import GenRouter
89
from app.modules.monitor.cache.controller import CacheRouter
910
from app.modules.monitor.health.controller import HealthRouter
@@ -61,6 +62,7 @@
6162
"/ai": [ChatRouter],
6263
"/generator": [GenRouter],
6364
"/common": [FileRouter],
65+
"/dbconsole": [DbConsoleRouter],
6466
}
6567

6668
api_v1 = APIRouter()

‎backend/app/modules/dbconsole/__init__.py‎

Whitespace-only changes.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""SQL 控制台接口(仅外部数据库连接)。"""
2+
3+
from typing import Annotated
4+
5+
from fastapi import APIRouter, Body, Depends, Path, Query, Security, status
6+
from fastapi.responses import JSONResponse
7+
from sqlalchemy.ext.asyncio import AsyncSession
8+
9+
from app.common.response import ResponseSchema, SuccessResponse
10+
from app.core.base_schema import AuthSchema
11+
from app.core.dependencies import AuthPermission, db_getter
12+
from app.core.router_class import OperationLogRoute
13+
14+
from .schema import (
15+
DbConnectionCreateSchema,
16+
DbConnectionOutSchema,
17+
DbConnectionTestSchema,
18+
DbConnectionUpdateSchema,
19+
DbObjectSchema,
20+
DbQueryResultSchema,
21+
DbQuerySchema,
22+
)
23+
from .service import DbConsoleService
24+
25+
DbConsoleRouter = APIRouter(route_class=OperationLogRoute, prefix="/connection", tags=["SQL控制台"])
26+
27+
28+
@DbConsoleRouter.get("/list", summary="查询数据库连接列表", response_model=ResponseSchema[list[DbConnectionOutSchema]])
29+
async def list_db_connection_controller(
30+
auth: Annotated[AuthSchema, Security(AuthPermission(["module_dbconsole:conn:query"]))],
31+
db: Annotated[AsyncSession, Depends(db_getter)],
32+
) -> JSONResponse:
33+
result = await DbConsoleService(auth, db).get_list()
34+
return SuccessResponse(data=result, msg="查询连接列表成功")
35+
36+
37+
@DbConsoleRouter.get("/detail/{id}", summary="查询数据库连接详情", response_model=ResponseSchema[DbConnectionOutSchema])
38+
async def detail_db_connection_controller(
39+
auth: Annotated[AuthSchema, Security(AuthPermission(["module_dbconsole:conn:query"]))],
40+
db: Annotated[AsyncSession, Depends(db_getter)],
41+
id: Annotated[int, Path(description="连接ID", ge=1)],
42+
) -> JSONResponse:
43+
result = await DbConsoleService(auth, db).detail(id=id)
44+
return SuccessResponse(data=result, msg="查询连接详情成功")
45+
46+
47+
@DbConsoleRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="新增数据库连接", response_model=ResponseSchema[DbConnectionOutSchema])
48+
async def create_db_connection_controller(
49+
auth: Annotated[AuthSchema, Security(AuthPermission(["module_dbconsole:conn:create"]))],
50+
db: Annotated[AsyncSession, Depends(db_getter)],
51+
data: Annotated[DbConnectionCreateSchema, Body(description="连接配置")],
52+
) -> JSONResponse:
53+
result = await DbConsoleService(auth, db).create(data=data)
54+
return SuccessResponse(data=result, msg="新增连接成功")
55+
56+
57+
@DbConsoleRouter.put("/update/{id}", summary="修改数据库连接", response_model=ResponseSchema[DbConnectionOutSchema])
58+
async def update_db_connection_controller(
59+
auth: Annotated[AuthSchema, Security(AuthPermission(["module_dbconsole:conn:update"]))],
60+
db: Annotated[AsyncSession, Depends(db_getter)],
61+
id: Annotated[int, Path(description="连接ID", ge=1)],
62+
data: Annotated[DbConnectionUpdateSchema, Body(description="连接配置")],
63+
) -> JSONResponse:
64+
result = await DbConsoleService(auth, db).update(id=id, data=data)
65+
return SuccessResponse(data=result, msg="修改连接成功")
66+
67+
68+
@DbConsoleRouter.delete("/delete", summary="删除数据库连接", response_model=ResponseSchema[None])
69+
async def delete_db_connection_controller(
70+
auth: Annotated[AuthSchema, Security(AuthPermission(["module_dbconsole:conn:delete"]))],
71+
db: Annotated[AsyncSession, Depends(db_getter)],
72+
ids: Annotated[list[int], Body(description="连接ID列表")],
73+
) -> JSONResponse:
74+
await DbConsoleService(auth, db).delete(ids=ids)
75+
return SuccessResponse(msg="删除连接成功")
76+
77+
78+
@DbConsoleRouter.post("/test", summary="按填写中的凭据测试连接(不落库)", response_model=ResponseSchema[bool])
79+
async def test_db_connection_config_controller(
80+
auth: Annotated[AuthSchema, Security(AuthPermission(["module_dbconsole:conn:update"]))],
81+
db: Annotated[AsyncSession, Depends(db_getter)],
82+
data: Annotated[DbConnectionTestSchema, Body(description="连接配置")],
83+
) -> JSONResponse:
84+
result = await DbConsoleService(auth, db).test_connection(data)
85+
return SuccessResponse(data=result, msg="连接成功")
86+
87+
88+
@DbConsoleRouter.post("/test/{id}", summary="测试已保存的连接", response_model=ResponseSchema[bool])
89+
async def test_db_connection_controller(
90+
auth: Annotated[AuthSchema, Security(AuthPermission(["module_dbconsole:conn:query"]))],
91+
db: Annotated[AsyncSession, Depends(db_getter)],
92+
id: Annotated[int, Path(description="连接ID", ge=1)],
93+
) -> JSONResponse:
94+
result = await DbConsoleService(auth, db).test_by_id(id=id)
95+
return SuccessResponse(data=result, msg="连接成功")
96+
97+
98+
@DbConsoleRouter.get("/{id}/databases", summary="查询库/schema 列表", response_model=ResponseSchema[list[DbObjectSchema]])
99+
async def list_db_databases_controller(
100+
auth: Annotated[AuthSchema, Security(AuthPermission(["module_dbconsole:query:execute"]))],
101+
db: Annotated[AsyncSession, Depends(db_getter)],
102+
id: Annotated[int, Path(description="连接ID", ge=1)],
103+
) -> JSONResponse:
104+
result = await DbConsoleService(auth, db).list_databases(id=id)
105+
return SuccessResponse(data=result, msg="查询库列表成功")
106+
107+
108+
@DbConsoleRouter.get("/{id}/tables", summary="查询表列表", response_model=ResponseSchema[list[DbObjectSchema]])
109+
async def list_db_tables_controller(
110+
auth: Annotated[AuthSchema, Security(AuthPermission(["module_dbconsole:query:execute"]))],
111+
db: Annotated[AsyncSession, Depends(db_getter)],
112+
id: Annotated[int, Path(description="连接ID", ge=1)],
113+
database: Annotated[str | None, Query(max_length=128, description="库/schema 名")] = None,
114+
) -> JSONResponse:
115+
result = await DbConsoleService(auth, db).list_tables(id=id, database=database)
116+
return SuccessResponse(data=result, msg="查询表列表成功")
117+
118+
119+
@DbConsoleRouter.post("/{id}/query", summary="执行 SQL(只读校验;写需 confirmed)", response_model=ResponseSchema[DbQueryResultSchema])
120+
async def run_db_query_controller(
121+
auth: Annotated[AuthSchema, Security(AuthPermission(["module_dbconsole:query:execute"]))],
122+
db: Annotated[AsyncSession, Depends(db_getter)],
123+
id: Annotated[int, Path(description="连接ID", ge=1)],
124+
data: Annotated[DbQuerySchema, Body(description="SQL 与确认标记")],
125+
) -> JSONResponse:
126+
result = await DbConsoleService(auth, db).run_query(id=id, sql=data.sql, confirmed=data.confirmed)
127+
return SuccessResponse(data=result, msg="执行成功")
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from sqlalchemy.ext.asyncio import AsyncSession
2+
3+
from app.core.base_crud import CRUDBase
4+
from app.core.base_schema import AuthSchema
5+
6+
from .model import DbConnectionModel
7+
from .schema import DbConnectionCreateSchema, DbConnectionUpdateSchema
8+
9+
10+
class DbConnectionCRUD(CRUDBase[DbConnectionModel, DbConnectionCreateSchema, DbConnectionUpdateSchema]):
11+
"""SQL 控制台连接数据层"""
12+
13+
def __init__(self, auth: AuthSchema, db: AsyncSession) -> None:
14+
super().__init__(model=DbConnectionModel, auth=auth, db=db)
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""SQL 控制台:**外部**数据库连接(仅 mysql / postgres)。
2+
3+
按需求**不包含**本地/内置数据库(例如 FastHub 的 `data/fasthub.db` 这类本机 SQLite 浏览)。
4+
密码用 Fernet 加密存储(CryptoUtil),出参永不返回密码,只回 `has_password`。
5+
"""
6+
7+
from sqlalchemy import Integer, String, Text
8+
from sqlalchemy.orm import Mapped, mapped_column
9+
10+
from app.core.base_model import ModelMixin, UserMixin
11+
12+
13+
class DbConnectionModel(ModelMixin, UserMixin):
14+
"""外部数据库连接配置"""
15+
16+
__tablename__: str = "dbconsole_connection"
17+
__table_args__: dict[str, str] = {"comment": "SQL控制台外部数据库连接表"}
18+
19+
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True, comment="连接名称")
20+
db_type: Mapped[str] = mapped_column(String(16), nullable=False, index=True, comment="数据库类型(mysql/postgres)")
21+
host: Mapped[str] = mapped_column(String(255), nullable=False, comment="主机地址")
22+
port: Mapped[int] = mapped_column(Integer, nullable=False, comment="端口")
23+
username: Mapped[str] = mapped_column(String(128), nullable=False, comment="用户名")
24+
password: Mapped[str] = mapped_column(Text, nullable=False, comment="密码(Fernet加密)")
25+
database: Mapped[str | None] = mapped_column(String(128), default=None, nullable=True, comment="默认库名")
26+
status: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="状态(0:启用 1:停用)")
27+
description: Mapped[str | None] = mapped_column(Text, default=None, nullable=True, comment="备注")
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""SQL 控制台数据契约。"""
2+
3+
from typing import Any, Literal
4+
5+
from pydantic import BaseModel, ConfigDict, Field
6+
7+
8+
class DbConnectionBaseSchema(BaseModel):
9+
"""连接配置公共字段(不含密码)"""
10+
11+
name: str = Field(..., min_length=1, max_length=64, description="连接名称(唯一)")
12+
db_type: Literal["mysql", "postgres"] = Field(..., description="数据库类型(仅外部连接:mysql/postgres)")
13+
host: str = Field(..., min_length=1, max_length=255, description="主机地址")
14+
port: int = Field(..., ge=1, le=65535, description="端口")
15+
username: str = Field(..., min_length=1, max_length=128, description="用户名")
16+
database: str | None = Field(None, max_length=128, description="默认库名(可空)")
17+
description: str | None = Field(None, max_length=500, description="备注")
18+
19+
20+
class DbConnectionCreateSchema(DbConnectionBaseSchema):
21+
"""新建连接"""
22+
23+
password: str = Field(..., min_length=1, max_length=500, description="密码(加密存储)")
24+
25+
26+
class DbConnectionUpdateSchema(DbConnectionBaseSchema):
27+
"""修改连接(不传密码表示不修改)"""
28+
29+
password: str | None = Field(None, max_length=500, description="密码;不传表示不修改")
30+
31+
32+
class DbConnectionTestSchema(BaseModel):
33+
"""按填写中的凭据测试连接(不落库)"""
34+
35+
db_type: Literal["mysql", "postgres"] = Field(..., description="数据库类型")
36+
host: str = Field(..., min_length=1, max_length=255)
37+
port: int = Field(..., ge=1, le=65535)
38+
username: str = Field(..., min_length=1, max_length=128)
39+
password: str = Field(..., min_length=1, max_length=500)
40+
database: str | None = Field(None, max_length=128)
41+
42+
43+
class DbConnectionOutSchema(DbConnectionBaseSchema):
44+
"""连接出参(不含密码)"""
45+
46+
id: int = Field(..., description="连接ID")
47+
status: int = Field(..., description="状态(0:启用 1:停用)")
48+
has_password: bool = Field(False, description="是否已设置密码")
49+
50+
model_config = ConfigDict(from_attributes=True)
51+
52+
53+
class DbQuerySchema(BaseModel):
54+
"""执行 SQL"""
55+
56+
sql: str = Field(..., min_length=1, max_length=20000, description="SQL 语句")
57+
confirmed: bool = Field(False, description="写操作必须显式确认(true)")
58+
59+
60+
class DbQueryResultSchema(BaseModel):
61+
"""SQL 执行结果"""
62+
63+
sql: str = Field(..., description="实际执行的 SQL")
64+
read_only: bool = Field(..., description="是否为只读语句")
65+
columns: list[str] = Field(default_factory=list, description="列名")
66+
rows: list[list[Any]] = Field(default_factory=list, description="行(最多 200 行)")
67+
rowcount: int = Field(0, description="影响行数/返回行数")
68+
masked_columns: list[str] = Field(default_factory=list, description="已脱敏的敏感列")
69+
truncated: bool = Field(False, description="是否因行数上限被截断")
70+
elapsed_ms: int = Field(0, description="耗时(毫秒)")
71+
72+
73+
class DbObjectSchema(BaseModel):
74+
"""库/表名"""
75+
76+
name: str = Field(..., description="名称")

0 commit comments

Comments
 (0)