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

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