Files
opsxsql/backend/app/services/ticket_executor.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

1385 lines
48 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.
"""多数据库工单执行引擎 — 支持 MySQL/PostgreSQL/Redis/MongoDB 的变更执行。
执行流程:
1. 接收审批通过的工单
2. 按 db_type 分发到对应执行器
3. 执行前快照(用于回滚和验证)
4. 执行变更
5. 执行后验证
6. 生成执行报告
验证机制:
- SQL数据库: 检查影响行数是否符合预期、语法合法性
- Redis: 检查命令结果、键是否存在/已被修改
- MongoDB: 检查修改文档数、文档内容验证
- 通用: 执行超时控制、连接健康检查
"""
import asyncio
import json
import re
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import sqlparse
from app.models.instance import Instance
from app.models.ticket import Ticket
from app.services.sql_analyzer import SQLAnalyzer, SQLType
# 超时配置(秒)
SQL_TIMEOUT = 60
REDIS_TIMEOUT = 30
MONGO_TIMEOUT = 60
# Redis 写命令集合(用于验证)
REDIS_WRITE_COMMANDS = {
"SET", "SETEX", "SETNX", "MSET", "HSET", "HMSET", "HINCRBY",
"LPUSH", "RPUSH", "LPOP", "RPOP", "LREM", "LSET", "LTRIM",
"SADD", "SREM", "SPOP", "SMOVE", "ZADD", "ZREM", "ZINCRBY",
"ZREMRANGEBYSCORE", "ZREMRANGEBYRANK", "DEL", "HDEL",
"EXPIRE", "EXPIREAT", "PERSIST", "RENAME", "RENAMENX",
"INCR", "INCRBY", "DECR", "DECRBY", "APPEND", "SETBIT",
"FLUSHDB", "FLUSHALL",
}
# MongoDB 支持的操作类型
MONGO_SUPPORTED_OPERATIONS = {
"insertOne", "insertMany",
"updateOne", "updateMany",
"deleteOne", "deleteMany",
"replaceOne",
}
@dataclass
class ExecutionReport:
"""执行报告,记录工单执行的完整结果。"""
status: str = "success" # success / failed / partial
affected_rows: int = 0 # 影响行数
duration_ms: int = 0 # 执行耗时
before_snapshot: str = "" # 执行前快照
after_snapshot: str = "" # 执行后快照
validation_passed: bool = True # 验证是否通过
warnings: List[str] = field(default_factory=list) # 警告信息
errors: List[str] = field(default_factory=list) # 错误信息
raw_output: str = "" # 原始输出
def to_json(self) -> str:
"""将执行报告序列化为 JSON 字符串。"""
return json.dumps(
{
"status": self.status,
"affected_rows": self.affected_rows,
"duration_ms": self.duration_ms,
"before_snapshot": self.before_snapshot,
"after_snapshot": self.after_snapshot,
"validation_passed": self.validation_passed,
"warnings": self.warnings,
"errors": self.errors,
"raw_output": self.raw_output,
},
ensure_ascii=False,
default=str,
)
@classmethod
def from_exception(cls, exc: Exception, stage: str = "执行") -> "ExecutionReport":
"""从异常创建失败的执行报告。"""
return cls(
status="failed",
errors=[f"{stage}失败: {type(exc).__name__}: {exc}"],
duration_ms=0,
validation_passed=False,
)
class TicketExecutorError(Exception):
"""工单执行引擎异常。"""
pass
class SyntaxValidationError(Exception):
"""语法预校验异常。"""
pass
class TicketExecutor:
"""多数据库工单执行引擎。
支持 MySQL、PostgreSQL、Redis 和 MongoDB 四种类型的工单执行。
提供执行前快照、执行后验证、超时控制等安全机制。
Attributes:
_analyzer: SQL 分析器,用于解析 SQL 类型和语法验证
"""
def __init__(self) -> None:
"""初始化工单执行引擎。"""
self._analyzer = SQLAnalyzer()
# ===== 公共接口 =====
async def execute(self, ticket: Ticket, instance: Instance) -> ExecutionReport:
"""执行工单。
根据实例的数据库类型分发到对应的执行器。
Args:
ticket: 工单对象,包含要执行的 SQL/命令内容
instance: 数据源实例,包含连接信息
Returns:
ExecutionReport: 执行报告
Raises:
ValueError: 不支持的数据库类型
"""
if instance.db_type == "mysql":
return await self._execute_mysql(ticket, instance)
elif instance.db_type in ("postgres", "postgresql"):
return await self._execute_postgres(ticket, instance)
elif instance.db_type == "redis":
return await self._execute_redis(ticket, instance)
elif instance.db_type == "mongo":
return await self._execute_mongo(ticket, instance)
else:
raise ValueError(f"不支持的执行类型: {instance.db_type}")
# ===== MySQL 执行 =====
async def _execute_mysql(self, ticket: Ticket, instance: Instance) -> ExecutionReport:
"""执行 MySQL 工单。
执行流程:
1. 语法预校验(sqlparse 解析)
2. 执行前快照(SELECT 查看受影响的数据)
3. 执行 SQL
4. 执行后验证
5. 生成报告
Args:
ticket: 工单对象
instance: MySQL 实例连接信息
Returns:
ExecutionReport: 执行报告
"""
import aiomysql
report = ExecutionReport()
start_time = time.time()
sql_content = ticket.sql_content.strip()
try:
# 1. 语法预校验
self._validate_mysql_syntax(sql_content)
# 2. 建立连接
conn = await aiomysql.connect(
host=instance.host,
port=instance.port,
user=instance.username,
password=instance.password,
db=ticket.target_database,
autocommit=True,
)
try:
# 3. 执行前快照
report.before_snapshot = await self._capture_sql_before_snapshot(
conn, sql_content, "mysql"
)
# 4. 执行 SQL(支持多条语句,分号分隔)
total_affected = 0
output_lines: List[str] = []
statements = self._split_statements(sql_content)
for stmt in statements:
stmt = stmt.strip()
if not stmt:
continue
async with conn.cursor() as cur:
await asyncio.wait_for(cur.execute(stmt), timeout=SQL_TIMEOUT)
affected = cur.rowcount
total_affected += max(0, affected) if affected is not None else 0
output_lines.append(f"[{stmt[:50]}...] 影响 {affected} 行")
report.affected_rows = total_affected
report.raw_output = "\n".join(output_lines)
# 5. 执行后快照
report.after_snapshot = await self._capture_sql_after_snapshot(
conn, sql_content, "mysql"
)
# 6. 验证执行结果
report.validation_passed = self._validate_sql_execution(
sql_content, report
)
if not report.validation_passed and report.errors:
report.status = "partial"
else:
report.status = "success"
except asyncio.TimeoutError:
report.status = "failed"
report.errors.append(f"执行超时({SQL_TIMEOUT}秒)")
report.validation_passed = False
except Exception as e:
report.status = "failed"
report.errors.append(f"执行异常: {e}")
report.validation_passed = False
finally:
conn.close()
except SyntaxValidationError as e:
report.status = "failed"
report.errors.append(f"语法校验失败: {e}")
report.validation_passed = False
except Exception as e:
report.status = "failed"
report.errors.append(f"连接异常: {e}")
report.validation_passed = False
report.duration_ms = int((time.time() - start_time) * 1000)
return report
# ===== PostgreSQL 执行 =====
async def _execute_postgres(self, ticket: Ticket, instance: Instance) -> ExecutionReport:
"""执行 PostgreSQL 工单。
执行流程与 MySQL 类似,使用 asyncpg 驱动。
Args:
ticket: 工单对象
instance: PostgreSQL 实例连接信息
Returns:
ExecutionReport: 执行报告
"""
import asyncpg
report = ExecutionReport()
start_time = time.time()
sql_content = ticket.sql_content.strip()
database = ticket.target_database or instance.database or "postgres"
try:
# 1. 语法预校验
self._validate_postgres_syntax(sql_content)
# 2. 建立连接
dsn = (
f"postgresql://{instance.username}:{instance.password}"
f"@{instance.host}:{instance.port}/{database}"
)
conn = await asyncpg.connect(dsn)
try:
# 3. 执行前快照
report.before_snapshot = await self._capture_sql_before_snapshot(
conn, sql_content, "postgres"
)
# 4. 执行 SQL
total_affected = 0
output_lines: List[str] = []
statements = self._split_statements(sql_content)
for stmt in statements:
stmt = stmt.strip()
if not stmt:
continue
status = await asyncio.wait_for(
conn.execute(stmt), timeout=SQL_TIMEOUT
)
# status like "UPDATE 3" or "INSERT 0 1"
parts = status.split()
affected = 0
if len(parts) >= 2 and parts[1].isdigit():
affected = int(parts[1])
total_affected += affected
output_lines.append(f"[{stmt[:50]}...] 状态: {status}")
report.affected_rows = total_affected
report.raw_output = "\n".join(output_lines)
# 5. 执行后快照
report.after_snapshot = await self._capture_sql_after_snapshot(
conn, sql_content, "postgres"
)
# 6. 验证
report.validation_passed = self._validate_sql_execution(
sql_content, report
)
if not report.validation_passed and report.errors:
report.status = "partial"
else:
report.status = "success"
except asyncio.TimeoutError:
report.status = "failed"
report.errors.append(f"执行超时({SQL_TIMEOUT}秒)")
report.validation_passed = False
except Exception as e:
report.status = "failed"
report.errors.append(f"执行异常: {e}")
report.validation_passed = False
finally:
await conn.close()
except SyntaxValidationError as e:
report.status = "failed"
report.errors.append(f"语法校验失败: {e}")
report.validation_passed = False
except Exception as e:
report.status = "failed"
report.errors.append(f"连接异常: {e}")
report.validation_passed = False
report.duration_ms = int((time.time() - start_time) * 1000)
return report
# ===== Redis 执行 =====
async def _execute_redis(self, ticket: Ticket, instance: Instance) -> ExecutionReport:
"""执行 Redis 工单。
Redis 工单格式支持:
- JSON 格式: {"commands": [{"command": "SET key value", "db": 0}]}
- 简单文本格式: 每行一个命令
Args:
ticket: 工单对象
instance: Redis 实例连接信息
Returns:
ExecutionReport: 执行报告
"""
import redis.asyncio as aioredis
report = ExecutionReport()
start_time = time.time()
try:
# 解析命令
commands = self._parse_redis_commands(ticket.sql_content)
if not commands:
report.status = "failed"
report.errors.append("未解析到有效的 Redis 命令")
report.validation_passed = False
return report
# 语法预校验
self._validate_redis_commands(commands)
# 建立连接
conn = aioredis.Redis(
host=instance.host,
port=instance.port,
password=instance.password or None,
decode_responses=True,
)
try:
# 执行前快照
report.before_snapshot = await self._capture_redis_before_snapshot(
conn, commands
)
# 执行命令
output_lines: List[str] = []
success_count = 0
for cmd_info in commands:
command = cmd_info["command"]
db = cmd_info.get("db", 0)
# 选择数据库
if db != 0:
await conn.select(db)
parts = command.strip().split()
if not parts:
continue
cmd_name = parts[0].upper()
args = parts[1:]
try:
result = await asyncio.wait_for(
conn.execute_command(cmd_name, *args),
timeout=REDIS_TIMEOUT,
)
success_count += 1
output_lines.append(
f"[DB{db}] {command} => {self._format_redis_result(result)}"
)
except asyncio.TimeoutError:
output_lines.append(
f"[DB{db}] {command} => 超时({REDIS_TIMEOUT}秒)"
)
report.errors.append(f"命令超时: {command}")
except Exception as e:
output_lines.append(f"[DB{db}] {command} => 错误: {e}")
report.errors.append(f"命令执行失败 '{command}': {e}")
report.affected_rows = success_count
report.raw_output = "\n".join(output_lines)
# 执行后快照
report.after_snapshot = await self._capture_redis_after_snapshot(
conn, commands
)
# 验证
report.validation_passed = self._validate_redis_execution(
commands, report
)
if report.errors:
report.status = "partial" if success_count > 0 else "failed"
else:
report.status = "success"
finally:
await conn.close()
except SyntaxValidationError as e:
report.status = "failed"
report.errors.append(f"语法校验失败: {e}")
report.validation_passed = False
except Exception as e:
report.status = "failed"
report.errors.append(f"连接异常: {e}")
report.validation_passed = False
report.duration_ms = int((time.time() - start_time) * 1000)
return report
# ===== MongoDB 执行 =====
async def _execute_mongo(self, ticket: Ticket, instance: Instance) -> ExecutionReport:
"""执行 MongoDB 工单。
MongoDB 工单格式(JSON):
{
"collection": "users",
"operation": "updateMany",
"filter": {"status": "inactive"},
"update": {"$set": {"status": "active"}}
}
Args:
ticket: 工单对象
instance: MongoDB 实例连接信息
Returns:
ExecutionReport: 执行报告
"""
report = ExecutionReport()
start_time = time.time()
try:
# 解析工单内容
operation_spec = self._parse_mongo_operation(ticket.sql_content)
collection_name = operation_spec["collection"]
operation = operation_spec["operation"]
# 语法预校验
self._validate_mongo_operation(operation_spec)
# 导入 motor(异步 MongoDB 驱动)
try:
from motor.motor_asyncio import AsyncIOMotorClient
except ImportError:
report.status = "failed"
report.errors.append("缺少 motor 依赖,请安装: pip install motor")
report.validation_passed = False
return report
# 建立连接
database = ticket.target_database or instance.database or "admin"
conn_str = (
f"mongodb://{instance.username}:{instance.password}"
f"@{instance.host}:{instance.port}/{database}"
)
client = AsyncIOMotorClient(conn_str, serverSelectionTimeoutMS=5000)
try:
db = client[database]
collection = db[collection_name]
# 执行前快照
report.before_snapshot = await self._capture_mongo_before_snapshot(
collection, operation_spec
)
# 执行操作
filter_doc = operation_spec.get("filter", {})
update_doc = operation_spec.get("update", {})
doc = operation_spec.get("document", {})
options = operation_spec.get("options", {})
result_obj: Any = None
if operation == "insertOne":
result_obj = await asyncio.wait_for(
collection.insert_one(doc),
timeout=MONGO_TIMEOUT,
)
report.affected_rows = 1 if result_obj.inserted_id else 0
report.raw_output = f"插入成功,ID: {result_obj.inserted_id}"
elif operation == "insertMany":
docs = operation_spec.get("documents", [])
result_obj = await asyncio.wait_for(
collection.insert_many(docs),
timeout=MONGO_TIMEOUT,
)
report.affected_rows = len(result_obj.inserted_ids)
report.raw_output = f"批量插入成功,共 {len(result_obj.inserted_ids)} 条"
elif operation == "updateOne":
result_obj = await asyncio.wait_for(
collection.update_one(filter_doc, update_doc, **options),
timeout=MONGO_TIMEOUT,
)
report.affected_rows = result_obj.modified_count
report.raw_output = (
f"匹配 {result_obj.matched_count} 条,"
f"修改 {result_obj.modified_count} 条"
)
elif operation == "updateMany":
result_obj = await asyncio.wait_for(
collection.update_many(filter_doc, update_doc, **options),
timeout=MONGO_TIMEOUT,
)
report.affected_rows = result_obj.modified_count
report.raw_output = (
f"匹配 {result_obj.matched_count} 条,"
f"修改 {result_obj.modified_count} 条"
)
elif operation == "deleteOne":
result_obj = await asyncio.wait_for(
collection.delete_one(filter_doc),
timeout=MONGO_TIMEOUT,
)
report.affected_rows = result_obj.deleted_count
report.raw_output = f"删除 {result_obj.deleted_count} 条"
elif operation == "deleteMany":
result_obj = await asyncio.wait_for(
collection.delete_many(filter_doc),
timeout=MONGO_TIMEOUT,
)
report.affected_rows = result_obj.deleted_count
report.raw_output = f"批量删除 {result_obj.deleted_count} 条"
elif operation == "replaceOne":
result_obj = await asyncio.wait_for(
collection.replace_one(filter_doc, doc, **options),
timeout=MONGO_TIMEOUT,
)
report.affected_rows = result_obj.modified_count
report.raw_output = (
f"匹配 {result_obj.matched_count} 条,"
f"替换 {result_obj.modified_count} 条"
)
# 执行后快照
report.after_snapshot = await self._capture_mongo_after_snapshot(
collection, operation_spec
)
# 验证
report.validation_passed = self._validate_mongo_execution(
operation_spec, report
)
if report.validation_passed:
report.status = "success"
else:
report.status = "partial"
except asyncio.TimeoutError:
report.status = "failed"
report.errors.append(f"执行超时({MONGO_TIMEOUT}秒)")
report.validation_passed = False
except Exception as e:
report.status = "failed"
report.errors.append(f"执行异常: {e}")
report.validation_passed = False
finally:
client.close()
except SyntaxValidationError as e:
report.status = "failed"
report.errors.append(f"语法校验失败: {e}")
report.validation_passed = False
except Exception as e:
report.status = "failed"
report.errors.append(f"连接异常: {e}")
report.validation_passed = False
report.duration_ms = int((time.time() - start_time) * 1000)
return report
# ===== 执行前快照 =====
async def _capture_sql_before_snapshot(
self, conn: Any, sql_content: str, db_type: str
) -> str:
"""执行前捕获 SQL 数据库的数据快照。
对 UPDATE/DELETE 操作,查询将被影响的数据行。
对 INSERT 操作,记录表当前行数。
Args:
conn: 数据库连接
sql_content: SQL 内容
db_type: 数据库类型(mysql/postgres)
Returns:
快照信息的字符串表示
"""
snapshots: List[str] = []
statements = self._split_statements(sql_content)
for stmt in statements:
stmt = stmt.strip()
if not stmt:
continue
sql_type = self._analyzer._detect_sql_type(stmt)
table_name = self._extract_table_name(stmt, sql_type)
if sql_type == SQLType.UPDATE:
where_clause = self._extract_where_clause_text(stmt)
if table_name:
try:
count = await self._get_affected_count(
conn, table_name, where_clause, db_type
)
snapshots.append(
f"UPDATE 表 `{table_name}`: 将有 {count} 行被更新"
f"{' (WHERE: ' + where_clause + ')' if where_clause else ' (全表更新)' }"
)
except Exception as e:
snapshots.append(f"UPDATE 表 `{table_name}`: 快照获取失败 - {e}")
elif sql_type == SQLType.DELETE:
where_clause = self._extract_where_clause_text(stmt)
if table_name:
try:
count = await self._get_affected_count(
conn, table_name, where_clause, db_type
)
snapshots.append(
f"DELETE 表 `{table_name}`: 将有 {count} 行被删除"
f"{' (WHERE: ' + where_clause + ')' if where_clause else ' (全表删除)' }"
)
except Exception as e:
snapshots.append(f"DELETE 表 `{table_name}`: 快照获取失败 - {e}")
elif sql_type == SQLType.INSERT:
if table_name:
try:
current_count = await self._get_table_row_count(
conn, table_name, db_type
)
snapshots.append(
f"INSERT 表 `{table_name}`: 当前 {current_count} 行"
)
except Exception as e:
snapshots.append(f"INSERT 表 `{table_name}`: 行数获取失败 - {e}")
return "\n".join(snapshots) if snapshots else "无快照信息"
async def _capture_sql_after_snapshot(
self, conn: Any, sql_content: str, db_type: str
) -> str:
"""执行后捕获 SQL 数据库的数据快照。
Args:
conn: 数据库连接
sql_content: SQL 内容
db_type: 数据库类型
Returns:
执行后的快照信息
"""
snapshots: List[str] = []
statements = self._split_statements(sql_content)
for stmt in statements:
stmt = stmt.strip()
if not stmt:
continue
sql_type = self._analyzer._detect_sql_type(stmt)
table_name = self._extract_table_name(stmt, sql_type)
if table_name and sql_type in (SQLType.UPDATE, SQLType.DELETE, SQLType.INSERT):
try:
current_count = await self._get_table_row_count(
conn, table_name, db_type
)
snapshots.append(f"表 `{table_name}`: 当前 {current_count} 行")
except Exception as e:
snapshots.append(f"表 `{table_name}`: 行数获取失败 - {e}")
return "\n".join(snapshots) if snapshots else "无快照信息"
async def _capture_redis_before_snapshot(
self, conn: Any, commands: List[Dict[str, Any]]
) -> str:
"""执行前捕获 Redis 键的快照。
记录将被修改的键的当前值和 TTL。
Args:
conn: Redis 连接
commands: 命令列表
Returns:
键快照信息的字符串表示
"""
snapshots: List[str] = []
keys_to_capture: set = set()
# 提取可能被修改的键
for cmd_info in commands:
command = cmd_info["command"]
db = cmd_info.get("db", 0)
parts = command.strip().split()
if len(parts) >= 2:
cmd_name = parts[0].upper()
if cmd_name in REDIS_WRITE_COMMANDS:
key = parts[1]
keys_to_capture.add((key, db))
# 查询每个键的当前值
for key, db in keys_to_capture:
try:
if db != 0:
await conn.select(db)
key_type = await conn.type(key)
ttl = await conn.ttl(key)
if key_type == "none":
snapshots.append(f"[DB{db}] 键 '{key}': 不存在")
else:
# 获取值(限制长度)
value_preview = await self._get_redis_value_preview(conn, key, key_type)
ttl_str = f"TTL={ttl}s" if ttl > 0 else "无过期时间"
snapshots.append(
f"[DB{db}] 键 '{key}' ({key_type}, {ttl_str}): {value_preview}"
)
except Exception as e:
snapshots.append(f"[DB{db}] 键 '{key}': 快照获取失败 - {e}")
return "\n".join(snapshots) if snapshots else "无键被修改"
async def _capture_redis_after_snapshot(
self, conn: Any, commands: List[Dict[str, Any]]
) -> str:
"""执行后捕获 Redis 键的快照。"""
return await self._capture_redis_before_snapshot(conn, commands)
async def _capture_mongo_before_snapshot(
self, collection: Any, operation_spec: Dict[str, Any]
) -> str:
"""执行前捕获 MongoDB 文档快照。
记录将被修改的文档数量和示例。
Args:
collection: MongoDB 集合对象
operation_spec: 操作规格
Returns:
文档快照信息的字符串表示
"""
operation = operation_spec.get("operation", "")
filter_doc = operation_spec.get("filter", {})
try:
if operation in ("updateOne", "updateMany", "deleteOne", "deleteMany", "replaceOne"):
count = await collection.count_documents(filter_doc)
return f"操作 '{operation}': 匹配 {count} 条文档\n筛选条件: {json.dumps(filter_doc, ensure_ascii=False)}"
elif operation in ("insertOne", "insertMany"):
return f"操作 '{operation}': 将插入新文档"
return f"操作 '{operation}': 无前置快照"
except Exception as e:
return f"快照获取失败: {e}"
async def _capture_mongo_after_snapshot(
self, collection: Any, operation_spec: Dict[str, Any]
) -> str:
"""执行后捕获 MongoDB 文档快照。"""
operation = operation_spec.get("operation", "")
filter_doc = operation_spec.get("filter", {})
try:
if operation in ("updateOne", "updateMany", "replaceOne"):
count = await collection.count_documents(filter_doc)
return f"操作 '{operation}' 完成后: 匹配 {count} 条文档"
elif operation in ("deleteOne", "deleteMany"):
return f"操作 '{operation}' 已完成"
elif operation in ("insertOne", "insertMany"):
total = await collection.estimated_document_count()
return f"操作 '{operation}' 完成后: 集合约 {total} 条文档"
return f"操作 '{operation}': 无后置快照"
except Exception as e:
return f"快照获取失败: {e}"
# ===== 验证方法 =====
def _validate_sql_execution(self, sql_content: str, report: ExecutionReport) -> bool:
"""验证 SQL 执行结果。
验证项:
1. 影响行数 >= 0
2. UPDATE/DELETE 有 WHERE 条件时,影响行数应合理
3. 无错误信息
Args:
sql_content: SQL 内容
report: 执行报告
Returns:
验证是否通过
"""
passed = True
statements = self._split_statements(sql_content)
for stmt in statements:
stmt = stmt.strip()
if not stmt:
continue
sql_type = self._analyzer._detect_sql_type(stmt)
# 检查 UPDATE/DELETE 是否缺少 WHERE
if sql_type in (SQLType.UPDATE, SQLType.DELETE):
has_where = self._extract_where_clause_text(stmt) is not None
if not has_where:
report.warnings.append(
f"{sql_type.value} 操作缺少 WHERE 条件,影响全表"
)
# 检查是否有执行错误
if report.errors:
passed = False
return passed
def _validate_redis_execution(
self, commands: List[Dict[str, Any]], report: ExecutionReport
) -> bool:
"""验证 Redis 执行结果。
验证项:
1. 所有命令都已执行
2. 写命令返回预期结果
Args:
commands: 命令列表
report: 执行报告
Returns:
验证是否通过
"""
passed = True
# 检查是否有错误
if report.errors:
passed = False
# 检查是否所有命令都执行成功
if report.affected_rows != len(commands):
report.warnings.append(
f"仅 {report.affected_rows}/{len(commands)} 条命令成功执行"
)
return passed
def _validate_mongo_execution(
self, operation_spec: Dict[str, Any], report: ExecutionReport
) -> bool:
"""验证 MongoDB 执行结果。
验证项:
1. 修改文档数符合预期
2. 无错误信息
Args:
operation_spec: 操作规格
report: 执行报告
Returns:
验证是否通过
"""
passed = True
operation = operation_spec.get("operation", "")
# 检查写操作是否有影响
if operation in ("updateOne", "updateMany", "replaceOne"):
if report.affected_rows == 0:
report.warnings.append(f"{operation} 未修改任何文档")
elif operation in ("deleteOne", "deleteMany"):
if report.affected_rows == 0:
report.warnings.append(f"{operation} 未删除任何文档")
elif operation in ("insertOne",):
if report.affected_rows != 1:
report.errors.append(f"insertOne 应插入 1 条文档,实际 {report.affected_rows}")
passed = False
# 检查错误
if report.errors:
passed = False
return passed
# ===== 语法预校验 =====
def _validate_mysql_syntax(self, sql_content: str) -> None:
"""预校验 MySQL 语法。
使用 sqlparse 解析验证 SQL 语句的基本合法性。
Args:
sql_content: SQL 内容
Raises:
SyntaxValidationError: 语法校验失败
"""
if not sql_content or not sql_content.strip():
raise SyntaxValidationError("SQL 内容不能为空")
statements = self._split_statements(sql_content)
for stmt in statements:
stmt = stmt.strip()
if not stmt:
continue
# 使用 sqlparse 解析
parsed = sqlparse.parse(stmt)
if not parsed or not parsed[0].tokens:
raise SyntaxValidationError(f"无法解析 SQL: {stmt[:100]}")
# 检查是否为 DML 语句
sql_type = self._analyzer._detect_sql_type(stmt)
if sql_type not in (SQLType.INSERT, SQLType.UPDATE, SQLType.DELETE, SQLType.SELECT):
raise SyntaxValidationError(
f"不支持的 SQL 类型: {sql_type.value}, 仅支持 INSERT/UPDATE/DELETE"
)
def _validate_postgres_syntax(self, sql_content: str) -> None:
"""预校验 PostgreSQL 语法。
PostgreSQL 语法校验与 MySQL 类似,使用 sqlparse 进行基础解析。
Args:
sql_content: SQL 内容
Raises:
SyntaxValidationError: 语法校验失败
"""
# PostgreSQL 基础语法校验与 MySQL 相同
self._validate_mysql_syntax(sql_content)
def _validate_redis_commands(self, commands: List[Dict[str, Any]]) -> None:
"""预校验 Redis 命令格式。
验证命令名和参数数量是否合法。
Args:
commands: 命令列表
Raises:
SyntaxValidationError: 语法校验失败
"""
for cmd_info in commands:
command = cmd_info.get("command", "").strip()
if not command:
raise SyntaxValidationError("命令不能为空")
parts = command.split()
cmd_name = parts[0].upper()
# 检查命令名是否合法(基本检查)
if not re.match(r"^[A-Z]+$", cmd_name):
raise SyntaxValidationError(f"非法的命令名: {cmd_name}")
# 检查参数数量(基本检查)
if len(parts) < 2 and cmd_name not in (
"PING", "INFO", "DBSIZE", "FLUSHDB", "FLUSHALL", "SAVE",
"BGSAVE", "LASTSAVE", "MONITOR", "SYNC", "SHUTDOWN",
):
raise SyntaxValidationError(f"命令 {cmd_name} 缺少参数")
def _validate_mongo_operation(self, operation_spec: Dict[str, Any]) -> None:
"""预校验 MongoDB 操作。
验证 JSON 格式和操作类型是否合法。
Args:
operation_spec: 操作规格字典
Raises:
SyntaxValidationError: 语法校验失败
"""
if not isinstance(operation_spec, dict):
raise SyntaxValidationError("MongoDB 工单必须是 JSON 对象")
collection = operation_spec.get("collection")
if not collection or not isinstance(collection, str):
raise SyntaxValidationError("缺少或非法的 'collection' 字段")
operation = operation_spec.get("operation")
if operation not in MONGO_SUPPORTED_OPERATIONS:
raise SyntaxValidationError(
f"不支持的操作类型: {operation}, "
f"支持: {', '.join(MONGO_SUPPORTED_OPERATIONS)}"
)
# 检查各操作必需参数
if operation in ("insertOne",) and "document" not in operation_spec:
raise SyntaxValidationError("insertOne 操作需要 'document' 字段")
if operation in ("insertMany",) and "documents" not in operation_spec:
raise SyntaxValidationError("insertMany 操作需要 'documents' 字段")
if operation in ("updateOne", "updateMany", "replaceOne"):
if "filter" not in operation_spec:
raise SyntaxValidationError(f"{operation} 操作需要 'filter' 字段")
if "update" not in operation_spec and operation != "replaceOne":
raise SyntaxValidationError(f"{operation} 操作需要 'update' 字段")
if operation == "replaceOne" and "document" not in operation_spec:
raise SyntaxValidationError("replaceOne 操作需要 'document' 字段")
if operation in ("deleteOne", "deleteMany"):
if "filter" not in operation_spec:
raise SyntaxValidationError(f"{operation} 操作需要 'filter' 字段")
# ===== 工具方法 =====
@staticmethod
def _split_statements(sql_content: str) -> List[str]:
"""将 SQL 内容按分号分割为多条语句。
智能处理字符串内的分号。
Args:
sql_content: SQL 内容
Returns:
语句列表
"""
if not sql_content:
return []
# 使用 sqlparse 进行更智能的分割
parsed = sqlparse.split(sql_content)
return [p.strip() for p in parsed if p.strip()]
def _extract_table_name(self, sql: str, sql_type: SQLType) -> Optional[str]:
"""从 SQL 语句中提取表名。
Args:
sql: SQL 语句
sql_type: SQL 类型
Returns:
表名,解析失败返回 None
"""
sql = sql.strip()
upper = sql.upper()
if sql_type == SQLType.INSERT:
match = re.search(r"INSERT\s+(?:IGNORE\s+)?INTO\s+`?([^`\s(]+)`?", upper)
if match:
return match.group(1).strip()
elif sql_type == SQLType.UPDATE:
match = re.search(r"UPDATE\s+`?([^`\s,]+)`?", upper)
if match:
return match.group(1).strip()
elif sql_type == SQLType.DELETE:
match = re.search(r"DELETE\s+FROM\s+`?([^`\s(]+)`?", upper)
if not match:
match = re.search(r"DELETE\s+`?([^`\s(]+)`?\s+FROM", upper)
if match:
return match.group(1).strip()
return None
@staticmethod
def _extract_where_clause_text(sql: str) -> Optional[str]:
"""从 SQL 语句中提取 WHERE 子句文本。
Args:
sql: SQL 语句
Returns:
WHERE 条件文本,没有则返回 None
"""
parsed = sqlparse.parse(sql)
if not parsed:
return None
statement = parsed[0]
# 使用 sqlparse 的 Where 对象
from sqlparse.sql import Where
for token in statement.tokens:
if isinstance(token, Where):
where_text = str(token).replace("WHERE", "", 1).strip()
return where_text
# 回退:检查关键字
from sqlparse.tokens import Keyword
where_tokens = []
in_where = False
for token in statement.tokens:
if token.ttype is Keyword and token.normalized == "WHERE":
in_where = True
continue
if in_where:
if token.ttype is Keyword and token.normalized in (
"ORDER", "GROUP", "HAVING", "LIMIT",
):
break
where_tokens.append(str(token))
if where_tokens:
return "".join(where_tokens).strip()
return None
async def _get_affected_count(
self, conn: Any, table_name: str, where_clause: Optional[str], db_type: str
) -> int:
"""获取将被影响的数据行数。
Args:
conn: 数据库连接
table_name: 表名
where_clause: WHERE 条件
db_type: 数据库类型
Returns:
行数
"""
# 安全过滤表名(只允许字母数字下划线)
safe_table = re.sub(r"[^\w]", "", table_name)
if not safe_table:
raise ValueError("非法的表名")
if db_type == "postgres":
sql = f'SELECT COUNT(*) FROM "{safe_table}"'
else:
sql = f"SELECT COUNT(*) FROM `{safe_table}`"
if where_clause:
sql += f" WHERE {where_clause}"
sql += " LIMIT 10001"
# 根据连接类型执行查询
if db_type == "mysql":
async with conn.cursor() as cur:
await cur.execute(sql)
row = await cur.fetchone()
return row[0] if row else 0
else:
# PostgreSQL (asyncpg)
row = await conn.fetchval(sql)
return row or 0
async def _get_table_row_count(
self, conn: Any, table_name: str, db_type: str
) -> int:
"""获取表的当前行数。
Args:
conn: 数据库连接
table_name: 表名
db_type: 数据库类型
Returns:
行数
"""
safe_table = re.sub(r"[^\w]", "", table_name)
if not safe_table:
raise ValueError("非法的表名")
if db_type == "postgres":
sql = f'SELECT COUNT(*) FROM "{safe_table}"'
else:
sql = f"SELECT COUNT(*) FROM `{safe_table}`"
sql += " LIMIT 10001"
if db_type == "mysql":
async with conn.cursor() as cur:
await cur.execute(sql)
row = await cur.fetchone()
return row[0] if row else 0
else:
row = await conn.fetchval(sql)
return row or 0
@staticmethod
def _parse_redis_commands(content: str) -> List[Dict[str, Any]]:
"""解析 Redis 工单内容为命令列表。
支持 JSON 格式和简单文本格式。
Args:
content: 工单内容
Returns:
命令列表,每项为 {"command": str, "db": int}
"""
content = content.strip()
if not content:
return []
# 尝试 JSON 解析
if content.startswith("{"):
try:
data = json.loads(content)
if isinstance(data, dict) and "commands" in data:
commands = []
for cmd in data["commands"]:
if isinstance(cmd, dict) and "command" in cmd:
commands.append({
"command": cmd["command"],
"db": cmd.get("db", 0),
})
elif isinstance(cmd, str):
commands.append({"command": cmd, "db": 0})
return commands
# 可能是单条命令的 JSON
if "command" in data:
return [{"command": data["command"], "db": data.get("db", 0)}]
except json.JSONDecodeError:
pass # 不是 JSON,尝试文本格式
# 简单文本格式:每行一个命令
commands = []
for line in content.split("\n"):
line = line.strip()
if line and not line.startswith("#"):
commands.append({"command": line, "db": 0})
return commands
@staticmethod
def _parse_mongo_operation(content: str) -> Dict[str, Any]:
"""解析 MongoDB 工单内容为操作规格。
Args:
content: 工单内容(JSON 字符串)
Returns:
操作规格字典
Raises:
SyntaxValidationError: JSON 解析失败
"""
content = content.strip()
if not content:
raise SyntaxValidationError("MongoDB 工单内容不能为空")
try:
data = json.loads(content)
if not isinstance(data, dict):
raise SyntaxValidationError("MongoDB 工单必须是 JSON 对象")
return data
except json.JSONDecodeError as e:
raise SyntaxValidationError(f"JSON 解析失败: {e}")
@staticmethod
async def _get_redis_value_preview(
conn: Any, key: str, key_type: str, max_len: int = 200
) -> str:
"""获取 Redis 键值的预览。
Args:
conn: Redis 连接
key: 键名
key_type: 键类型
max_len: 最大预览长度
Returns:
值预览字符串
"""
try:
if key_type == "string":
value = await conn.get(key)
preview = str(value)[:max_len]
return f"'{preview}'"
elif key_type == "hash":
value = await conn.hgetall(key)
preview = str(value)[:max_len]
return f"{preview}"
elif key_type == "list":
length = await conn.llen(key)
return f"<list, {length} items>"
elif key_type == "set":
length = await conn.scard(key)
return f"<set, {length} members>"
elif key_type == "zset":
length = await conn.zcard(key)
return f"<zset, {length} members>"
else:
return f"<{key_type}>"
except Exception:
return "<无法读取>"
@staticmethod
def _format_redis_result(result: Any) -> str:
"""格式化 Redis 命令执行结果。
Args:
result: 原始结果
Returns:
格式化后的字符串
"""
if result is None:
return "(nil)"
if isinstance(result, bytes):
return result.decode("utf-8", errors="replace")
if isinstance(result, bool):
return "1" if result else "0"
if isinstance(result, (list, set, tuple)):
items = []
for item in result:
if isinstance(item, bytes):
items.append(item.decode("utf-8", errors="replace"))
else:
items.append(str(item))
return "[" + ", ".join(items[:10]) + ("..." if len(items) > 10 else "") + "]"
if isinstance(result, dict):
preview = str(result)[:200]
return preview
return str(result)