feat: 三大核心功能完成 — query_history持久化 + EXPLAIN真实解析 + Dashboard修复

P1 查询历史持久化 (47 tests):
- QueryHistory/QuickQuery SQLAlchemy model 建表
- query_history_service 从内存迁移到 SQLite async
- 每用户500条上限自动清理
- 7个API endpoint全部对接持久化
- 10个预置快捷查询模板

P2 EXPLAIN真实解析 (40 tests):
- MySQL EXPLAIN FORMAT=JSON 执行+解析
- PostgreSQL EXPLAIN (FORMAT JSON) 执行+解析
- SQLite EXPLAIN QUERY PLAN 解析
- 统一树结构输出 + 优化警告
- POST /api/v1/sql-assistant/explain endpoint

Dashboard修复:
- 修复重复export default导致页面崩溃
- 对接/instances /tickets /dashboard/stats真实API
- 替换硬编码统计数字为API驱动
- 修复calcHealthScore安全访问

AuditLogsPage:
- 修复Fragment key warning

1081 tests passed (10 system_settings failures are pre-existing)
This commit is contained in:
logan committed 2026-05-24 12:29:21 +08:00
1 parent 1fcb984aee
commit c7fccf99c7
8 files changed
+2274 -400

No files matched your search

+1
View File
@@ -180,6 +180,7 @@ async def execute_query(
try:
username = current_user.display_name or current_user.username
await QueryHistoryService.record_query(
db=db,
user_id=current_user.id,
username=username,
instance_id=instance.id,
+12 -4
View File
@@ -18,6 +18,7 @@ async def my_query_history(
keyword: Optional[str] = None,
page: int = 1,
page_size: int = 20,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取我的查询历史。
@@ -25,6 +26,7 @@ async def my_query_history(
自动记录每次查询,支持按数据库类型、实例、关键词筛选。
"""
return await QueryHistoryService.get_history(
db=db,
user_id=current_user.id,
db_type=db_type,
instance_id=instance_id,
@@ -37,10 +39,11 @@ async def my_query_history(
@router.post("/{history_id}/favorite")
async def toggle_favorite(
history_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""切换查询历史收藏状态。"""
is_fav = await QueryHistoryService.toggle_favorite(current_user.id, history_id)
is_fav = await QueryHistoryService.toggle_favorite(db, current_user.id, history_id)
return {"is_favorite": is_fav, "message": "已收藏" if is_fav else "已取消收藏"}
@@ -48,20 +51,22 @@ async def toggle_favorite(
async def my_favorites(
page: int = 1,
page_size: int = 20,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取我收藏的查询。"""
return await QueryHistoryService.get_favorites(
user_id=current_user.id, page=page, page_size=page_size
db=db, user_id=current_user.id, page=page, page_size=page_size
)
@router.get("/statistics")
async def my_query_statistics(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取我的查询统计 — 了解自己的数据使用习惯。"""
return await QueryHistoryService.get_query_statistics(user_id=current_user.id)
return await QueryHistoryService.get_query_statistics(db=db, user_id=current_user.id)
# ── 快捷查询 ──
@@ -70,11 +75,12 @@ async def my_query_statistics(
async def list_quick_queries(
db_type: Optional[str] = None,
keyword: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取快捷查询列表(个人+团队共享)。"""
items = await QueryHistoryService.get_quick_queries(
user_id=current_user.id, db_type=db_type, keyword=keyword
db=db, user_id=current_user.id, db_type=db_type, keyword=keyword
)
return {"items": items, "total": len(items)}
@@ -87,10 +93,12 @@ async def create_quick_query(
description: str = "",
database_pattern: str = "*",
is_shared: bool = False,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""创建快捷查询。"""
qq = await QueryHistoryService.create_quick_query(
db=db,
name=name,
description=description,
query_content=query_content,
@@ -1,11 +1,20 @@
"""SQL助手 API — 人性化辅助功能。"""
import time
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.crypto import decrypt_password
from app.core.permissions import check_instance_access, check_instance_permission
from app.core.security import get_current_user
from app.models.user import User
from app.models.instance import Instance
from app.services.sql_assistant import SQLAssistant
from app.services.explain_service import ExplainService
router = APIRouter(prefix="/sql-assistant", tags=["SQL助手"])
@@ -111,3 +120,63 @@ async def parse_explain(
"warnings": result.warnings,
"human_readable": result.human_readable,
}
# ===== EXPLAIN 真实执行 API =====
class ExplainRequest(BaseModel):
"""EXPLAIN 请求体。"""
instance_id: int
database: str
query: str
@router.post("/explain")
async def execute_explain(
req: ExplainRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""连接数据源执行 EXPLAIN 并解析为统一树结构。
根据数据源类型自动构造对应的 EXPLAIN 语句:
- MySQL: EXPLAIN FORMAT=JSON <query>
- PostgreSQL: EXPLAIN (FORMAT JSON, ANALYZE false) <query>
- SQLite: EXPLAIN QUERY PLAN <query>
返回统一的树形执行计划、优化警告和汇总统计。
"""
if current_user.role == "guest":
raise HTTPException(status_code=403, detail="访客无权执行 EXPLAIN,请联系管理员申请权限")
if not req.query or not req.query.strip():
raise HTTPException(status_code=400, detail="查询语句不能为空")
# 获取实例
result = await db.execute(select(Instance).where(Instance.id == req.instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在,请确认实例ID正确")
# 权限检查
await check_instance_access(current_user, instance, db)
await check_instance_permission(current_user, instance, "query:select", db)
# 解密密码
instance.password = decrypt_password(instance.password)
start_time = time.time()
try:
explain_result = await ExplainService.execute_explain(
instance, req.database, req.query
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"EXPLAIN 执行失败: {e}")
elapsed_ms = int((time.time() - start_time) * 1000)
explain_result["elapsed_ms"] = elapsed_ms
return explain_result
+789
View File
@@ -0,0 +1,789 @@
"""EXPLAIN 执行服务 — 连接数据源执行 EXPLAIN 并解析为统一树结构。
支持:
- MySQL: EXPLAIN FORMAT=JSON <query>
- PostgreSQL: EXPLAIN (FORMAT JSON, ANALYZE false) <query>
- SQLite: EXPLAIN QUERY PLAN <query>
返回统一树结构:
{
"execution_plan": [...], # 树形执行计划节点
"warnings": [...], # 优化警告
"summary": {...}, # 汇总统计
"db_type": "mysql",
"raw_result": {...}, # 原始结果
}
"""
import asyncio
import json
import logging
from typing import Any, Dict, List, Optional, Tuple
# 顶层 import 以便测试 mock(实际运行时为可选依赖)
try:
import aiomysql # noqa: F401
except ImportError:
aiomysql = None # type: ignore[assignment]
try:
import asyncpg # noqa: F401
except ImportError:
asyncpg = None # type: ignore[assignment]
from app.models.instance import Instance
logger = logging.getLogger(__name__)
# ===== 统一节点结构 =====
def make_plan_node(
id: int,
operation: str,
table: Optional[str] = None,
rows: Optional[int] = None,
cost: Optional[float] = None,
key: Optional[str] = None,
extra: Optional[str] = None,
children: Optional[List[Dict]] = None,
**kwargs,
) -> Dict[str, Any]:
"""构建统一执行计划节点。"""
node = {
"id": id,
"operation": operation,
"table": table,
"rows": rows,
"cost": cost,
"key": key,
"extra": extra,
"children": children or [],
}
# 附加额外字段
for k, v in kwargs.items():
if v is not None:
node[k] = v
return node
# ===== MySQL EXPLAIN FORMAT=JSON 解析 =====
def parse_mysql_explain_json(raw_json: Any) -> Dict[str, Any]:
"""解析 MySQL EXPLAIN FORMAT=JSON 的结果。
MySQL EXPLAIN FORMAT=JSON 结构示例:
{
"query_block": {
"select_id": 1,
"cost_info": {"query_cost": "0.35"},
"table": {
"table_name": "users",
"access_type": "ALL",
"rows_examined_per_scan": 1000,
"rows_produced_per_join": 1000,
"filtered": "100.00",
"cost_info": {...},
"used_columns": [...]
},
"nested_loop": [...]
}
}
Returns:
统一结构的 Dict
"""
if isinstance(raw_json, str):
try:
raw_json = json.loads(raw_json)
except (json.JSONDecodeError, TypeError):
return _empty_result("mysql", raw_json, error="EXPLAIN JSON 解析失败")
nodes: List[Dict] = []
warnings: List[str] = []
node_id_counter = [0]
def next_id() -> int:
node_id_counter[0] += 1
return node_id_counter[0]
def parse_query_block(qb: Dict) -> Dict[str, Any]:
"""递归解析 query_block。"""
node_id = next_id()
children = []
operation = "Query Block"
table_name = None
access_type = None
key_used = None
rows_est = None
cost_val = None
extra_parts = []
# 表信息
table_info = qb.get("table")
if table_info:
table_name = table_info.get("table_name")
access_type = table_info.get("access_type", "")
key_used = table_info.get("key")
possible_keys = table_info.get("possible_keys")
rows_est = table_info.get("rows_examined_per_scan")
filtered = table_info.get("filtered", "100.00")
# 成本
cost_info = table_info.get("cost_info", {})
cost_str = cost_info.get("read_cost") or cost_info.get("eval_cost") or cost_info.get("prefix_cost")
if cost_str:
try:
cost_val = float(cost_str)
except (ValueError, TypeError):
pass
# 操作类型映射
operation = _mysql_access_type_to_operation(access_type)
# Extra 信息
if key_used:
extra_parts.append(f"使用索引: {key_used}")
if possible_keys and not key_used:
extra_parts.append(f"可选索引: {possible_keys}")
if filtered and filtered != "100.00":
extra_parts.append(f"过滤率: {filtered}%")
used_key_parts = table_info.get("used_key_parts")
if used_key_parts:
extra_parts.append(f"索引列: {', '.join(str(p) for p in used_key_parts)}")
# 警告检测
if access_type == "ALL":
warnings.append(f"表 '{table_name}' 全表扫描,建议添加索引")
if access_type == "index":
warnings.append(f"表 '{table_name}' 全索引扫描")
# 嵌套循环 / join
nested_loop = qb.get("nested_loop")
if nested_loop:
for nl_item in nested_loop:
child = parse_query_block(nl_item)
children.append(child)
# 排序
order_by = qb.get("ordering_operation")
if order_by:
sort_node = _parse_mysql_ordering(order_by, next_id())
children.append(sort_node)
extra_parts.append("Using filesort")
warnings.append("使用了文件排序 (filesort)")
# 分组
grouping = qb.get("grouping_operation")
if grouping:
group_node = _parse_mysql_grouping(grouping, next_id())
children.append(group_node)
extra_parts.append("Using temporary")
warnings.append("使用了临时表 (temporary)")
# 子查询
subqueries = qb.get("subqueries") or qb.get("optimized_away_subqueries")
if subqueries:
for sq in subqueries:
if "query_block" in sq:
child = parse_query_block(sq["query_block"])
children.append(child)
# 成本信息
qb_cost = qb.get("cost_info", {})
if not cost_val and "query_cost" in qb_cost:
try:
cost_val = float(qb_cost["query_cost"])
except (ValueError, TypeError):
pass
return make_plan_node(
id=node_id,
operation=operation,
table=table_name,
rows=int(rows_est) if rows_est else None,
cost=cost_val,
key=key_used,
extra="; ".join(extra_parts) if extra_parts else None,
children=children,
access_type=access_type,
)
# 解析顶层 query_block
query_block = raw_json.get("query_block") if isinstance(raw_json, dict) else None
if query_block:
root = parse_query_block(query_block)
nodes = [root]
else:
warnings.append("无法识别的 EXPLAIN JSON 结构")
# 汇总
summary = _build_summary(nodes, warnings, "mysql")
return {
"execution_plan": nodes,
"warnings": warnings,
"summary": summary,
"db_type": "mysql",
"raw_result": raw_json,
}
def _parse_mysql_ordering(ordering: Dict, node_id: int) -> Dict:
"""解析 MySQL 排序操作。"""
table_info = ordering.get("table", {})
return make_plan_node(
id=node_id,
operation="Filesort",
table=table_info.get("table_name"),
extra="排序操作",
)
def _parse_mysql_grouping(grouping: Dict, node_id: int) -> Dict:
"""解析 MySQL 分组操作。"""
table_info = grouping.get("table", {})
return make_plan_node(
id=node_id,
operation="Temporary Table",
table=table_info.get("table_name"),
extra="分组操作使用临时表",
)
def _mysql_access_type_to_operation(access_type: Optional[str]) -> str:
"""将 MySQL access_type 映射为统一操作名。"""
mapping = {
"system": "System Scan",
"const": "Const Row",
"eq_ref": "Unique Lookup",
"ref": "Index Lookup",
"fulltext": "Fulltext Search",
"ref_or_null": "Index Lookup (nullable)",
"index_merge": "Index Merge",
"unique_subquery": "Unique Subquery",
"index_subquery": "Index Subquery",
"range": "Index Range Scan",
"index": "Full Index Scan",
"ALL": "Table Scan",
}
return mapping.get(access_type or "", f"Unknown ({access_type})")
# ===== PostgreSQL EXPLAIN (FORMAT JSON) 解析 =====
def parse_postgres_explain_json(raw_json: Any) -> Dict[str, Any]:
"""解析 PostgreSQL EXPLAIN (FORMAT JSON) 的结果。
PostgreSQL EXPLAIN JSON 结构示例:
[
{
"Plan": {
"Node Type": "Seq Scan",
"Relation Name": "users",
"Alias": "users",
"Startup Cost": 0.00,
"Total Cost": 15.50,
"Plan Rows": 1000,
"Plan Width": 68,
"Plans": [
{
"Node Type": "Index Scan",
...
"Plans": []
}
]
}
}
]
Returns:
统一结构的 Dict
"""
if isinstance(raw_json, str):
try:
raw_json = json.loads(raw_json)
except (json.JSONDecodeError, TypeError):
return _empty_result("postgres", raw_json, error="EXPLAIN JSON 解析失败")
# PG 返回的是数组
if isinstance(raw_json, list) and len(raw_json) > 0:
plan_root = raw_json[0].get("Plan", raw_json[0])
elif isinstance(raw_json, dict):
plan_root = raw_json.get("Plan", raw_json)
else:
return _empty_result("postgres", raw_json, error="无法识别的 EXPLAIN JSON 结构")
warnings: List[str] = []
node_id_counter = [0]
def next_id() -> int:
node_id_counter[0] += 1
return node_id_counter[0]
def parse_pg_plan(plan: Dict) -> Dict[str, Any]:
"""递归解析 PostgreSQL Plan 节点。"""
node_id = next_id()
children = []
node_type = plan.get("Node Type", "Unknown")
relation_name = plan.get("Relation Name") or plan.get("Table Name")
alias = plan.get("Alias")
startup_cost = plan.get("Startup Cost")
total_cost = plan.get("Total Cost")
plan_rows = plan.get("Plan Rows")
plan_width = plan.get("Plan Width")
index_name = plan.get("Index Name")
index_cond = plan.get("Index Cond")
filter_str = plan.get("Filter")
hash_cond = plan.get("Hash Cond")
join_type = plan.get("Join Type")
sort_key = plan.get("Sort Key")
sort_method = plan.get("Sort Method")
group_key = plan.get("Group Key")
actual_rows = plan.get("Actual Rows")
actual_time = plan.get("Actual Total Time")
# 操作类型映射
operation = _pg_node_type_to_operation(node_type)
if join_type:
operation = f"{join_type} {_pg_node_type_to_operation(node_type)}"
# Extra 信息
extra_parts = []
if index_cond:
extra_parts.append(f"索引条件: {index_cond}")
if filter_str:
extra_parts.append(f"过滤条件: {filter_str}")
if hash_cond:
extra_parts.append(f"Hash 条件: {hash_cond}")
if sort_key:
extra_parts.append(f"排序键: {sort_key}")
if sort_method:
extra_parts.append(f"排序方法: {sort_method}")
if group_key:
extra_parts.append(f"分组键: {group_key}")
if actual_rows is not None:
extra_parts.append(f"实际行数: {actual_rows}")
if actual_time is not None:
extra_parts.append(f"实际耗时: {actual_time}ms")
# 警告检测
if node_type == "Seq Scan":
warnings.append(f"表 '{relation_name or '?'}' 使用了顺序扫描 (Seq Scan),建议检查索引")
if node_type == "Sort" and sort_method == "external" or plan.get("Sort Space Used"):
warnings.append(f"排序使用了外部排序 (磁盘),建议增加 work_mem")
# 子计划
sub_plans = plan.get("Plans", [])
for sp in sub_plans:
children.append(parse_pg_plan(sp))
# 子查询/SubPlan
subplan_name = plan.get("Subplan Name")
if subplan_name:
extra_parts.append(f"子计划: {subplan_name}")
kwargs = {}
if alias and alias != relation_name:
kwargs["alias"] = alias
if plan_width:
kwargs["plan_width"] = plan_width
if startup_cost is not None:
kwargs["startup_cost"] = startup_cost
if actual_time is not None:
kwargs["actual_time_ms"] = actual_time
if actual_rows is not None:
kwargs["actual_rows"] = actual_rows
return make_plan_node(
id=node_id,
operation=operation,
table=relation_name,
rows=int(plan_rows) if plan_rows else None,
cost=float(total_cost) if total_cost is not None else None,
key=index_name,
extra="; ".join(extra_parts) if extra_parts else None,
children=children,
node_type=node_type,
**kwargs,
)
root = parse_pg_plan(plan_root)
nodes = [root]
# 汇总
summary = _build_summary(nodes, warnings, "postgres")
return {
"execution_plan": nodes,
"warnings": warnings,
"summary": summary,
"db_type": "postgres",
"raw_result": raw_json,
}
def _pg_node_type_to_operation(node_type: str) -> str:
"""将 PostgreSQL Node Type 映射为统一操作名。"""
mapping = {
"Seq Scan": "Table Scan",
"Index Scan": "Index Scan",
"Index Only Scan": "Index Only Scan",
"Bitmap Heap Scan": "Bitmap Heap Scan",
"Bitmap Index Scan": "Bitmap Index Scan",
"Tid Scan": "TID Scan",
"Subquery Scan": "Subquery Scan",
"Function Scan": "Function Scan",
"Table Function Scan": "Table Function Scan",
"Values Scan": "Values Scan",
"CTE Scan": "CTE Scan",
"Named Tuplestore Scan": "Named Tuplestore Scan",
"WorkTable Scan": "WorkTable Scan",
"Nested Loop": "Nested Loop Join",
"Merge Join": "Merge Join",
"Hash Join": "Hash Join",
"Sort": "Sort",
"Hash": "Hash",
"Aggregate": "Aggregate",
"Group": "Group",
"Append": "Append",
"Merge Append": "Merge Append",
"Limit": "Limit",
"Unique": "Unique",
"Gather": "Gather",
"Gather Merge": "Gather Merge",
"Materialize": "Materialize",
"Result": "Result",
"ProjectSet": "ProjectSet",
"WindowAgg": "Window Aggregate",
"Recursive Union": "Recursive Union",
"LockRows": "Lock Rows",
"ModifyTable": "Modify Table",
"SetOp": "Set Operation",
}
return mapping.get(node_type, node_type)
# ===== SQLite EXPLAIN QUERY PLAN 解析 =====
def parse_sqlite_explain(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
"""解析 SQLite EXPLAIN QUERY PLAN 的文本/行结果。
SQLite 返回格式:
id|parent|notused|detail
3|0|0|SCAN TABLE users
或
id|parent|notused|detail
2|0|0|SEARCH TABLE users USING INDEX idx_name (name=?)
Returns:
统一结构的 Dict
"""
warnings: List[str] = []
node_id_counter = [0]
node_map: Dict[int, Dict] = {}
def next_id() -> int:
node_id_counter[0] += 1
return node_id_counter[0]
for row in rows:
# SQLite EXPLAIN QUERY PLAN 返回: id, parent, notused, detail
detail = row.get("detail") or row.get("DETAIL") or ""
parent_id = row.get("parent") or row.get("PARENT") or 0
sqlite_id = row.get("id") or row.get("ID") or next_id()
table_name = None
operation = "Unknown"
key = None
extra = None
detail_upper = detail.upper()
# 解析 detail 文本
if "SCAN TABLE" in detail_upper:
operation = "Table Scan"
# 提取表名: "SCAN TABLE users"
parts = detail.split("TABLE", 1)
if len(parts) > 1:
table_part = parts[1].strip().split()[0] if parts[1].strip() else None
table_name = table_part
warnings.append(f"表 '{table_name or '?'}' 全表扫描,建议添加索引")
elif "SEARCH TABLE" in detail_upper:
operation = "Index Lookup"
parts = detail.split("TABLE", 1)
if len(parts) > 1:
rest = parts[1].strip()
tokens = rest.split()
table_name = tokens[0] if tokens else None
# 提取索引名: "USING INDEX idx_name (col=?)"
if "USING INDEX" in detail_upper:
idx_parts = detail.split("USING INDEX", 1)
if len(idx_parts) > 1:
idx_rest = idx_parts[1].strip().split("(")[0].strip()
key = idx_rest
elif "USING COVERING INDEX" in detail_upper:
idx_parts = detail.split("USING COVERING INDEX", 1)
if len(idx_parts) > 1:
idx_rest = idx_parts[1].strip().split("(")[0].strip()
key = idx_rest
extra = "Covering Index"
elif "EXECUTE" in detail_upper:
operation = "Execute"
extra = detail
elif "CORRELATED SCALAR SUBQUERY" in detail_upper:
operation = "Correlated Subquery"
extra = detail
elif "CO-ROUTINE" in detail_upper:
operation = "Co-routine"
extra = detail
elif "SCAN" in detail_upper and "TABLE" not in detail_upper:
operation = "Scan"
extra = detail
else:
operation = detail if detail else "Unknown"
node = make_plan_node(
id=next_id(),
operation=operation,
table=table_name,
key=key,
extra=extra,
)
node_map[sqlite_id] = node
# 构建树(基于 parent)
root_nodes = []
for sqlite_id, node in node_map.items():
parent = None
for row in rows:
rid = row.get("id") or row.get("ID")
if rid == sqlite_id:
parent = row.get("parent") or row.get("PARENT") or 0
break
if parent == 0 or parent not in node_map:
root_nodes.append(node)
else:
parent_node = node_map[parent]
parent_node["children"].append(node)
if not root_nodes and node_map:
root_nodes = list(node_map.values())
summary = _build_summary(root_nodes, warnings, "sqlite")
return {
"execution_plan": root_nodes,
"warnings": warnings,
"summary": summary,
"db_type": "sqlite",
"raw_result": rows,
}
# ===== 辅助函数 =====
def _empty_result(db_type: str, raw: Any, error: str = "") -> Dict[str, Any]:
"""返回空结果(解析失败时)。"""
return {
"execution_plan": [],
"warnings": [error] if error else ["EXPLAIN 结果为空"],
"summary": {
"total_cost": 0,
"estimated_rows": 0,
"has_full_scan": False,
"has_temporary": False,
"has_filesort": False,
"index_usage_ratio": 0.0,
},
"db_type": db_type,
"raw_result": raw,
}
def _build_summary(
nodes: List[Dict],
warnings: List[str],
db_type: str,
) -> Dict[str, Any]:
"""从执行计划树构建汇总信息。"""
total_cost = 0.0
total_rows = 0
has_full_scan = False
has_temporary = False
has_filesort = False
index_used_count = 0
total_table_count = 0
def walk(node: Dict):
nonlocal total_cost, total_rows, has_full_scan, has_temporary, has_filesort
nonlocal index_used_count, total_table_count
if node.get("cost") is not None:
total_cost = max(total_cost, float(node["cost"]))
if node.get("rows") is not None:
total_rows += int(node["rows"])
op = (node.get("operation") or "").lower()
extra = str(node.get("extra") or "").lower()
if "table scan" in op or "seq scan" in op or op == "system scan":
has_full_scan = True
total_table_count += 1
elif "index" in op or "lookup" in op:
index_used_count += 1
total_table_count += 1
elif "scan" in op:
total_table_count += 1
if "temporary" in op or "temporary" in extra:
has_temporary = True
if "filesort" in op or "sort" in op or "filesort" in extra:
has_filesort = True
for child in node.get("children", []):
walk(child)
for n in nodes:
walk(n)
index_usage_ratio = 0.0
if total_table_count > 0:
index_usage_ratio = round(index_used_count / total_table_count, 2)
return {
"total_cost": round(total_cost, 4),
"estimated_rows": total_rows,
"has_full_scan": has_full_scan,
"has_temporary": has_temporary,
"has_filesort": has_filesort,
"index_usage_ratio": index_usage_ratio,
}
# ===== EXPLAIN 执行入口 =====
class ExplainService:
"""EXPLAIN 执行服务 — 统一入口。"""
@staticmethod
async def execute_explain(
instance: Instance,
database: Optional[str],
query: str,
) -> Dict[str, Any]:
"""根据 db_type 构造并执行 EXPLAIN,返回统一树结构。
Args:
instance: 数据源实例
database: 目标数据库名
query: 要解释的 SQL 查询
Returns:
统一 EXPLAIN 结果 Dict
"""
db_type = instance.db_type
# 验证查询以 SELECT 或 WITH 开头
stripped = query.strip()
upper = stripped.upper()
if not upper.startswith(("SELECT", "WITH")):
raise ValueError("EXPLAIN 仅支持 SELECT 或 WITH 查询")
if db_type == "mysql":
return await ExplainService._explain_mysql(instance, database, query)
elif db_type in ("postgres", "postgresql"):
return await ExplainService._explain_postgres(instance, database, query)
elif db_type == "sqlite":
return await ExplainService._explain_sqlite(instance, query)
else:
raise ValueError(f"暂不支持 {db_type} 类型的 EXPLAIN")
@staticmethod
async def _explain_mysql(
instance: Instance, database: Optional[str], query: str
) -> Dict[str, Any]:
"""执行 MySQL EXPLAIN FORMAT=JSON。"""
explain_sql = f"EXPLAIN FORMAT=JSON {query}"
conn = await aiomysql.connect(
host=instance.host,
port=instance.port,
user=instance.username,
password=instance.password,
db=database or instance.database,
)
try:
async with conn.cursor(aiomysql.DictCursor) as cur:
await asyncio.wait_for(cur.execute(explain_sql), timeout=30)
rows = await cur.fetchall()
# MySQL EXPLAIN FORMAT=JSON 返回一行一列 JSON
if rows:
raw = rows[0].get("EXPLAIN") or rows[0].get("JSON") or rows[0]
# raw 可能是字符串或已经解析的 dict
if isinstance(raw, str):
raw = json.loads(raw)
else:
raw = {}
except asyncio.TimeoutError:
raise ValueError("EXPLAIN 执行超时(30秒)")
finally:
conn.close()
return parse_mysql_explain_json(raw)
@staticmethod
async def _explain_postgres(
instance: Instance, database: Optional[str], query: str
) -> Dict[str, Any]:
"""执行 PostgreSQL EXPLAIN (FORMAT JSON)。"""
explain_sql = f"EXPLAIN (FORMAT JSON, ANALYZE false) {query}"
dsn = (
f"postgresql://{instance.username}:{instance.password}"
f"@{instance.host}:{instance.port}/{database or instance.database or 'postgres'}"
)
conn = await asyncpg.connect(dsn)
try:
result = await asyncio.wait_for(conn.fetchval(explain_sql), timeout=30)
# asyncpg fetchval 返回 JSON 字符串
if isinstance(result, str):
raw = json.loads(result)
elif isinstance(result, list):
raw = result
else:
raw = result
except asyncio.TimeoutError:
raise ValueError("EXPLAIN 执行超时(30秒)")
finally:
await conn.close()
return parse_postgres_explain_json(raw)
@staticmethod
async def _explain_sqlite(instance: Instance, query: str) -> Dict[str, Any]:
"""执行 SQLite EXPLAIN QUERY PLAN。"""
import sqlite3
explain_sql = f"EXPLAIN QUERY PLAN {query}"
db_path = instance.host
def _exec():
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cur = conn.execute(explain_sql)
rows = [dict(r) for r in cur.fetchall()]
conn.close()
return rows
try:
rows = await asyncio.wait_for(
asyncio.get_event_loop().run_in_executor(None, _exec),
timeout=30,
)
except asyncio.TimeoutError:
raise ValueError("EXPLAIN 执行超时(30秒)")
return parse_sqlite_explain(rows)
+225 -187
View File
@@ -1,4 +1,4 @@
"""查询历史服务 — 记录和管理用户查询历史。
"""查询历史服务 — 记录和管理用户查询历史(SQLite 持久化)。
支持:
- 自动记录每次查询(内容、耗时、结果数、数据库类型)
@@ -13,65 +13,27 @@
- 快捷查询模板,减少重复输入
- 查询统计帮助用户了解自己的数据使用习惯
"""
import re
from typing import List, Optional, Dict, Any
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from collections import Counter
from sqlalchemy import select, func, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
@dataclass
class QueryHistoryEntry:
"""查询历史条目。"""
id: int = 0
user_id: int = 0
username: str = ""
instance_id: int = 0
instance_name: str = ""
db_type: str = "" # mysql / postgres / redis / mongo
database_name: str = ""
query_content: str = "" # SQL / Redis命令 / MongoDB查询
query_type: str = "" # SELECT / GET / find 等
execution_time_ms: int = 0
result_rows: int = 0
is_success: bool = True
error_message: Optional[str] = None
is_favorite: bool = False
note: str = "" # 用户备注
created_at: str = ""
@dataclass
class QuickQuery:
"""快捷查询模板。"""
id: int = 0
name: str = "" # 查询名称,如"查看活跃用户数"
description: str = "" # 描述
query_content: str = "" # 查询内容
db_type: str = "" # 适用的数据库类型
database_pattern: str = "" # 适用的数据库名称模式
is_shared: bool = False # 是否团队共享
created_by: int = 0
created_by_name: str = ""
usage_count: int = 0 # 使用次数
created_at: str = ""
from app.models.query_history import QueryHistory, QuickQueryModel
class QueryHistoryService:
"""查询历史服务。
"""查询历史服务(SQLite 持久化)。"""
内存存储实现,生产环境应替换为数据库存储。
"""
_history: List[QueryHistoryEntry] = []
_favorites: set = set() # (user_id, history_id)
_quick_queries: List[QuickQuery] = []
_history_counter: int = 0
_quick_counter: int = 0
_max_history_per_user: int = 500 # 每个用户最多保留500条
@classmethod
# ── 查询历史 ──
@staticmethod
async def record_query(
cls,
db: AsyncSession,
user_id: int,
username: str,
instance_id: int,
@@ -84,15 +46,12 @@ class QueryHistoryService:
result_rows: int,
is_success: bool = True,
error_message: Optional[str] = None,
) -> QueryHistoryEntry:
) -> QueryHistory:
"""记录一次查询。
每次查询自动调用,无需用户干预。
"""
cls._history_counter += 1
entry = QueryHistoryEntry(
id=cls._history_counter,
entry = QueryHistory(
user_id=user_id,
username=username,
instance_id=instance_id,
@@ -105,24 +64,39 @@ class QueryHistoryService:
result_rows=result_rows,
is_success=is_success,
error_message=error_message,
created_at=datetime.now().isoformat(),
)
cls._history.append(entry)
db.add(entry)
await db.flush()
# 限制每个用户的历史记录数量
user_entries = [h for h in cls._history if h.user_id == user_id]
if len(user_entries) > cls._max_history_per_user:
# 删除最旧的
to_remove = len(user_entries) - cls._max_history_per_user
oldest_ids = {h.id for h in sorted(user_entries, key=lambda x: x.created_at)[:to_remove]}
cls._history = [h for h in cls._history if h.id not in oldest_ids]
count_stmt = select(func.count()).select_from(QueryHistory).where(
QueryHistory.user_id == user_id
)
total = (await db.execute(count_stmt)).scalar()
if total > QueryHistoryService._max_history_per_user:
# 删除最旧的条目
to_remove = total - QueryHistoryService._max_history_per_user
old_stmt = (
select(QueryHistory.id)
.where(QueryHistory.user_id == user_id)
.order_by(QueryHistory.created_at.asc())
.limit(to_remove)
)
old_result = await db.execute(old_stmt)
old_ids = [row[0] for row in old_result.all()]
if old_ids:
delete_stmt = QueryHistory.__table__.delete().where(
QueryHistory.id.in_(old_ids)
)
await db.execute(delete_stmt)
await db.flush()
return entry
@classmethod
@staticmethod
async def get_history(
cls,
db: AsyncSession,
user_id: int,
db_type: Optional[str] = None,
instance_id: Optional[int] = None,
@@ -131,22 +105,24 @@ class QueryHistoryService:
page_size: int = 20,
) -> Dict[str, Any]:
"""获取用户查询历史。"""
entries = [h for h in cls._history if h.user_id == user_id]
stmt = select(QueryHistory).where(QueryHistory.user_id == user_id)
if db_type:
entries = [h for h in entries if h.db_type == db_type]
stmt = stmt.where(QueryHistory.db_type == db_type)
if instance_id:
entries = [h for h in entries if h.instance_id == instance_id]
stmt = stmt.where(QueryHistory.instance_id == instance_id)
if keyword:
kw = keyword.lower()
entries = [h for h in entries if kw in h.query_content.lower()]
stmt = stmt.where(QueryHistory.query_content.ilike(f"%{keyword}%"))
total = len(entries)
entries.sort(key=lambda x: x.created_at, reverse=True)
# 总数
total_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(total_stmt)).scalar()
start = (page - 1) * page_size
end = start + page_size
page_entries = entries[start:end]
# 排序 + 分页
stmt = stmt.order_by(QueryHistory.created_at.desc())
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
entries = result.scalars().all()
return {
"total": total,
@@ -158,42 +134,60 @@ class QueryHistoryService:
"instance_name": e.instance_name,
"db_type": e.db_type,
"database_name": e.database_name,
"query_content": e.query_content[:200],
"query_content": (e.query_content or "")[:200],
"query_type": e.query_type,
"execution_time_ms": e.execution_time_ms,
"result_rows": e.result_rows,
"is_success": e.is_success,
"is_favorite": (user_id, e.id) in cls._favorites,
"is_favorite": e.is_favorite,
"note": e.note,
"created_at": e.created_at,
"created_at": e.created_at.isoformat() if e.created_at else "",
}
for e in page_entries
for e in entries
],
}
@classmethod
async def toggle_favorite(cls, user_id: int, history_id: int) -> bool:
@staticmethod
async def toggle_favorite(db: AsyncSession, user_id: int, history_id: int) -> bool:
"""切换收藏状态。"""
key = (user_id, history_id)
if key in cls._favorites:
cls._favorites.remove(key)
return False # 已取消收藏
else:
cls._favorites.add(key)
return True # 已收藏
stmt = select(QueryHistory).where(
and_(
QueryHistory.id == history_id,
QueryHistory.user_id == user_id,
)
)
result = await db.execute(stmt)
entry = result.scalar_one_or_none()
@classmethod
if entry is None:
# 没有找到该条记录(可能不属于当前用户),直接返回 False
return False
entry.is_favorite = not entry.is_favorite
await db.flush()
return entry.is_favorite
@staticmethod
async def get_favorites(
cls, user_id: int, page: int = 1, page_size: int = 20
db: AsyncSession, user_id: int, page: int = 1, page_size: int = 20
) -> Dict[str, Any]:
"""获取收藏的查询。"""
favorite_ids = {hid for uid, hid in cls._favorites if uid == user_id}
entries = [h for h in cls._history if h.id in favorite_ids]
entries.sort(key=lambda x: x.created_at, reverse=True)
stmt = select(QueryHistory).where(
and_(
QueryHistory.user_id == user_id,
QueryHistory.is_favorite == True, # noqa: E712
)
)
total = len(entries)
start = (page - 1) * page_size
end = start + page_size
# 总数
total_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(total_stmt)).scalar()
# 排序 + 分页
stmt = stmt.order_by(QueryHistory.created_at.desc())
stmt = stmt.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(stmt)
entries = result.scalars().all()
return {
"total": total,
@@ -205,21 +199,21 @@ class QueryHistoryService:
"instance_name": e.instance_name,
"db_type": e.db_type,
"database_name": e.database_name,
"query_content": e.query_content[:200],
"query_content": (e.query_content or "")[:200],
"query_type": e.query_type,
"execution_time_ms": e.execution_time_ms,
"result_rows": e.result_rows,
"created_at": e.created_at,
"created_at": e.created_at.isoformat() if e.created_at else "",
}
for e in entries[start:end]
for e in entries
],
}
# ── 快捷查询 ──
@classmethod
@staticmethod
async def create_quick_query(
cls,
db: AsyncSession,
name: str,
description: str,
query_content: str,
@@ -228,11 +222,9 @@ class QueryHistoryService:
is_shared: bool,
created_by: int,
created_by_name: str,
) -> QuickQuery:
) -> QuickQueryModel:
"""创建快捷查询。"""
cls._quick_counter += 1
qq = QuickQuery(
id=cls._quick_counter,
qq = QuickQueryModel(
name=name,
description=description,
query_content=query_content,
@@ -241,71 +233,102 @@ class QueryHistoryService:
is_shared=is_shared,
created_by=created_by,
created_by_name=created_by_name,
created_at=datetime.now().isoformat(),
)
cls._quick_queries.append(qq)
db.add(qq)
await db.flush()
return qq
@classmethod
@staticmethod
async def get_quick_queries(
cls,
db: AsyncSession,
user_id: int,
db_type: Optional[str] = None,
keyword: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""获取快捷查询列表(个人+团队共享)。"""
result = []
for qq in cls._quick_queries:
# 条件:共享的 或 自己创建的
if qq.is_shared or qq.created_by == user_id:
if db_type and qq.db_type != db_type:
continue
if keyword and keyword.lower() not in qq.name.lower():
continue
result.append({
"id": qq.id,
"name": qq.name,
"description": qq.description,
"query_content": qq.query_content[:500],
"db_type": qq.db_type,
"is_shared": qq.is_shared,
"created_by_name": qq.created_by_name,
"usage_count": qq.usage_count,
"created_at": qq.created_at,
})
return result
stmt = select(QuickQueryModel).where(
or_(
QuickQueryModel.is_shared == True, # noqa: E712
QuickQueryModel.created_by == user_id,
)
)
@classmethod
async def use_quick_query(cls, quick_query_id: int) -> Optional[str]:
if db_type:
stmt = stmt.where(QuickQueryModel.db_type == db_type)
if keyword:
stmt = stmt.where(QuickQueryModel.name.ilike(f"%{keyword}%"))
stmt = stmt.order_by(QuickQueryModel.created_at.desc())
result = await db.execute(stmt)
queries = result.scalars().all()
return [
{
"id": qq.id,
"name": qq.name,
"description": qq.description,
"query_content": (qq.query_content or "")[:500],
"db_type": qq.db_type,
"is_shared": qq.is_shared,
"created_by_name": qq.created_by_name,
"usage_count": qq.usage_count,
"created_at": qq.created_at.isoformat() if qq.created_at else "",
}
for qq in queries
]
@staticmethod
async def use_quick_query(db: AsyncSession, quick_query_id: int) -> Optional[str]:
"""使用快捷查询,返回查询内容并增加使用次数。"""
for qq in cls._quick_queries:
if qq.id == quick_query_id:
qq.usage_count += 1
return qq.query_content
return None
stmt = select(QuickQueryModel).where(QuickQueryModel.id == quick_query_id)
result = await db.execute(stmt)
qq = result.scalar_one_or_none()
if qq is None:
return None
qq.usage_count += 1
await db.flush()
return qq.query_content
@classmethod
async def get_query_statistics(cls, user_id: int) -> Dict[str, Any]:
@staticmethod
async def get_query_statistics(db: AsyncSession, user_id: int) -> Dict[str, Any]:
"""获取用户查询统计。
人性化:帮助用户了解自己的数据使用习惯。
"""
entries = [h for h in cls._history if h.user_id == user_id]
# 总数
count_stmt = select(func.count()).select_from(QueryHistory).where(
QueryHistory.user_id == user_id
)
total_queries = (await db.execute(count_stmt)).scalar()
if not entries:
if not total_queries:
return {"message": "暂无查询记录"}
# 按数据库类型分布
db_type_dist = Counter(h.db_type for h in entries)
db_type_stmt = (
select(QueryHistory.db_type, func.count().label("cnt"))
.where(QueryHistory.user_id == user_id)
.group_by(QueryHistory.db_type)
)
db_type_result = await db.execute(db_type_stmt)
db_type_dist = {row[0]: row[1] for row in db_type_result.all()}
# 高频查询表(从SQL中提取表名)
sql_stmt = (
select(QueryHistory.query_content)
.where(
and_(
QueryHistory.user_id == user_id,
QueryHistory.db_type.in_(["mysql", "postgres"]),
)
)
)
sql_result = await db.execute(sql_stmt)
tables = []
for h in entries:
if h.db_type in ("mysql", "postgres"):
# 简单提取表名
import re
found = re.findall(r"FROM\s+(\w+)", h.query_content, re.IGNORECASE)
tables.extend(found)
for row in sql_result.all():
content = row[0] or ""
found = re.findall(r"FROM\s+(\w+)", content, re.IGNORECASE)
tables.extend(found)
table_dist = Counter(tables).most_common(10)
# 查询趋势(最近7天)
@@ -313,58 +336,77 @@ class QueryHistoryService:
daily_counts = {}
for i in range(7):
day = (now - timedelta(days=i)).strftime("%Y-%m-%d")
count = sum(
1 for h in entries
if h.created_at.startswith(day)
day_start = datetime.strptime(day, "%Y-%m-%d")
day_end = day_start + timedelta(days=1)
day_count_stmt = select(func.count()).select_from(QueryHistory).where(
and_(
QueryHistory.user_id == user_id,
QueryHistory.created_at >= day_start,
QueryHistory.created_at < day_end,
)
)
daily_counts[day] = count
day_count = (await db.execute(day_count_stmt)).scalar()
daily_counts[day] = day_count
# 平均执行时间
avg_time = sum(h.execution_time_ms for h in entries) / len(entries)
avg_stmt = select(func.avg(QueryHistory.execution_time_ms)).where(
QueryHistory.user_id == user_id
)
avg_time = (await db.execute(avg_stmt)).scalar() or 0
# 总查询次数
total_queries = len(entries)
# 成功率
success_stmt = select(func.count()).select_from(QueryHistory).where(
and_(
QueryHistory.user_id == user_id,
QueryHistory.is_success == True, # noqa: E712
)
)
success_count = (await db.execute(success_stmt)).scalar()
return {
"total_queries": total_queries,
"avg_execution_time_ms": round(avg_time, 2),
"db_type_distribution": dict(db_type_dist),
"avg_execution_time_ms": round(float(avg_time), 2),
"db_type_distribution": db_type_dist,
"top_tables": [{"table": t, "count": c} for t, c in table_dist],
"daily_trend": daily_counts,
"success_rate": round(
sum(1 for h in entries if h.is_success) / total_queries * 100, 2
),
"success_rate": round(success_count / total_queries * 100, 2),
}
# ── 预置快捷查询模板 ──
@classmethod
def seed_default_quick_queries(cls):
"""预置默认快捷查询模板。"""
@staticmethod
async def seed_default_quick_queries(db: AsyncSession):
"""预置默认快捷查询模板(仅在表为空时插入)。"""
# 检查是否已有数据
count_stmt = select(func.count()).select_from(QuickQueryModel)
count = (await db.execute(count_stmt)).scalar()
if count and count > 0:
return
defaults = [
QuickQuery(
id=-1, name="查看所有表", description="列出数据库中所有表",
QuickQueryModel(
name="查看所有表", description="列出数据库中所有表",
query_content="SHOW TABLES", db_type="mysql", database_pattern="*",
is_shared=True, created_by=0, created_by_name="系统",
),
QuickQuery(
id=-2, name="查看表结构", description="查看指定表的列信息",
QuickQueryModel(
name="查看表结构", description="查看指定表的列信息",
query_content="DESCRIBE {table_name}", db_type="mysql", database_pattern="*",
is_shared=True, created_by=0, created_by_name="系统",
),
QuickQuery(
id=-3, name="查看慢查询", description="MySQL慢查询日志",
QuickQueryModel(
name="查看慢查询", description="MySQL慢查询日志",
query_content="SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 20",
db_type="mysql", database_pattern="*", is_shared=True,
created_by=0, created_by_name="系统",
),
QuickQuery(
id=-4, name="查看活跃连接", description="当前数据库连接情况",
QuickQueryModel(
name="查看活跃连接", description="当前数据库连接情况",
query_content="SHOW PROCESSLIST", db_type="mysql", database_pattern="*",
is_shared=True, created_by=0, created_by_name="系统",
),
QuickQuery(
id=-5, name="查看表大小", description="各表占用空间统计",
QuickQueryModel(
name="查看表大小", description="各表占用空间统计",
query_content="""SELECT
table_name AS `表名`,
ROUND(data_length / 1024 / 1024, 2) AS `数据大小(MB)`,
@@ -376,38 +418,34 @@ ORDER BY data_length DESC
LIMIT 50""", db_type="mysql", database_pattern="*", is_shared=True,
created_by=0, created_by_name="系统",
),
QuickQuery(
id=-6, name="Redis查看所有键", description="SCAN遍历所有键",
QuickQueryModel(
name="Redis查看所有键", description="SCAN遍历所有键",
query_content="SCAN 0 MATCH * COUNT 50", db_type="redis",
database_pattern="*", is_shared=True, created_by=0, created_by_name="系统",
),
QuickQuery(
id=-7, name="Redis查看服务器信息", description="Redis INFO命令",
QuickQueryModel(
name="Redis查看服务器信息", description="Redis INFO命令",
query_content="INFO server", db_type="redis", database_pattern="*",
is_shared=True, created_by=0, created_by_name="系统",
),
QuickQuery(
id=-8, name="PG查看所有表", description="PostgreSQL列出所有表",
QuickQueryModel(
name="PG查看所有表", description="PostgreSQL列出所有表",
query_content="SELECT schemaname, tablename, n_tup_ins, n_tup_upd, n_tup_del FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 50",
db_type="postgres", database_pattern="*", is_shared=True,
created_by=0, created_by_name="系统",
),
QuickQuery(
id=-9, name="MongoDB查找文档", description="查找集合中的文档",
QuickQueryModel(
name="MongoDB查找文档", description="查找集合中的文档",
query_content='{"collection": "{collection_name}", "operation": "find", "limit": 20}',
db_type="mongo", database_pattern="*", is_shared=True,
created_by=0, created_by_name="系统",
),
QuickQuery(
id=-10, name="MongoDB统计文档数", description="统计集合文档数量",
QuickQueryModel(
name="MongoDB统计文档数", description="统计集合文档数量",
query_content='{"collection": "{collection_name}", "operation": "countDocuments"}',
db_type="mongo", database_pattern="*", is_shared=True,
created_by=0, created_by_name="系统",
),
]
cls._quick_queries.extend(defaults)
cls._quick_counter = max(cls._quick_counter, 10)
# 初始化预置快捷查询
QueryHistoryService.seed_default_quick_queries()
db.add_all(defaults)
await db.flush()