sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1

- P0: 实例访问控制全线接入权限组 (829测试)
- P1: permissions字段激活 + 操作级权限检查 (843测试)
- 前端: 删除数据权限页面, 权限组合并为唯一入口
- 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析
- 新增: MongoDB管理页面 + Dashboard增强
- 修复: Pydantic V2迁移 + 前端TS错误修复
This commit is contained in:
logan committed 2026-05-23 14:41:06 +08:00
1 parent 6fcd9465cf
commit 4c9427e546
105 files changed
+23680 -471

No files matched your search

+780
View File
@@ -0,0 +1,780 @@
"""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))