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)
452 lines
16 KiB
Python
452 lines
16 KiB
Python
"""查询历史服务 — 记录和管理用户查询历史(SQLite 持久化)。
|
|
|
|
支持:
|
|
- 自动记录每次查询(内容、耗时、结果数、数据库类型)
|
|
- 查询历史分页查看
|
|
- 收藏常用查询
|
|
- 快捷查询(个人/团队共享)
|
|
- 查询统计(高频表、查询趋势)
|
|
|
|
人性化设计:
|
|
- 自动记录查询历史,用户无需手动保存
|
|
- 支持收藏常用查询,一键复用
|
|
- 快捷查询模板,减少重复输入
|
|
- 查询统计帮助用户了解自己的数据使用习惯
|
|
"""
|
|
import re
|
|
from typing import List, Optional, Dict, Any
|
|
from datetime import datetime, timedelta
|
|
from collections import Counter
|
|
|
|
from sqlalchemy import select, func, and_, or_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.query_history import QueryHistory, QuickQueryModel
|
|
|
|
|
|
class QueryHistoryService:
|
|
"""查询历史服务(SQLite 持久化)。"""
|
|
|
|
_max_history_per_user: int = 500 # 每个用户最多保留500条
|
|
|
|
# ── 查询历史 ──
|
|
|
|
@staticmethod
|
|
async def record_query(
|
|
db: AsyncSession,
|
|
user_id: int,
|
|
username: str,
|
|
instance_id: int,
|
|
instance_name: str,
|
|
db_type: str,
|
|
database_name: str,
|
|
query_content: str,
|
|
query_type: str,
|
|
execution_time_ms: int,
|
|
result_rows: int,
|
|
is_success: bool = True,
|
|
error_message: Optional[str] = None,
|
|
) -> QueryHistory:
|
|
"""记录一次查询。
|
|
|
|
每次查询自动调用,无需用户干预。
|
|
"""
|
|
entry = QueryHistory(
|
|
user_id=user_id,
|
|
username=username,
|
|
instance_id=instance_id,
|
|
instance_name=instance_name,
|
|
db_type=db_type,
|
|
database_name=database_name,
|
|
query_content=query_content[:2000], # 限制长度
|
|
query_type=query_type,
|
|
execution_time_ms=execution_time_ms,
|
|
result_rows=result_rows,
|
|
is_success=is_success,
|
|
error_message=error_message,
|
|
)
|
|
db.add(entry)
|
|
await db.flush()
|
|
|
|
# 限制每个用户的历史记录数量
|
|
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
|
|
|
|
@staticmethod
|
|
async def get_history(
|
|
db: AsyncSession,
|
|
user_id: int,
|
|
db_type: Optional[str] = None,
|
|
instance_id: Optional[int] = None,
|
|
keyword: Optional[str] = None,
|
|
page: int = 1,
|
|
page_size: int = 20,
|
|
) -> Dict[str, Any]:
|
|
"""获取用户查询历史。"""
|
|
stmt = select(QueryHistory).where(QueryHistory.user_id == user_id)
|
|
|
|
if db_type:
|
|
stmt = stmt.where(QueryHistory.db_type == db_type)
|
|
if instance_id:
|
|
stmt = stmt.where(QueryHistory.instance_id == instance_id)
|
|
if keyword:
|
|
stmt = stmt.where(QueryHistory.query_content.ilike(f"%{keyword}%"))
|
|
|
|
# 总数
|
|
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,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"items": [
|
|
{
|
|
"id": e.id,
|
|
"instance_name": e.instance_name,
|
|
"db_type": e.db_type,
|
|
"database_name": e.database_name,
|
|
"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": e.is_favorite,
|
|
"note": e.note,
|
|
"created_at": e.created_at.isoformat() if e.created_at else "",
|
|
}
|
|
for e in entries
|
|
],
|
|
}
|
|
|
|
@staticmethod
|
|
async def toggle_favorite(db: AsyncSession, user_id: int, history_id: int) -> bool:
|
|
"""切换收藏状态。"""
|
|
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()
|
|
|
|
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(
|
|
db: AsyncSession, user_id: int, page: int = 1, page_size: int = 20
|
|
) -> Dict[str, Any]:
|
|
"""获取收藏的查询。"""
|
|
stmt = select(QueryHistory).where(
|
|
and_(
|
|
QueryHistory.user_id == user_id,
|
|
QueryHistory.is_favorite == True, # noqa: E712
|
|
)
|
|
)
|
|
|
|
# 总数
|
|
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,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"items": [
|
|
{
|
|
"id": e.id,
|
|
"instance_name": e.instance_name,
|
|
"db_type": e.db_type,
|
|
"database_name": e.database_name,
|
|
"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.isoformat() if e.created_at else "",
|
|
}
|
|
for e in entries
|
|
],
|
|
}
|
|
|
|
# ── 快捷查询 ──
|
|
|
|
@staticmethod
|
|
async def create_quick_query(
|
|
db: AsyncSession,
|
|
name: str,
|
|
description: str,
|
|
query_content: str,
|
|
db_type: str,
|
|
database_pattern: str,
|
|
is_shared: bool,
|
|
created_by: int,
|
|
created_by_name: str,
|
|
) -> QuickQueryModel:
|
|
"""创建快捷查询。"""
|
|
qq = QuickQueryModel(
|
|
name=name,
|
|
description=description,
|
|
query_content=query_content,
|
|
db_type=db_type,
|
|
database_pattern=database_pattern,
|
|
is_shared=is_shared,
|
|
created_by=created_by,
|
|
created_by_name=created_by_name,
|
|
)
|
|
db.add(qq)
|
|
await db.flush()
|
|
return qq
|
|
|
|
@staticmethod
|
|
async def get_quick_queries(
|
|
db: AsyncSession,
|
|
user_id: int,
|
|
db_type: Optional[str] = None,
|
|
keyword: Optional[str] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
"""获取快捷查询列表(个人+团队共享)。"""
|
|
stmt = select(QuickQueryModel).where(
|
|
or_(
|
|
QuickQueryModel.is_shared == True, # noqa: E712
|
|
QuickQueryModel.created_by == user_id,
|
|
)
|
|
)
|
|
|
|
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]:
|
|
"""使用快捷查询,返回查询内容并增加使用次数。"""
|
|
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
|
|
|
|
@staticmethod
|
|
async def get_query_statistics(db: AsyncSession, user_id: int) -> Dict[str, Any]:
|
|
"""获取用户查询统计。
|
|
|
|
人性化:帮助用户了解自己的数据使用习惯。
|
|
"""
|
|
# 总数
|
|
count_stmt = select(func.count()).select_from(QueryHistory).where(
|
|
QueryHistory.user_id == user_id
|
|
)
|
|
total_queries = (await db.execute(count_stmt)).scalar()
|
|
|
|
if not total_queries:
|
|
return {"message": "暂无查询记录"}
|
|
|
|
# 按数据库类型分布
|
|
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 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天)
|
|
now = datetime.now()
|
|
daily_counts = {}
|
|
for i in range(7):
|
|
day = (now - timedelta(days=i)).strftime("%Y-%m-%d")
|
|
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,
|
|
)
|
|
)
|
|
day_count = (await db.execute(day_count_stmt)).scalar()
|
|
daily_counts[day] = day_count
|
|
|
|
# 平均执行时间
|
|
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
|
|
|
|
# 成功率
|
|
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(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(success_count / total_queries * 100, 2),
|
|
}
|
|
|
|
# ── 预置快捷查询模板 ──
|
|
|
|
@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 = [
|
|
QuickQueryModel(
|
|
name="查看所有表", description="列出数据库中所有表",
|
|
query_content="SHOW TABLES", db_type="mysql", database_pattern="*",
|
|
is_shared=True, created_by=0, created_by_name="系统",
|
|
),
|
|
QuickQueryModel(
|
|
name="查看表结构", description="查看指定表的列信息",
|
|
query_content="DESCRIBE {table_name}", db_type="mysql", database_pattern="*",
|
|
is_shared=True, created_by=0, created_by_name="系统",
|
|
),
|
|
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="系统",
|
|
),
|
|
QuickQueryModel(
|
|
name="查看活跃连接", description="当前数据库连接情况",
|
|
query_content="SHOW PROCESSLIST", db_type="mysql", database_pattern="*",
|
|
is_shared=True, created_by=0, created_by_name="系统",
|
|
),
|
|
QuickQueryModel(
|
|
name="查看表大小", description="各表占用空间统计",
|
|
query_content="""SELECT
|
|
table_name AS `表名`,
|
|
ROUND(data_length / 1024 / 1024, 2) AS `数据大小(MB)`,
|
|
ROUND(index_length / 1024 / 1024, 2) AS `索引大小(MB)`,
|
|
table_rows AS `行数`
|
|
FROM information_schema.tables
|
|
WHERE table_schema = DATABASE()
|
|
ORDER BY data_length DESC
|
|
LIMIT 50""", db_type="mysql", database_pattern="*", is_shared=True,
|
|
created_by=0, created_by_name="系统",
|
|
),
|
|
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="系统",
|
|
),
|
|
QuickQueryModel(
|
|
name="Redis查看服务器信息", description="Redis INFO命令",
|
|
query_content="INFO server", db_type="redis", database_pattern="*",
|
|
is_shared=True, created_by=0, created_by_name="系统",
|
|
),
|
|
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="系统",
|
|
),
|
|
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="系统",
|
|
),
|
|
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="系统",
|
|
),
|
|
]
|
|
db.add_all(defaults)
|
|
await db.flush()
|