Files
opsxsql/backend/app/api/v1/endpoints/query.py
T
logan 8b921c6c83 refactor: mock清理+安全配置+通用组件+菜单优化
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
2026-05-24 02:42:29 +08:00

251 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""统一查询 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"],
},
]
}