- P0: 实例访问控制全线接入权限组 (829测试) - P1: permissions字段激活 + 操作级权限检查 (843测试) - 前端: 删除数据权限页面, 权限组合并为唯一入口 - 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析 - 新增: MongoDB管理页面 + Dashboard增强 - 修复: Pydantic V2迁移 + 前端TS错误修复
414 lines
15 KiB
Python
414 lines
15 KiB
Python
"""查询历史服务 — 记录和管理用户查询历史。
|
|
|
|
支持:
|
|
- 自动记录每次查询(内容、耗时、结果数、数据库类型)
|
|
- 查询历史分页查看
|
|
- 收藏常用查询
|
|
- 快捷查询(个人/团队共享)
|
|
- 查询统计(高频表、查询趋势)
|
|
|
|
人性化设计:
|
|
- 自动记录查询历史,用户无需手动保存
|
|
- 支持收藏常用查询,一键复用
|
|
- 快捷查询模板,减少重复输入
|
|
- 查询统计帮助用户了解自己的数据使用习惯
|
|
"""
|
|
from typing import List, Optional, Dict, Any
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timedelta
|
|
from collections import Counter
|
|
|
|
|
|
@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 = ""
|
|
|
|
|
|
class QueryHistoryService:
|
|
"""查询历史服务。
|
|
|
|
内存存储实现,生产环境应替换为数据库存储。
|
|
"""
|
|
|
|
_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
|
|
async def record_query(
|
|
cls,
|
|
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,
|
|
) -> QueryHistoryEntry:
|
|
"""记录一次查询。
|
|
|
|
每次查询自动调用,无需用户干预。
|
|
"""
|
|
cls._history_counter += 1
|
|
|
|
entry = QueryHistoryEntry(
|
|
id=cls._history_counter,
|
|
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,
|
|
created_at=datetime.now().isoformat(),
|
|
)
|
|
|
|
cls._history.append(entry)
|
|
|
|
# 限制每个用户的历史记录数量
|
|
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]
|
|
|
|
return entry
|
|
|
|
@classmethod
|
|
async def get_history(
|
|
cls,
|
|
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]:
|
|
"""获取用户查询历史。"""
|
|
entries = [h for h in cls._history if h.user_id == user_id]
|
|
|
|
if db_type:
|
|
entries = [h for h in entries if h.db_type == db_type]
|
|
if instance_id:
|
|
entries = [h for h in entries if h.instance_id == instance_id]
|
|
if keyword:
|
|
kw = keyword.lower()
|
|
entries = [h for h in entries if kw in h.query_content.lower()]
|
|
|
|
total = len(entries)
|
|
entries.sort(key=lambda x: x.created_at, reverse=True)
|
|
|
|
start = (page - 1) * page_size
|
|
end = start + page_size
|
|
page_entries = entries[start:end]
|
|
|
|
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[: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,
|
|
"note": e.note,
|
|
"created_at": e.created_at,
|
|
}
|
|
for e in page_entries
|
|
],
|
|
}
|
|
|
|
@classmethod
|
|
async def toggle_favorite(cls, 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 # 已收藏
|
|
|
|
@classmethod
|
|
async def get_favorites(
|
|
cls, 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)
|
|
|
|
total = len(entries)
|
|
start = (page - 1) * page_size
|
|
end = start + page_size
|
|
|
|
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[:200],
|
|
"query_type": e.query_type,
|
|
"execution_time_ms": e.execution_time_ms,
|
|
"result_rows": e.result_rows,
|
|
"created_at": e.created_at,
|
|
}
|
|
for e in entries[start:end]
|
|
],
|
|
}
|
|
|
|
# ── 快捷查询 ──
|
|
|
|
@classmethod
|
|
async def create_quick_query(
|
|
cls,
|
|
name: str,
|
|
description: str,
|
|
query_content: str,
|
|
db_type: str,
|
|
database_pattern: str,
|
|
is_shared: bool,
|
|
created_by: int,
|
|
created_by_name: str,
|
|
) -> QuickQuery:
|
|
"""创建快捷查询。"""
|
|
cls._quick_counter += 1
|
|
qq = QuickQuery(
|
|
id=cls._quick_counter,
|
|
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,
|
|
created_at=datetime.now().isoformat(),
|
|
)
|
|
cls._quick_queries.append(qq)
|
|
return qq
|
|
|
|
@classmethod
|
|
async def get_quick_queries(
|
|
cls,
|
|
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
|
|
|
|
@classmethod
|
|
async def use_quick_query(cls, 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
|
|
|
|
@classmethod
|
|
async def get_query_statistics(cls, user_id: int) -> Dict[str, Any]:
|
|
"""获取用户查询统计。
|
|
|
|
人性化:帮助用户了解自己的数据使用习惯。
|
|
"""
|
|
entries = [h for h in cls._history if h.user_id == user_id]
|
|
|
|
if not entries:
|
|
return {"message": "暂无查询记录"}
|
|
|
|
# 按数据库类型分布
|
|
db_type_dist = Counter(h.db_type for h in entries)
|
|
|
|
# 高频查询表(从SQL中提取表名)
|
|
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)
|
|
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")
|
|
count = sum(
|
|
1 for h in entries
|
|
if h.created_at.startswith(day)
|
|
)
|
|
daily_counts[day] = count
|
|
|
|
# 平均执行时间
|
|
avg_time = sum(h.execution_time_ms for h in entries) / len(entries)
|
|
|
|
# 总查询次数
|
|
total_queries = len(entries)
|
|
|
|
return {
|
|
"total_queries": total_queries,
|
|
"avg_execution_time_ms": round(avg_time, 2),
|
|
"db_type_distribution": dict(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
|
|
),
|
|
}
|
|
|
|
# ── 预置快捷查询模板 ──
|
|
|
|
@classmethod
|
|
def seed_default_quick_queries(cls):
|
|
"""预置默认快捷查询模板。"""
|
|
defaults = [
|
|
QuickQuery(
|
|
id=-1, 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="查看指定表的列信息",
|
|
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慢查询日志",
|
|
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="当前数据库连接情况",
|
|
query_content="SHOW PROCESSLIST", db_type="mysql", database_pattern="*",
|
|
is_shared=True, created_by=0, created_by_name="系统",
|
|
),
|
|
QuickQuery(
|
|
id=-5, 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="系统",
|
|
),
|
|
QuickQuery(
|
|
id=-6, 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命令",
|
|
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列出所有表",
|
|
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="查找集合中的文档",
|
|
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="统计集合文档数量",
|
|
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()
|