Files
opsxsql/backend/app/services/mongo_connector.py
T
logan 4c9427e546 sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1
- P0: 实例访问控制全线接入权限组 (829测试)
- P1: permissions字段激活 + 操作级权限检查 (843测试)
- 前端: 删除数据权限页面, 权限组合并为唯一入口
- 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析
- 新增: MongoDB管理页面 + Dashboard增强
- 修复: Pydantic V2迁移 + 前端TS错误修复
2026-05-23 14:41:06 +08:00

781 lines
26 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.
"""MongoDB 异步连接器 — 使用 motor 驱动。
支持数据库变更工单的全生命周期:
- 连接管理(支持认证、TLS)
- 只读查询: find, findOne, countDocuments, distinct, aggregate
- 变更操作: insertOne/Many, updateOne/Many, replaceOne, deleteOne/Many
- 连接测试、库/collection 列表
- 安全验证:只读操作白名单
查询格式(JSON):
{
"collection": "users",
"operation": "find",
"filter": {"age": {"$gt": 18}},
"projection": {"name": 1, "age": 1},
"sort": {"age": -1},
"limit": 100,
"skip": 0
}
变更格式(JSON):
{
"collection": "users",
"operation": "insertMany",
"documents": [{"name": "Alice", "age": 25}]
}
"""
import json
import time
from typing import Any, Dict, List, Optional, Tuple
from bson import json_util
from motor.motor_asyncio import AsyncIOMotorClient
from app.core.crypto import decrypt_password
# ── 常量定义 ─────────────────────────────────────────────────────
MONGODB_READONLY_OPS: set[str] = {
"find",
"findOne",
"countDocuments",
"estimatedDocumentCount",
"distinct",
"aggregate",
}
MONGODB_WRITE_OPS: set[str] = {
"insertOne",
"insertMany",
"updateOne",
"updateMany",
"replaceOne",
"deleteOne",
"deleteMany",
"drop",
"createIndex",
"dropIndex",
"renameCollection",
"findOneAndUpdate",
"findOneAndReplace",
"findOneAndDelete",
"bulkWrite",
}
MAX_QUERY_LIMIT = 10000
# ── 安全验证 ─────────────────────────────────────────────────────
def validate_operation(operation: str) -> Tuple[bool, str]:
"""验证操作是否为只读。
Args:
operation: MongoDB 操作名称。
Returns:
(is_readonly, error_message): 是否只读,若否附带错误原因。
"""
if operation in MONGODB_READONLY_OPS:
return True, ""
if operation in MONGODB_WRITE_OPS:
return False, f"MongoDB 操作 '{operation}' 是写操作,查询模式下禁止"
return False, f"未知的 MongoDB 操作: {operation}"
# ── 客户端缓存 ───────────────────────────────────────────────────
_mongo_clients: Dict[int, AsyncIOMotorClient] = {}
class ConnectionResult:
"""MongoDB 连接测试结果。"""
def __init__(
self,
success: bool,
version: str = "",
latency_ms: int = 0,
message: str = "",
):
self.success = success
self.version = version
self.latency_ms = latency_ms
self.message = message
def to_dict(self) -> dict:
return {
"success": self.success,
"version": self.version,
"latency_ms": self.latency_ms,
"message": self.message,
}
class MongoConnector:
"""MongoDB 异步连接器。
提供连接池管理、只读查询、变更操作、元数据获取及安全验证。
"""
# ── 连接管理 ────────────────────────────────────────────────
@staticmethod
async def get_client(instance: Any) -> AsyncIOMotorClient:
"""获取(或创建)motor 客户端实例。
使用内部缓存避免重复创建连接池。密码通过 decrypt_password 解密。
Args:
instance: Instance 模型对象,需包含 host, port, username, password 等字段。
Returns:
AsyncIOMotorClient: 异步 MongoDB 客户端。
"""
instance_id: int = int(instance.id)
if instance_id in _mongo_clients:
return _mongo_clients[instance_id]
password = decrypt_password(instance.password)
conn_str = MongoConnector._build_connection_string(
host=instance.host,
port=instance.port,
username=instance.username or None,
password=password or None,
database=getattr(instance, "database", None),
)
client = AsyncIOMotorClient(conn_str, serverSelectionTimeoutMS=5000)
_mongo_clients[instance_id] = client
return client
@staticmethod
def _build_connection_string(
host: str,
port: int,
username: Optional[str] = None,
password: Optional[str] = None,
database: Optional[str] = None,
) -> str:
"""构建 MongoDB 连接字符串。
Args:
host: 主机地址。
port: 端口号。
username: 用户名(可选)。
password: 密码(可选)。
database: 认证数据库(可选)。
Returns:
MongoDB URI 字符串。
"""
if username and password:
auth_db = database or "admin"
return (
f"mongodb://{username}:{password}@{host}:{port}/{auth_db}"
f"?authSource={auth_db}"
)
return f"mongodb://{host}:{port}"
@staticmethod
async def test_connection(instance: Any) -> ConnectionResult:
"""测试 MongoDB 连接并返回版本与延迟。
Args:
instance: Instance 模型对象。
Returns:
ConnectionResult: 包含 success, version, latency_ms, message。
"""
start = time.time()
client: AsyncIOMotorClient = await MongoConnector.get_client(instance)
try:
build_info = await client.admin.command("buildInfo")
elapsed = int((time.time() - start) * 1000)
version = build_info.get("version", "unknown")
return ConnectionResult(
success=True, version=version, latency_ms=elapsed, message="连接成功"
)
except Exception as exc:
elapsed = int((time.time() - start) * 1000)
return ConnectionResult(
success=False,
version="",
latency_ms=elapsed,
message=f"连接失败: {exc}",
)
@staticmethod
async def close_client(instance_id: int) -> None:
"""关闭指定实例的 MongoDB 客户端并从缓存中移除。
Args:
instance_id: 数据库实例 ID。
"""
client = _mongo_clients.pop(instance_id, None)
if client:
client.close()
# ── 只读查询 ────────────────────────────────────────────────
@staticmethod
async def execute_find(
instance: Any,
database: str,
collection: str,
filter_doc: Optional[Dict[str, Any]] = None,
projection: Optional[Dict[str, Any]] = None,
sort: Optional[List[tuple]] = None,
limit: int = 1000,
skip: int = 0,
) -> List[Dict[str, Any]]:
"""执行 find 查询。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
filter_doc: 查询过滤器(MongoDB 查询文档)。
projection: 字段投影。
sort: 排序规则,可以是字典 {"field": 1/-1} 或列表 [("field", 1/-1)]。
limit: 最大返回文档数(上限 MAX_QUERY_LIMIT)。
skip: 跳过的文档数。
Returns:
文档列表,每个文档为字典。_id 被序列化为字符串。
Raises:
ValueError: 当 limit 超过 MAX_QUERY_LIMIT 时。
"""
if limit > MAX_QUERY_LIMIT:
raise ValueError(f"limit 不能超过 {MAX_QUERY_LIMIT}")
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
cursor = coll.find(filter_doc or {}, projection=projection)
if sort:
if isinstance(sort, dict):
sort_list = [(k, v) for k, v in sort.items()]
cursor = cursor.sort(sort_list)
else:
cursor = cursor.sort(sort)
if skip:
cursor = cursor.skip(skip)
if limit:
cursor = cursor.limit(limit)
docs = await cursor.to_list(length=limit or MAX_QUERY_LIMIT)
return [MongoConnector._serialize_doc(doc) for doc in docs]
@staticmethod
async def execute_find_one(
instance: Any,
database: str,
collection: str,
filter_doc: Optional[Dict[str, Any]] = None,
projection: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
"""执行 findOne 查询,返回单个文档或 None。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
filter_doc: 查询过滤器。
projection: 字段投影。
Returns:
单个文档字典,或 None。_id 被序列化为字符串。
"""
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
doc = await coll.find_one(filter_doc or {}, projection=projection)
if doc is None:
return None
return MongoConnector._serialize_doc(doc)
@staticmethod
async def execute_count(
instance: Any,
database: str,
collection: str,
filter_doc: Optional[Dict[str, Any]] = None,
) -> int:
"""执行 countDocuments 计数。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
filter_doc: 查询过滤器。
Returns:
符合条件的文档数量。
"""
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
return await coll.count_documents(filter_doc or {})
@staticmethod
async def execute_distinct(
instance: Any,
database: str,
collection: str,
field: str,
filter_doc: Optional[Dict[str, Any]] = None,
) -> List[Any]:
"""获取指定字段的去重值列表。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
field: 字段名。
filter_doc: 前置过滤器。
Returns:
去重后的值列表。
"""
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
return await coll.distinct(field, filter_doc or {})
@staticmethod
async def execute_aggregate(
instance: Any,
database: str,
collection: str,
pipeline: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""执行聚合管道查询。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
pipeline: 聚合管道阶段列表。
Returns:
聚合结果文档列表。_id 被序列化为字符串。
"""
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
cursor = coll.aggregate(pipeline, allowDiskUse=True)
docs = await cursor.to_list(length=MAX_QUERY_LIMIT)
return [MongoConnector._serialize_doc(doc) for doc in docs]
# ── 变更操作(工单执行用)───────────────────────────────────
@staticmethod
async def execute_insert_one(
instance: Any,
database: str,
collection: str,
document: Dict[str, Any],
) -> str:
"""插入单个文档。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
document: 待插入的文档。
Returns:
插入文档的 ID 字符串。
"""
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
result = await coll.insert_one(document)
return str(result.inserted_id)
@staticmethod
async def execute_insert_many(
instance: Any,
database: str,
collection: str,
documents: List[Dict[str, Any]],
) -> str:
"""批量插入文档。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
documents: 待插入的文档列表。
Returns:
插入数量的描述字符串。
Raises:
ValueError: 文档列表为空时。
"""
if not documents:
raise ValueError("documents 不能为空列表")
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
result = await coll.insert_many(documents)
return f"成功插入 {len(result.inserted_ids)} 条文档"
@staticmethod
async def execute_update_one(
instance: Any,
database: str,
collection: str,
filter_doc: Dict[str, Any],
update_doc: Dict[str, Any],
) -> str:
"""更新单个文档。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
filter_doc: 查询过滤器。
update_doc: 更新操作文档(如 {"$set": {...}})。
Returns:
影响行数描述字符串。
"""
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
result = await coll.update_one(filter_doc, update_doc)
return f"匹配 {result.matched_count} 条,修改 {result.modified_count} 条"
@staticmethod
async def execute_update_many(
instance: Any,
database: str,
collection: str,
filter_doc: Dict[str, Any],
update_doc: Dict[str, Any],
) -> str:
"""批量更新文档。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
filter_doc: 查询过滤器。
update_doc: 更新操作文档。
Returns:
影响行数描述字符串。
"""
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
result = await coll.update_many(filter_doc, update_doc)
return f"匹配 {result.matched_count} 条,修改 {result.modified_count} 条"
@staticmethod
async def execute_replace_one(
instance: Any,
database: str,
collection: str,
filter_doc: Dict[str, Any],
replacement: Dict[str, Any],
) -> str:
"""替换单个文档。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
filter_doc: 查询过滤器。
replacement: 替换用的新文档。
Returns:
影响行数描述字符串。
"""
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
result = await coll.replace_one(filter_doc, replacement)
return f"匹配 {result.matched_count} 条,替换 {result.modified_count} 条"
@staticmethod
async def execute_delete_one(
instance: Any,
database: str,
collection: str,
filter_doc: Dict[str, Any],
) -> str:
"""删除单个文档。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
filter_doc: 查询过滤器。
Returns:
删除数量描述字符串。
"""
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
result = await coll.delete_one(filter_doc)
return f"删除 {result.deleted_count} 条文档"
@staticmethod
async def execute_delete_many(
instance: Any,
database: str,
collection: str,
filter_doc: Dict[str, Any],
) -> str:
"""批量删除文档。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
filter_doc: 查询过滤器。
Returns:
删除数量描述字符串。
"""
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
result = await coll.delete_many(filter_doc)
return f"删除 {result.deleted_count} 条文档"
# ── 元数据 ──────────────────────────────────────────────────
@staticmethod
async def list_databases(instance: Any) -> List[str]:
"""列出 MongoDB 实例中所有数据库名称。
Args:
instance: Instance 模型对象。
Returns:
数据库名称列表(排除系统库 admin, local, config)。
"""
client = await MongoConnector.get_client(instance)
db_info_list = await client.list_database_names()
system_dbs = {"admin", "local", "config"}
return [name for name in db_info_list if name not in system_dbs]
@staticmethod
async def list_collections(instance: Any, database: str) -> List[str]:
"""列出指定数据库中所有集合名称。
Args:
instance: Instance 模型对象。
database: 数据库名称。
Returns:
集合名称列表。
"""
client = await MongoConnector.get_client(instance)
db = client[database]
return await db.list_collection_names()
@staticmethod
async def get_collection_stats(
instance: Any, database: str, collection: str
) -> Dict[str, Any]:
"""获取集合统计信息。
Args:
instance: Instance 模型对象。
database: 数据库名称。
collection: 集合名称。
Returns:
包含 count, size, avgObjSize, storageSize, indexes, totalIndexSize 等字段的字典。
"""
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
stats = await coll.database.command("collStats", collection)
return {
"namespace": stats.get("ns", f"{database}.{collection}"),
"document_count": stats.get("count", 0),
"size_bytes": stats.get("size", 0),
"avg_document_size_bytes": stats.get("avgObjSize", 0),
"storage_size_bytes": stats.get("storageSize", 0),
"index_count": stats.get("nindexes", 0),
"index_size_bytes": stats.get("totalIndexSize", 0),
"capped": stats.get("capped", False),
}
# ── 通用查询执行 ────────────────────────────────────────────
@staticmethod
async def execute_query_json(
instance: Any,
database: str,
query_json: Dict[str, Any],
) -> Any:
"""通过 JSON 格式执行 MongoDB 查询。
自动根据 query_json["operation"] 分发到对应方法。
仅支持只读操作,写操作会被拒绝。
Args:
instance: Instance 模型对象。
database: 数据库名称。
query_json: 查询 JSON 字典,必须包含 collection 和 operation 字段。
Returns:
查询结果,类型取决于具体操作。
Raises:
ValueError: 操作不合法或参数缺失时。
"""
collection: str = query_json.get("collection")
operation: str = query_json.get("operation")
if not collection:
raise ValueError("查询 JSON 缺少 'collection' 字段")
if not operation:
raise ValueError("查询 JSON 缺少 'operation' 字段")
is_readonly, error_msg = validate_operation(operation)
if not is_readonly:
raise ValueError(error_msg)
filter_doc = query_json.get("filter") or query_json.get("query") or {}
projection = query_json.get("projection")
sort = query_json.get("sort")
limit = query_json.get("limit", 1000)
skip = query_json.get("skip", 0)
pipeline = query_json.get("pipeline", [])
field = query_json.get("field", "")
if operation == "find":
return await MongoConnector.execute_find(
instance, database, collection, filter_doc, projection, sort, limit, skip
)
if operation == "findOne":
return await MongoConnector.execute_find_one(
instance, database, collection, filter_doc, projection
)
if operation == "countDocuments":
return await MongoConnector.execute_count(
instance, database, collection, filter_doc
)
if operation == "estimatedDocumentCount":
client = await MongoConnector.get_client(instance)
coll = client[database][collection]
return await coll.estimated_document_count()
if operation == "distinct":
if not field:
raise ValueError("distinct 操作需要提供 'field' 字段")
return await MongoConnector.execute_distinct(
instance, database, collection, field, filter_doc
)
if operation == "aggregate":
if not pipeline:
raise ValueError("aggregate 操作需要提供 'pipeline' 字段")
return await MongoConnector.execute_aggregate(
instance, database, collection, pipeline
)
raise ValueError(f"不支持的只读操作: {operation}")
@staticmethod
async def execute_change_json(
instance: Any,
database: str,
change_json: Dict[str, Any],
) -> str:
"""通过 JSON 格式执行 MongoDB 变更操作。
自动根据 change_json["operation"] 分发到对应方法。
用于工单变更执行。
Args:
instance: Instance 模型对象。
database: 数据库名称。
change_json: 变更 JSON 字典,必须包含 collection 和 operation 字段。
Returns:
操作结果描述字符串。
Raises:
ValueError: 操作不合法或参数缺失时。
"""
collection: str = change_json.get("collection")
operation: str = change_json.get("operation")
if not collection:
raise ValueError("变更 JSON 缺少 'collection' 字段")
if not operation:
raise ValueError("变更 JSON 缺少 'operation' 字段")
if operation not in MONGODB_WRITE_OPS:
raise ValueError(f"不支持的变更操作: {operation}")
filter_doc = change_json.get("filter") or change_json.get("query") or {}
if operation == "insertOne":
document = change_json.get("document")
if document is None:
raise ValueError("insertOne 需要提供 'document' 字段")
return await MongoConnector.execute_insert_one(
instance, database, collection, document
)
if operation == "insertMany":
documents = change_json.get("documents")
if not documents:
raise ValueError("insertMany 需要提供 'documents' 字段")
return await MongoConnector.execute_insert_many(
instance, database, collection, documents
)
if operation == "updateOne":
update_doc = change_json.get("update")
if update_doc is None:
raise ValueError("updateOne 需要提供 'update' 字段")
return await MongoConnector.execute_update_one(
instance, database, collection, filter_doc, update_doc
)
if operation == "updateMany":
update_doc = change_json.get("update")
if update_doc is None:
raise ValueError("updateMany 需要提供 'update' 字段")
return await MongoConnector.execute_update_many(
instance, database, collection, filter_doc, update_doc
)
if operation == "replaceOne":
replacement = change_json.get("replacement")
if replacement is None:
raise ValueError("replaceOne 需要提供 'replacement' 字段")
return await MongoConnector.execute_replace_one(
instance, database, collection, filter_doc, replacement
)
if operation == "deleteOne":
return await MongoConnector.execute_delete_one(
instance, database, collection, filter_doc
)
if operation == "deleteMany":
return await MongoConnector.execute_delete_many(
instance, database, collection, filter_doc
)
raise ValueError(f"暂未实现的变更操作: {operation}")
# ── 序列化工具 ──────────────────────────────────────────────
@staticmethod
def _serialize_doc(doc: Dict[str, Any]) -> Dict[str, Any]:
"""将 MongoDB 文档序列化为 JSON 可序列化字典。
使用 bson.json_util 处理 ObjectId、DateTime 等特殊类型。
Args:
doc: 原始 MongoDB 文档。
Returns:
JSON 可序列化的字典。
"""
return json.loads(json_util.dumps(doc))