P0安全: SECRET_KEY/ADMIN_PASSWORD/CORS环境变量化, seed仅admin P1Mock: mock.ts 301->88行, 删8数据mock+5generateMock, net-1300行 P2组件: useToast/useFetchList+ConfirmDialog/FormDialog P3菜单: menuSections/menuItems->constants/menu.ts P4API: NotificationsPage/WorkflowPage重写+RolesPage演示 测试: 1050passed, tsc clean
251 lines
11 KiB
Python
251 lines
11 KiB
Python
"""统一查询 API — 支持 MySQL、PostgreSQL、Redis、MongoDB 的只读查询。
|
||
|
||
核心设计:
|
||
- 一个入口查询所有数据库,减少学习成本
|
||
- 自动审计记录所有查询(谁在什么时间查了什么)
|
||
- 自动记录查询历史(方便复用)
|
||
- 数据脱敏保护敏感信息
|
||
- 完整错误处理和人性化提示
|
||
"""
|
||
import time
|
||
from fastapi import APIRouter, Depends, HTTPException, Query as QueryParam
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.database import get_db
|
||
from app.core.security import get_current_user
|
||
from app.models.user import User
|
||
from app.models.instance import Instance
|
||
from app.models.audit_log import AuditLog
|
||
from app.schemas.query import QueryRequest, UnifiedQueryResult
|
||
from app.services.query_service import QueryService
|
||
from app.services.data_masker import DataMasker, load_masking_rules
|
||
from app.services.audit_log_service import AuditLogService
|
||
from app.services.query_history_service import QueryHistoryService
|
||
from app.core.crypto import decrypt_password
|
||
from app.core.permissions import check_instance_access, check_instance_permission
|
||
|
||
router = APIRouter(prefix="/query", tags=["统一查询"])
|
||
|
||
|
||
@router.post("", response_model=UnifiedQueryResult)
|
||
async def execute_query(
|
||
req: QueryRequest,
|
||
mask: bool = QueryParam(True, description="是否启用数据脱敏"),
|
||
save_history: bool = QueryParam(True, description="是否保存到查询历史"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""执行统一查询(只读)— 支持 MySQL、PostgreSQL、Redis、MongoDB。
|
||
|
||
**一个入口,查询所有数据库。** 系统会根据数据源类型自动路由到对应的查询引擎,
|
||
并进行只读安全验证。
|
||
|
||
**自动审计**: 每次查询都会被记录在案,包含查询内容、耗时、返回行数等。
|
||
|
||
**查询历史**: 自动保存查询记录,方便后续复用。
|
||
|
||
**不同数据库的查询格式:**
|
||
- **MySQL/PostgreSQL**: 标准 SQL SELECT 语句,如 `SELECT * FROM users LIMIT 10`
|
||
- **Redis**: 命令字符串,如 `GET mykey`、`HGETALL myhash`、`LRANGE mylist 0 -1`
|
||
- **MongoDB**: JSON 格式查询,如:
|
||
```json
|
||
{"collection": "users", "operation": "find",
|
||
"filter": {"age": {"$gt": 18}}, "limit": 10}
|
||
```
|
||
|
||
Args:
|
||
req: 查询请求(instance_id + database + sql)
|
||
mask: 是否启用数据脱敏(默认 True)
|
||
save_history: 是否保存到查询历史(默认 True)
|
||
|
||
Returns:
|
||
UnifiedQueryResult: 统一格式的查询结果
|
||
"""
|
||
if current_user.role == "guest":
|
||
raise HTTPException(status_code=403, detail="访客无权执行查询,请联系管理员申请权限")
|
||
|
||
# ── 系统设置集成 ──
|
||
from app.api.v1.endpoints.settings import get_setting_value
|
||
require_approval = await get_setting_value(db, "query_require_approval", False)
|
||
max_limit = await get_setting_value(db, "query_max_limit", 10000)
|
||
query_timeout = await get_setting_value(db, "query_timeout", 30)
|
||
max_length = await get_setting_value(db, "query_max_length", 10000)
|
||
auto_limit = await get_setting_value(db, "query_auto_limit", True)
|
||
enable_masking = await get_setting_value(db, "query_enable_masking", True)
|
||
save_hist = await get_setting_value(db, "query_save_history", True)
|
||
|
||
# 查询审批检查
|
||
if require_approval and current_user.role not in ("admin", "dba"):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="当前系统已开启查询审批,请通过工单提交查询申请"
|
||
)
|
||
|
||
# 查询语句长度检查
|
||
if len(req.sql) > max_length:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"查询语句过长({len(req.sql)} 字符),上限 {max_length} 字符"
|
||
)
|
||
|
||
# 查询实例信息
|
||
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)
|
||
|
||
# P1: 操作级权限检查
|
||
await check_instance_permission(current_user, instance, "query:select", db)
|
||
|
||
# 解密密码
|
||
instance.password = decrypt_password(instance.password)
|
||
|
||
# 记录开始时间
|
||
start_time = time.time()
|
||
is_success = True
|
||
error_msg = None
|
||
|
||
try:
|
||
query_result = await QueryService.execute_query(
|
||
instance, req.database, req.sql, current_user.id, req.options
|
||
)
|
||
except ValueError as e:
|
||
is_success = False
|
||
error_msg = str(e)
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except Exception as e:
|
||
is_success = False
|
||
error_msg = f"{type(e).__name__}: {e}"
|
||
raise HTTPException(status_code=500, detail=f"查询执行失败: {e}")
|
||
finally:
|
||
# 无论成功失败都记录审计日志
|
||
elapsed_ms = int((time.time() - start_time) * 1000)
|
||
|
||
# 1. 记录审计日志(完整上下文)
|
||
try:
|
||
username = current_user.display_name or current_user.username
|
||
await AuditLogService.log(
|
||
db=db,
|
||
user_id=current_user.id,
|
||
action="QUERY",
|
||
target_type="query",
|
||
target_id=instance.id,
|
||
detail=f"查询{'成功' if is_success else '失败'}: {req.sql[:200]}",
|
||
)
|
||
# 直接写入增强字段(AuditLogService.log只写基础字段)
|
||
log_entry = AuditLog(
|
||
user_id=current_user.id,
|
||
username=username,
|
||
action="QUERY",
|
||
target_type="query",
|
||
target_id=instance.id,
|
||
instance_name=instance.name,
|
||
db_type=instance.db_type,
|
||
database_name=req.database or instance.database,
|
||
query_content=req.sql[:2000],
|
||
query_type=getattr(query_result, 'query_type', 'UNKNOWN') if is_success else None,
|
||
execution_time_ms=elapsed_ms,
|
||
rows_affected=getattr(query_result, 'total', 0) if is_success else 0,
|
||
result_status="success" if is_success else "failed",
|
||
detail=f"查询{'成功' if is_success else '失败'}: {req.sql[:200]}{f', 错误: {error_msg}' if error_msg else ''}",
|
||
)
|
||
db.add(log_entry)
|
||
await db.commit()
|
||
except Exception:
|
||
pass # 审计记录失败不影响查询结果
|
||
|
||
# 数据脱敏处理
|
||
if is_success and mask and query_result.rows and instance.db_type in ("mysql", "postgres", "postgresql"):
|
||
try:
|
||
if req.database:
|
||
masking_rules = await load_masking_rules(db, req.instance_id, req.database)
|
||
if masking_rules:
|
||
masker = DataMasker(masking_rules)
|
||
from app.services.sql_analyzer import SQLAnalyzer
|
||
analyzer = SQLAnalyzer()
|
||
analysis = analyzer.analyze(req.sql)
|
||
tables = analysis.tables if analysis.tables else ["*"]
|
||
table_name = tables[0] if tables else "*"
|
||
if masker.has_rules_for_table(table_name):
|
||
query_result.rows = masker.mask_rows(table_name, query_result.rows)
|
||
except Exception:
|
||
pass # 脱敏失败不影响查询结果
|
||
|
||
# 记录查询历史(人性化:方便用户复用)
|
||
if is_success and save_history:
|
||
try:
|
||
username = current_user.display_name or current_user.username
|
||
await QueryHistoryService.record_query(
|
||
user_id=current_user.id,
|
||
username=username,
|
||
instance_id=instance.id,
|
||
instance_name=instance.name,
|
||
db_type=instance.db_type,
|
||
database_name=req.database or instance.database,
|
||
query_content=req.sql,
|
||
query_type=getattr(query_result, 'query_type', 'QUERY'),
|
||
execution_time_ms=elapsed_ms,
|
||
result_rows=getattr(query_result, 'total', 0),
|
||
is_success=is_success,
|
||
error_message=error_msg,
|
||
)
|
||
except Exception:
|
||
pass # 历史记录失败不影响查询结果
|
||
|
||
return query_result
|
||
|
||
|
||
@router.get("/supported-types")
|
||
async def supported_db_types(
|
||
current_user: User = Depends(get_current_user),
|
||
):
|
||
"""获取支持的数据库类型和查询格式说明。
|
||
|
||
人性化设计:帮助用户了解如何查询不同类型的数据库。
|
||
"""
|
||
return {
|
||
"types": [
|
||
{
|
||
"type": "mysql",
|
||
"name": "MySQL",
|
||
"query_format": "标准 SQL SELECT 语句",
|
||
"example": "SELECT id, name FROM users WHERE status = 'active' LIMIT 10",
|
||
"security": "AST语法分析验证只读,禁止DDL/DML",
|
||
"features": ["语法高亮", "EXPLAIN分析", "查询建议", "数据脱敏"],
|
||
},
|
||
{
|
||
"type": "postgres",
|
||
"name": "PostgreSQL",
|
||
"query_format": "标准 SQL SELECT 语句",
|
||
"example": "SELECT id, name FROM users WHERE status = 'active' LIMIT 10",
|
||
"security": "AST语法分析验证只读,禁止DDL/DML",
|
||
"features": ["语法高亮", "EXPLAIN分析", "查询建议", "数据脱敏"],
|
||
},
|
||
{
|
||
"type": "redis",
|
||
"name": "Redis",
|
||
"query_format": "Redis 命令字符串",
|
||
"example": "HGETALL user:1001",
|
||
"security": "130+只读命令白名单验证",
|
||
"features": ["命令自动补全", "结果格式化", "键值浏览"],
|
||
"readonly_commands": [
|
||
"GET", "MGET", "HGET", "HGETALL", "LRANGE", "SMEMBERS",
|
||
"ZRANGE", "SCAN", "KEYS", "INFO", "TTL", "TYPE", "EXISTS",
|
||
],
|
||
},
|
||
{
|
||
"type": "mongo",
|
||
"name": "MongoDB",
|
||
"query_format": "JSON 格式查询",
|
||
"example": '{"collection": "users", "operation": "find", "filter": {}, "limit": 10}',
|
||
"security": "6个只读操作白名单:find/findOne/countDocuments/distinct/aggregate",
|
||
"features": ["JSON格式化", "结果表格化", "集合浏览"],
|
||
"readonly_operations": ["find", "findOne", "countDocuments", "estimatedDocumentCount", "distinct", "aggregate"],
|
||
},
|
||
]
|
||
}
|