fix: merge后14个测试修复+前端TS编译修复 - 895/895通过+前端零错误

This commit is contained in:
logan committed 2026-05-23 17:50:13 +08:00
1 parent 58eb78c987
commit b39c426317
24 files changed
+3918 -760

No files matched your search

+414 -130
View File
@@ -1,11 +1,21 @@
from typing import Optional, List, Dict, Any, TYPE_CHECKING
"""工单管理 API — 支持多数据库的变更工单生命周期管理。
核心功能:
- 工单创建、审核、执行、取消、回滚
- 多级审批流支持
- 工单状态机管理(严格的状态转换)
- 自动 SQL 审核(创建时触发)
- 执行前二次确认
- 执行后通知触发
- 完整操作审计
"""
from datetime import datetime, timezone
from enum import Enum
from typing import TYPE_CHECKING, Any, Dict, List, Optional
if TYPE_CHECKING:
from app.services.ticket_executor import ExecutionReport
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
@@ -14,25 +24,103 @@ from app.core.security import get_current_user, require_role
from app.models.user import User
from app.models.ticket import Ticket
from app.models.instance import Instance
from app.models.workflow import WorkflowDefinition, WorkflowStep, TicketApproval
from app.models.audit_log import AuditLog
from app.schemas.ticket import TicketCreate, TicketReview, TicketResponse
from app.core.crypto import decrypt_password
from app.core.permissions import check_instance_access, check_instance_permission
from app.services.audit_engine import SQLAuditEngine
from app.services.audit_engine_v2 import SQLAuditEngineV2
from app.services.audit_log_service import AuditLogService
from app.models.workflow import WorkflowDefinition, WorkflowStep, TicketApproval
from app.services.query_service import MAX_QUERY_LENGTH
import json
import logging
logger = logging.getLogger(__name__)
class AuditRequest(BaseModel):
sql_content: str
instance_id: int
database: str = ""
if TYPE_CHECKING:
from app.services.ticket_executor import ExecutionReport
router = APIRouter(prefix="/tickets", tags=["工单管理"])
# ===== 工单状态机 =====
class TicketStatus(str, Enum):
"""工单状态枚举。"""
PENDING = "pending" # 待审批
REVIEWING = "reviewing" # 审批中(多级审批)
APPROVED = "approved" # 已通过
REJECTED = "rejected" # 已拒绝
EXECUTING = "executing" # 执行中
SUCCESS = "success" # 执行成功
FAILED = "failed" # 执行失败
ROLLBACK = "rollback" # 已回滚
# 状态转换图: 当前状态 -> 允许的目标状态
TICKET_STATE_TRANSITIONS: Dict[str, List[str]] = {
TicketStatus.PENDING.value: [TicketStatus.REVIEWING.value, TicketStatus.APPROVED.value, TicketStatus.REJECTED.value],
TicketStatus.REVIEWING.value: [TicketStatus.REVIEWING.value, TicketStatus.APPROVED.value, TicketStatus.REJECTED.value],
TicketStatus.APPROVED.value: [TicketStatus.EXECUTING.value, TicketStatus.REJECTED.value],
TicketStatus.EXECUTING.value: [TicketStatus.SUCCESS.value, TicketStatus.FAILED.value],
TicketStatus.SUCCESS.value: [TicketStatus.ROLLBACK.value],
TicketStatus.FAILED.value: [TicketStatus.ROLLBACK.value, TicketStatus.APPROVED.value],
TicketStatus.REJECTED.value: [], # 终态,不可再转换
TicketStatus.ROLLBACK.value: [], # 终态,不可再转换
}
def _validate_status_transition(current_status: str, new_status: str) -> bool:
"""验证工单状态转换是否合法。
Args:
current_status: 当前状态
new_status: 目标状态
Returns:
转换是否合法
"""
allowed = TICKET_STATE_TRANSITIONS.get(current_status, [])
return new_status in allowed
# ===== 请求模型 =====
class AuditRequest(BaseModel):
"""SQL 审核请求。"""
sql_content: str = Field(..., min_length=1, max_length=MAX_QUERY_LENGTH)
instance_id: int
database: str = ""
@field_validator('sql_content')
@classmethod
def validate_sql_length(cls, v):
if len(v) > MAX_QUERY_LENGTH:
raise ValueError(f"SQL 内容不能超过 {MAX_QUERY_LENGTH} 字符")
return v
class TicketExecuteConfirm(BaseModel):
"""工单执行二次确认请求。"""
confirmed: bool = Field(..., description="是否确认执行")
comment: str = Field(default="", max_length=500, description="执行备注")
# ===== 辅助函数 =====
async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
"""丰富工单数据,添加申请人/审批人名称等信息。
Args:
ticket: 工单对象
db: 数据库会话
Returns:
丰富的工单字典
"""
data = {
"id": ticket.id,
"title": ticket.title,
@@ -68,27 +156,121 @@ async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
return data
async def _get_instance(db: AsyncSession, instance_id: int) -> Instance:
"""获取数据源实例,不存在则抛出 404。
Args:
db: 数据库会话
instance_id: 实例ID
Returns:
Instance 对象
Raises:
HTTPException: 实例不存在
"""
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
return instance
async def _get_ticket(db: AsyncSession, ticket_id: int) -> Ticket:
"""获取工单,不存在则抛出 404。
Args:
db: 数据库会话
ticket_id: 工单ID
Returns:
Ticket 对象
Raises:
HTTPException: 工单不存在
"""
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
return ticket
def _validate_ticket_content(data: TicketCreate) -> None:
"""验证工单内容安全性。
Args:
data: 工单创建数据
Raises:
HTTPException: 验证失败
"""
# SQL 内容长度检查
if len(data.sql_content) > MAX_QUERY_LENGTH:
raise HTTPException(
status_code=400,
detail=f"SQL 内容过长({len(data.sql_content)} 字符),最大允许 {MAX_QUERY_LENGTH} 字符",
)
# 检测 null 字节
if "\x00" in data.sql_content:
raise HTTPException(status_code=400, detail="SQL 内容包含非法字符(null 字节)")
# 检测零宽字符(可能的注入攻击)
zero_width_chars = ["\u200b", "\u200c", "\u200d", "\ufeff"]
for char in zero_width_chars:
if char in data.sql_content:
raise HTTPException(
status_code=400,
detail="SQL 内容包含非法的零宽 Unicode 字符",
)
# 回滚 SQL 长度检查
if data.rollback_sql and len(data.rollback_sql) > MAX_QUERY_LENGTH:
raise HTTPException(
status_code=400,
detail=f"回滚 SQL 过长({len(data.rollback_sql)} 字符),最大允许 {MAX_QUERY_LENGTH} 字符",
)
# ===== API 端点 =====
@router.get("")
async def list_tickets(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(10, ge=1, le=100, description="每页数量"),
status: Optional[str] = None,
db_type: Optional[str] = None,
keyword: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取工单列表。
- admin/dba 可查看所有工单
- developer 只能查看自己的工单
"""
stmt = select(Ticket)
# developer 只能看到自己的工单
# 权限过滤: developer 只能看到自己的工单
if current_user.role == "developer":
stmt = stmt.where(Ticket.applicant_id == current_user.id)
# 状态过滤
if status:
# 验证状态值合法性
valid_statuses = [s.value for s in TicketStatus]
if status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"无效的状态值: {status}")
stmt = stmt.where(Ticket.status == status)
if db_type:
stmt = stmt.where(Ticket.db_type == db_type)
if keyword:
# 限制关键词长度防止 DoS
keyword = keyword[:100]
stmt = stmt.where(
or_(
Ticket.title.contains(keyword),
@@ -96,10 +278,17 @@ async def list_tickets(
)
)
# 总数查询
total_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(total_stmt)).scalar()
total_result = await db.execute(total_stmt)
total = total_result.scalar() or 0
stmt = stmt.order_by(Ticket.create_time.desc()).offset((page - 1) * page_size).limit(page_size)
# 分页查询
stmt = (
stmt.order_by(Ticket.create_time.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
result = await db.execute(stmt)
tickets = result.scalars().all()
@@ -107,7 +296,12 @@ async def list_tickets(
for t in tickets:
items.append(await _enrich_ticket(t, db))
return {"total": total, "page": page, "page_size": page_size, "items": items}
return {
"total": total,
"page": page,
"page_size": page_size,
"items": items,
}
@router.post("", status_code=201)
@@ -116,26 +310,39 @@ async def create_ticket(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Verify instance exists
inst_result = await db.execute(select(Instance).where(Instance.id == data.instance_id))
instance = inst_result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=400, detail="数据源不存在")
"""创建工单。
# 权限组检查:用户是否有权对该数据源提交工单
流程:
1. 验证数据源存在和权限
2. 验证工单内容安全性
3. 自动 SQL 审核
4. 创建工单记录
5. 如有多级审批流,创建审批步骤
"""
# 1. 验证数据源
instance = await _get_instance(db, data.instance_id)
# 2. 权限检查
try:
await check_instance_access(current_user, instance, db)
except HTTPException:
raise HTTPException(status_code=403, detail="您没有权限对该数据源提交变更工单")
# P1: 操作级权限检查
await check_instance_permission(current_user, instance, "ticket:create", db)
# Auto-audit SQL before creating ticket (使用 V2 引擎)
v2_engine = SQLAuditEngineV2(db_session=db, env="production" if data.db_type == "mysql" else "development")
v2_report = await v2_engine.audit(data.sql_content, db_type=data.db_type)
# 3. 验证工单内容安全性
_validate_ticket_content(data)
# Check for default workflow to determine initial status
# 4. 自动审核 SQL
env = "production" if instance.env == "production" else "development"
v2_engine = SQLAuditEngineV2(db_session=db, env=env)
try:
v2_report = await v2_engine.audit(data.sql_content, db_type=data.db_type)
except Exception as e:
logger.error("SQL 审核失败: %s", e)
v2_report = None
# 5. 检查是否有默认审批流
default_wf_result = await db.execute(
select(WorkflowDefinition).where(
WorkflowDefinition.is_default == True,
@@ -144,8 +351,9 @@ async def create_ticket(
)
default_workflow = default_wf_result.scalar_one_or_none()
initial_status = "reviewing" if default_workflow else "pending"
initial_status = TicketStatus.REVIEWING.value if default_workflow else TicketStatus.PENDING.value
# 6. 创建工单
ticket = Ticket(
title=data.title,
db_type=data.db_type,
@@ -157,12 +365,12 @@ async def create_ticket(
priority=data.priority,
applicant_id=current_user.id,
status=initial_status,
audit_result=json.dumps(v2_report.to_dict(), ensure_ascii=False) if v2_report.findings else None,
audit_result=json.dumps(v2_report.to_dict(), ensure_ascii=False) if v2_report and v2_report.findings else None,
)
db.add(ticket)
await db.flush() # flush to get ticket.id
# If default workflow exists, create TicketApproval entries for each step
# 7. 创建审批步骤(多级审批流)
if default_workflow:
wf_steps_result = await db.execute(
select(WorkflowStep)
@@ -181,17 +389,22 @@ async def create_ticket(
db.add(approval)
await db.flush()
# Log ticket creation
# 8. 记录审计日志
audit_detail = ""
if v2_report:
audit_detail = f"SQL审核评分: {v2_report.score}, 通过: {v2_report.passed}, 规则数: {len(v2_report.findings)}"
await AuditLogService.log(
db, user_id=current_user.id, action="CREATE_TICKET",
target_type="ticket", target_id=ticket.id,
detail=f"SQL审核评分: {v2_report.score}, 通过: {v2_report.passed}, 规则数: {len(v2_report.findings)}",
detail=audit_detail or "工单创建",
)
await db.commit()
await db.refresh(ticket)
result = await _enrich_ticket(ticket, db)
result["audit_report"] = v2_report.to_dict()
if v2_report:
result["audit_report"] = v2_report.to_dict()
return result
@@ -200,6 +413,7 @@ async def ticket_statistics(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取工单统计信息。"""
stmt = select(Ticket.status, func.count(Ticket.id)).group_by(Ticket.status)
result = await db.execute(stmt)
stats = {row[0]: row[1] for row in result.all()}
@@ -214,6 +428,7 @@ async def ticket_statistics(
"executing": stats.get("executing", 0),
"success": stats.get("success", 0),
"failed": stats.get("failed", 0),
"rollback": stats.get("rollback", 0),
}
@@ -223,26 +438,35 @@ async def audit_sql(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Verify instance exists
inst_result = await db.execute(select(Instance).where(Instance.id == data.instance_id))
instance = inst_result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=400, detail="数据源不存在")
"""SQL 审核接口(独立调用)。"""
# 验证实例存在
instance = await _get_instance(db, data.instance_id)
env = "production" if instance.env == "production" else "development"
db_type = instance.db_type or "mysql"
# 使用 V2 引擎进行审核(支持数据库加载规则配置)
# 使用 V2 引擎进行审核
v2_engine = SQLAuditEngineV2(db_session=db, env=env)
report = await v2_engine.audit(data.sql_content, db_type=db_type)
try:
report = await v2_engine.audit(data.sql_content, db_type=db_type)
except Exception as e:
logger.error("SQL 审核异常: %s", e)
raise HTTPException(status_code=500, detail=f"审核引擎异常: {e}")
return {
"score": report.score,
"passed": report.passed,
"has_warnings": report.has_warnings,
"summary": report.summary,
"risk_level": getattr(report, 'risk_level', 'unknown'),
"summary": getattr(report, 'summary', ''),
"findings": [
{"rule": f.rule_code, "level": f.level.value, "message": f.message}
{
"rule": getattr(f, 'rule_code', getattr(f, 'rule', 'unknown')),
"level": f.level.value,
"message": f.message,
"risk_level": getattr(f, 'risk_level', 'medium'),
"suggestion": getattr(f, 'suggestion', ''),
}
for f in report.findings
],
}
@@ -254,11 +478,10 @@ async def get_ticket(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
"""获取工单详情。"""
ticket = await _get_ticket(db, ticket_id)
# 权限检查: developer 只能查看自己的工单
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
raise HTTPException(status_code=403, detail="无权查看该工单")
@@ -271,11 +494,12 @@ async def get_ticket_approvals(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取工单的审批进度(多级审批流)"""
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
"""获取工单的审批进度(多级审批流)。"""
ticket = await _get_ticket(db, ticket_id)
# 权限检查
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
raise HTTPException(status_code=403, detail="无权查看该工单")
approvals_result = await db.execute(
select(TicketApproval)
@@ -307,16 +531,23 @@ async def review_ticket(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
"""审核工单。
if ticket.status not in ("pending", "reviewing"):
raise HTTPException(status_code=400, detail=f"工单状态为 {ticket.status},无法审核")
支持多级审批流:
- 通过: 按顺序审批,所有步骤通过后状态变为 approved
- 拒绝: 立即拒绝整个工单
"""
ticket = await _get_ticket(db, ticket_id)
# 状态机验证: 只能审批 pending 或 reviewing 状态的工单
if ticket.status not in (TicketStatus.PENDING.value, TicketStatus.REVIEWING.value):
raise HTTPException(
status_code=400,
detail=f"工单状态为 {ticket.status},无法审核。只能审批 pending/reviewing 状态的工单"
)
if data.action == "approve":
# Check for pending workflow steps (multi-step approval)
# 检查多级审批流
pending_approvals_result = await db.execute(
select(TicketApproval)
.where(TicketApproval.ticket_id == ticket_id)
@@ -325,7 +556,7 @@ async def review_ticket(
pending_approvals = pending_approvals_result.scalars().all()
if pending_approvals:
# Multi-step workflow: find the current pending step
# 多级审批: 找到当前待审批步骤
current_step = None
for pa in pending_approvals:
if pa.action == "pending":
@@ -333,30 +564,29 @@ async def review_ticket(
break
if current_step:
# Approve current step
# 审批当前步骤
current_step.action = "approved"
current_step.approver_id = current_user.id
current_step.comment = data.comment or "审批通过"
# Check if there's a next pending step
# 检查是否还有后续待审批步骤
has_next = any(
pa.action == "pending"
for pa in pending_approvals
if pa.id != current_step.id
)
if has_next:
ticket.status = "reviewing"
new_status = TicketStatus.REVIEWING.value
else:
ticket.status = "approved"
new_status = TicketStatus.APPROVED.value
else:
# All steps already processed
ticket.status = "approved"
# 所有步骤已处理
new_status = TicketStatus.APPROVED.value
else:
# No workflow steps — single-step approval (backward compat)
ticket.status = "approved"
# 无审批流 - 单步审批
new_status = TicketStatus.APPROVED.value
else:
# Reject: reject the whole ticket immediately
# Mark all pending approval steps as rejected
# 拒绝: 标记所有待审批步骤为拒绝
pending_approvals_result = await db.execute(
select(TicketApproval)
.where(TicketApproval.ticket_id == ticket_id, TicketApproval.action == "pending")
@@ -366,8 +596,16 @@ async def review_ticket(
pa.approver_id = current_user.id
pa.comment = data.comment or "审批拒绝"
ticket.status = "rejected"
new_status = TicketStatus.REJECTED.value
# 验证状态转换合法性
if not _validate_status_transition(ticket.status, new_status):
raise HTTPException(
status_code=400,
detail=f"非法的状态转换: {ticket.status} -> {new_status}"
)
ticket.status = new_status
ticket.approver_id = current_user.id
ticket.review_time = datetime.now(timezone.utc)
ticket.audit_result = data.comment
@@ -375,7 +613,7 @@ async def review_ticket(
await AuditLogService.log(
db, user_id=current_user.id, action="REVIEW_TICKET",
target_type="ticket", target_id=ticket.id,
detail=f"审核动作: {data.action}, 备注: {data.comment}",
detail=f"审核动作: {data.action}, 备注: {data.comment}, 新状态: {new_status}",
)
await db.commit()
@@ -389,16 +627,13 @@ async def get_ticket_timeline(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
"""获取工单时间线。"""
ticket = await _get_ticket(db, ticket_id)
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
raise HTTPException(status_code=403, detail="无权查看该工单")
# Fetch audit logs for this ticket
from app.models.audit_log import AuditLog
# 获取审计日志
log_stmt = (
select(AuditLog)
.where(AuditLog.target_type == "ticket", AuditLog.target_id == ticket_id)
@@ -409,7 +644,7 @@ async def get_ticket_timeline(
timeline = []
# Creation event
# 创建事件
applicant_name = "未知"
if ticket.applicant_id:
r = await db.execute(select(User).where(User.id == ticket.applicant_id))
@@ -425,10 +660,10 @@ async def get_ticket_timeline(
"detail": "提交 SQL 变更工单",
})
# From audit logs
# 审计日志事件
for log in logs:
if log.action == "CREATE_TICKET":
continue # already handled
continue
r = await db.execute(select(User).where(User.id == log.user_id))
u = r.scalar_one_or_none()
uname = u.display_name if u else "未知"
@@ -447,7 +682,7 @@ async def get_ticket_timeline(
"detail": log.detail,
})
# Status-based synthetic events if no log entries cover them
# 基于状态的合成事件
if ticket.review_time:
has_review = any(t["action"] == "REVIEW_TICKET" for t in timeline)
if not has_review:
@@ -485,21 +720,40 @@ async def cancel_ticket(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
"""取消工单。
# Only applicant or admin can cancel
只有申请人或管理员可以取消 pending/reviewing 状态的工单。
"""
ticket = await _get_ticket(db, ticket_id)
# 权限检查
if ticket.applicant_id != current_user.id and current_user.role != "admin":
raise HTTPException(status_code=403, detail="只有申请人或管理员可以取消工单")
if ticket.status not in ("pending", "reviewing"):
# 状态检查
if ticket.status not in (TicketStatus.PENDING.value, TicketStatus.REVIEWING.value):
raise HTTPException(status_code=400, detail="只能取消待审批或审批中的工单")
ticket.status = "rejected"
# 状态转换
new_status = TicketStatus.REJECTED.value
if not _validate_status_transition(ticket.status, new_status):
raise HTTPException(
status_code=400,
detail=f"非法的状态转换: {ticket.status} -> {new_status}"
)
ticket.status = new_status
ticket.audit_result = "工单被取消"
# 更新审批步骤状态
pending_approvals_result = await db.execute(
select(TicketApproval)
.where(TicketApproval.ticket_id == ticket_id, TicketApproval.action == "pending")
)
for pa in pending_approvals_result.scalars().all():
pa.action = "cancelled"
pa.comment = "工单被取消"
await AuditLogService.log(
db, user_id=current_user.id, action="CANCEL_TICKET",
target_type="ticket", target_id=ticket.id,
@@ -517,24 +771,32 @@ async def rollback_ticket(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
"""回滚工单。
if ticket.status not in ("success", "failed"):
只能回滚 success 或 failed 状态的工单。
"""
ticket = await _get_ticket(db, ticket_id)
if ticket.status not in (TicketStatus.SUCCESS.value, TicketStatus.FAILED.value):
raise HTTPException(status_code=400, detail="只能回滚已执行(成功或失败)的工单")
# Simplified rollback — return info message
# 状态转换
new_status = TicketStatus.ROLLBACK.value
if not _validate_status_transition(ticket.status, new_status):
raise HTTPException(
status_code=400,
detail=f"非法的状态转换: {ticket.status} -> {new_status}"
)
rollback_sql = ticket.rollback_sql or "未提供回滚 SQL"
await AuditLogService.log(
db, user_id=current_user.id, action="ROLLBACK_TICKET",
target_type="ticket", target_id=ticket.id,
detail="请求回滚工单",
detail=f"请求回滚工单,回滚SQL: {rollback_sql[:200]}",
)
ticket.status = "rollback"
ticket.status = new_status
await db.commit()
await db.refresh(ticket)
@@ -549,49 +811,61 @@ async def rollback_ticket(
@router.post("/{ticket_id}/execute")
async def execute_ticket(
ticket_id: int,
confirm: Optional[TicketExecuteConfirm] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""执行工单。
使用多数据库工单执行引擎,按 db_type 自动分发到对应执行器。
支持 MySQL、PostgreSQL、Redis、MongoDB。
执行流程包括:语法预校验、执行前快照、执行变更、执行后验证、生成报告。
执行流程:
1. 验证工单状态为 approved
2. 二次确认(可选)
3. 更新状态为 executing
4. 执行变更
5. 更新结果和状态
6. 记录审计日志
7. 发送通知
Args:
ticket_id: 工单 ID
confirm: 二次确认参数
db: 数据库会话
current_user: 当前用户(需要 admin 或 dba 角色)
Returns:
执行后的工单详情,包含执行报告
Raises:
HTTPException: 工单不存在、状态不正确、数据源不存在等
"""
# 1. 查询工单
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
ticket = await _get_ticket(db, ticket_id)
if ticket.status != "approved":
# 2. 状态检查
if ticket.status != TicketStatus.APPROVED.value:
raise HTTPException(status_code=400, detail="只能执行已审批的工单")
# 2. 查询数据源实例
inst_result = await db.execute(
select(Instance).where(Instance.id == ticket.instance_id)
)
instance = inst_result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=400, detail="数据源不存在")
# 3. 二次确认检查
if confirm and not confirm.confirmed:
raise HTTPException(status_code=400, detail="未确认执行,操作已取消")
# 3. 更新工单状态为执行中
ticket.status = "executing"
# 4. 查询数据源实例
instance = await _get_instance(db, ticket.instance_id)
# 5. 权限检查
await check_instance_permission(current_user, instance, "ticket:execute", db)
# 6. 状态转换: approved -> executing
if not _validate_status_transition(ticket.status, TicketStatus.EXECUTING.value):
raise HTTPException(
status_code=400,
detail=f"非法的状态转换: {ticket.status} -> {TicketStatus.EXECUTING.value}"
)
ticket.status = TicketStatus.EXECUTING.value
await db.commit()
await db.refresh(ticket)
# 4. 使用新的多数据库执行引擎
# 7. 使用多数据库执行引擎
from app.services.ticket_executor import TicketExecutor
executor = TicketExecutor()
@@ -602,22 +876,30 @@ async def execute_ticket(
# 执行工单
report = await executor.execute(ticket, instance)
# 5. 更新工单状态和结果
# 8. 更新工单状态和结果
if report.status == "success":
ticket.status = "success"
new_status = TicketStatus.SUCCESS.value
else:
ticket.status = "failed"
new_status = TicketStatus.FAILED.value
# 验证状态转换
if _validate_status_transition(TicketStatus.EXECUTING.value, new_status):
ticket.status = new_status
else:
ticket.status = TicketStatus.FAILED.value
ticket.execute_result = report.to_json()
ticket.execute_time = datetime.now(timezone.utc)
# 6. 记录执行日志
# 9. 记录执行日志
log_detail = (
f"执行结果: {report.status}, 影响行数: {report.affected_rows}, "
f"耗时: {report.duration_ms}ms, 验证通过: {report.validation_passed}"
)
if report.errors:
log_detail += f", 错误: {'; '.join(report.errors[:3])}"
if confirm and confirm.comment:
log_detail += f", 执行备注: {confirm.comment}"
await AuditLogService.log(
db,
@@ -628,16 +910,20 @@ async def execute_ticket(
detail=log_detail,
)
# 7. 发送通知(执行完成后)
# 10. 发送通知
try:
await _send_execution_notification(db, ticket, report)
except Exception:
except Exception as notify_err:
logger.error("执行通知发送失败: %s", notify_err)
# 通知失败不应影响执行结果
pass
except Exception as e:
ticket.status = "failed"
ticket.execute_result = str(e)
logger.exception("工单 %d 执行异常", ticket_id)
ticket.status = TicketStatus.FAILED.value
ticket.execute_result = json.dumps({
"status": "failed",
"errors": [f"{type(e).__name__}: {e}"],
}, ensure_ascii=False)
ticket.execute_time = datetime.now(timezone.utc)
await AuditLogService.log(
@@ -649,7 +935,7 @@ async def execute_ticket(
detail=f"执行异常: {type(e).__name__}: {e}",
)
# 8. 提交事务并返回
# 11. 提交事务并返回
await db.commit()
await db.refresh(ticket)
@@ -658,7 +944,6 @@ async def execute_ticket(
# 尝试解析执行报告
try:
import json
exec_report = json.loads(ticket.execute_result) if ticket.execute_result else {}
if isinstance(exec_report, dict):
from app.schemas.execution import ExecutionReportResponse
@@ -674,7 +959,6 @@ async def execute_ticket(
after_snapshot_summary=exec_report.get("after_snapshot"),
)
except Exception:
# 如果解析失败,不返回结构化报告
ticket_data["execution_report"] = None
return ticket_data
@@ -736,7 +1020,7 @@ async def _send_execution_notification(
"duration": report.duration_ms,
"affected_rows": report.affected_rows,
"error": "; ".join(report.errors[:3]) if report.errors else "",
"operator": "dba", # 执行人角色
"operator": "dba",
}
await sender.notify_ticket_event(
+319 -12
View File
@@ -1,5 +1,8 @@
"""审批流程模型 — 支持多级、并行、条件审批。"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, func
"""审批流程模型 — 支持多级、并行、条件审批、版本控制与执行日志。"""
import json
from sqlalchemy import (
Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON, func, UniqueConstraint
)
from sqlalchemy.orm import relationship
from app.core.database import Base
@@ -7,49 +10,353 @@ from app.core.database import Base
class WorkflowDefinition(Base):
"""审批流程定义模板。"""
__tablename__ = "workflow_definitions"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
category = Column(String(50), default="ticket") # ticket / query / instance / general
is_default = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
steps = relationship("WorkflowStep", back_populates="workflow",
order_by="WorkflowStep.step_number",
cascade="all, delete-orphan")
# ═══════════════════════════════════════════════════════════
# 流程级配置(JSON格式,灵活扩展)
# ═══════════════════════════════════════════════════════════
# 全局流程配置:
# {
# "timeout_strategy": "auto_approve|auto_reject|escalate|notify",
# "timeout_hours": 48,
# "escalation_user_id": 1,
# "allow_transfer": true,
# "allow_skip": false,
# "require_comment": true,
# "parallel_mode": "all|any", // 并行审批模式:全部通过/任一通过
# "notify_channels": ["email", "sms", "webhook"]
# }
flow_config = Column(JSON, nullable=True, default=dict)
# 触发条件配置(支持多条件组合):
# {
# "conditions": [
# {"field": "ticket.type", "operator": "in", "value": ["DDL", "DML"]},
# {"field": "ticket.risk_level", "operator": ">=", "value": "high"},
# {"field": "database.instance_id", "operator": "==", "value": 1}
# ],
# "logic": "AND|OR"
# }
trigger_conditions = Column(JSON, nullable=True)
# 步骤定义关联
steps = relationship(
"WorkflowStep",
back_populates="workflow",
order_by="WorkflowStep.step_number",
cascade="all, delete-orphan"
)
# 版本关联
versions = relationship(
"WorkflowVersion",
back_populates="workflow",
order_by="desc(WorkflowVersion.version_number)",
cascade="all, delete-orphan"
)
# 审批实例关联
approvals = relationship(
"TicketApproval",
back_populates="workflow_def",
lazy="dynamic"
)
# 执行日志关联
execution_logs = relationship(
"WorkflowExecutionLog",
back_populates="workflow",
lazy="dynamic",
order_by="desc(WorkflowExecutionLog.created_at)"
)
def get_current_version(self):
"""获取当前激活的版本。"""
return next((v for v in self.versions if v.is_active), None)
def to_dict(self, include_steps: bool = False) -> dict:
data = {
"id": self.id,
"name": self.name,
"description": self.description,
"category": self.category,
"is_default": self.is_default,
"is_active": self.is_active,
"flow_config": self.flow_config,
"trigger_conditions": self.trigger_conditions,
"step_count": len(self.steps) if self.steps else 0,
"version_count": len(self.versions) if self.versions else 0,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
if include_steps:
data["steps"] = [s.to_dict() for s in (self.steps or [])]
return data
class WorkflowVersion(Base):
"""审批流程版本控制 — 支持流程的版本管理与回溯。"""
__tablename__ = "workflow_versions"
__table_args__ = (UniqueConstraint("workflow_id", "version_number", name="uix_workflow_version"),)
id = Column(Integer, primary_key=True, autoincrement=True)
workflow_id = Column(Integer, ForeignKey("workflow_definitions.id", ondelete="CASCADE"), nullable=False)
version_number = Column(Integer, nullable=False) # 版本号,递增
version_name = Column(String(100), nullable=True) # 版本名称,如 "v2.0-添加并行审批"
is_active = Column(Boolean, default=False) # 是否为当前激活版本
# 版本快照 — 存储此版本完整的流程定义快照
steps_snapshot = Column(JSON, nullable=False)
flow_config_snapshot = Column(JSON, nullable=True)
# 变更说明
change_summary = Column(Text, nullable=True)
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, server_default=func.now())
# 关联
workflow = relationship("WorkflowDefinition", back_populates="versions")
def to_dict(self) -> dict:
return {
"id": self.id,
"workflow_id": self.workflow_id,
"version_number": self.version_number,
"version_name": self.version_name,
"is_active": self.is_active,
"change_summary": self.change_summary,
"steps_count": len(self.steps_snapshot) if self.steps_snapshot else 0,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class WorkflowStep(Base):
"""审批步骤定义。"""
"""审批步骤定义 — 支持灵活的条件判断与动态审批人分配。"""
__tablename__ = "workflow_steps"
id = Column(Integer, primary_key=True, autoincrement=True)
workflow_id = Column(Integer, ForeignKey("workflow_definitions.id"), nullable=False)
workflow_id = Column(Integer, ForeignKey("workflow_definitions.id", ondelete="CASCADE"), nullable=False)
step_number = Column(Integer, nullable=False)
name = Column(String(100), nullable=False)
step_type = Column(String(20), nullable=False, default="serial") # serial / parallel / any_one
approver_role = Column(String(20), nullable=True) # admin / dba / manager
approver_ids = Column(Text, nullable=True) # JSON list of specific user ids
description = Column(Text, nullable=True)
# 步骤类型: serial(串行) / parallel(并行-会签) / any_one(或签) / condition(条件分支)
step_type = Column(String(20), nullable=False, default="serial")
# 审批人角色
approver_role = Column(String(20), nullable=True) # admin / dba / manager / creator
# 指定审批人ID列表(JSON数组)
approver_ids = Column(JSON, nullable=True)
# 审批人分配策略:
# {
# "type": "role|specific|leader|load_balance|creator|expression",
# "expression": "${ticket.creator.manager_id}", // 动态表达式
# "fallback_user_id": 1, // 无法解析时的 fallback
# "department_id": 2 // 指定部门
# }
approver_assignment = Column(JSON, nullable=True, default=dict)
is_required = Column(Boolean, default=True)
timeout_hours = Column(Integer, default=24)
auto_approve_on_timeout = Column(Boolean, default=False)
# ═══════════════════════════════════════════════════════════
# 条件判断配置(条件步骤使用)
# ═══════════════════════════════════════════════════════════
# 步骤准入条件(满足条件才进入此步骤):
# {
# "conditions": [
# {"field": "ticket.sql_type", "operator": "in", "value": ["ALTER", "DROP"]},
# {"field": "affected_rows", "operator": ">", "value": 1000}
# ],
# "logic": "AND"
# }
entry_conditions = Column(JSON, nullable=True)
# 步骤流转条件(满足条件才进入下一步,否则跳过后续步骤):
# {
# "conditions": [
# {"field": "current.result", "operator": "==", "value": "approved"}
# ],
# "on_false_action": "skip|reject|goto_step",
# "goto_step_number": 3
# }
transition_conditions = Column(JSON, nullable=True)
# ═══════════════════════════════════════════════════════════
# 字段权限配置
# ═══════════════════════════════════════════════════════════
# 此步骤中可查看/编辑的字段:
# {
# "visible_fields": ["ticket.title", "ticket.sql", "ticket.description"],
# "editable_fields": ["ticket.comment"],
# "masked_fields": ["ticket.password", "ticket.secret_key"]
# }
field_permissions = Column(JSON, nullable=True)
workflow = relationship("WorkflowDefinition", back_populates="steps")
def to_dict(self) -> dict:
return {
"id": self.id,
"workflow_id": self.workflow_id,
"step_number": self.step_number,
"name": self.name,
"description": self.description,
"step_type": self.step_type,
"approver_role": self.approver_role,
"approver_ids": self.approver_ids,
"approver_assignment": self.approver_assignment,
"is_required": self.is_required,
"timeout_hours": self.timeout_hours,
"auto_approve_on_timeout": self.auto_approve_on_timeout,
"entry_conditions": self.entry_conditions,
"transition_conditions": self.transition_conditions,
"field_permissions": self.field_permissions,
}
class TicketApproval(Base):
"""工单审批实例(运行时记录)。"""
__tablename__ = "ticket_approvals"
id = Column(Integer, primary_key=True, autoincrement=True)
ticket_id = Column(Integer, ForeignKey("tickets.id"), nullable=False)
ticket_id = Column(Integer, ForeignKey("tickets.id"), nullable=False, index=True)
# 关联的流程定义和版本
workflow_id = Column(Integer, ForeignKey("workflow_definitions.id"), nullable=True)
version_id = Column(Integer, ForeignKey("workflow_versions.id"), nullable=True)
step_number = Column(Integer, nullable=False)
step_name = Column(String(100), nullable=True)
step_type = Column(String(20), default="serial") # serial / parallel / any_one / condition
# 审批人信息
approver_id = Column(Integer, ForeignKey("users.id"), nullable=True)
approver_role = Column(String(20), nullable=True)
action = Column(String(20), default="pending") # pending / approved / rejected / transferred / skipped
# 实际执行人(转交场景)
actual_approver_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# 审批动作: pending / approved / rejected / transferred / skipped / auto_approved
action = Column(String(20), default="pending", index=True)
comment = Column(Text, nullable=True)
# 转交信息
transfer_from = Column(Integer, ForeignKey("users.id"), nullable=True)
transfer_reason = Column(Text, nullable=True)
# 审批时间记录
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
completed_at = Column(DateTime, nullable=True)
# 超时标记
is_timeout = Column(Boolean, default=False)
timeout_handled_at = Column(DateTime, nullable=True)
# 关联
workflow_def = relationship("WorkflowDefinition", back_populates="approvals")
approver = relationship("User", foreign_keys=[approver_id])
actual_approver = relationship("User", foreign_keys=[actual_approver_id])
def to_dict(self) -> dict:
return {
"id": self.id,
"ticket_id": self.ticket_id,
"workflow_id": self.workflow_id,
"step_number": self.step_number,
"step_name": self.step_name,
"step_type": self.step_type,
"approver_id": self.approver_id,
"approver_role": self.approver_role,
"approver_name": self.approver.display_name if self.approver else None,
"actual_approver_id": self.actual_approver_id,
"action": self.action,
"comment": self.comment,
"transfer_from": self.transfer_from,
"transfer_reason": self.transfer_reason,
"is_timeout": self.is_timeout,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
}
class WorkflowExecutionLog(Base):
"""流程执行日志 — 记录审批流程的完整执行轨迹,用于审计和问题排查。"""
__tablename__ = "workflow_execution_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
workflow_id = Column(Integer, ForeignKey("workflow_definitions.id"), nullable=False, index=True)
ticket_id = Column(Integer, ForeignKey("tickets.id"), nullable=True, index=True)
step_id = Column(Integer, ForeignKey("workflow_steps.id"), nullable=True)
approval_id = Column(Integer, ForeignKey("ticket_approvals.id"), nullable=True)
# 事件类型:
# workflow_started / step_entered / step_completed / approved / rejected /
# transferred / skipped / timeout / escalated / workflow_completed / workflow_cancelled
event_type = Column(String(50), nullable=False, index=True)
# 事件详情
event_description = Column(Text, nullable=True)
# 执行人
actor_id = Column(Integer, ForeignKey("users.id"), nullable=True)
actor_name = Column(String(100), nullable=True)
# 上下文数据(快照):
# {
# "step_snapshot": {...},
# "approval_snapshot": {...},
# "decision_context": {...},
# "condition_results": [...]
# }
context_data = Column(JSON, nullable=True)
created_at = Column(DateTime, server_default=func.now(), index=True)
# 关联
workflow = relationship("WorkflowDefinition", back_populates="execution_logs")
def to_dict(self) -> dict:
return {
"id": self.id,
"workflow_id": self.workflow_id,
"ticket_id": self.ticket_id,
"step_id": self.step_id,
"approval_id": self.approval_id,
"event_type": self.event_type,
"event_description": self.event_description,
"actor_id": self.actor_id,
"actor_name": self.actor_name,
"context_data": self.context_data,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self) -> str:
return (
f"<WorkflowExecutionLog("
f"workflow={self.workflow_id}, ticket={self.ticket_id}, "
f"event='{self.event_type}', actor='{self.actor_name}')>"
)
+708 -152
View File
@@ -4,11 +4,23 @@ Checks SQL against configurable rules and returns structured results:
- level: "error" (must fix), "warning" (should review), "info" (FYI)
- rule: rule identifier
- message: human-readable description
Supports:
- MySQL / PostgreSQL SQL audit
- Redis command audit
- MongoDB operation audit
- Dynamic rule loading from configuration
- Risk level assessment (LOW / MEDIUM / HIGH / CRITICAL)
- Weighted scoring algorithm
"""
import json
import logging
import re
from dataclasses import dataclass, field
from typing import List, Optional
from enum import Enum
from typing import Any, Callable, Dict, List, Optional, Protocol
logger = logging.getLogger(__name__)
class Severity(str, Enum):
@@ -17,17 +29,41 @@ class Severity(str, Enum):
INFO = "info"
class RiskLevel(str, Enum):
"""风险等级评估。"""
LOW = "low" # 低风险,一般建议
MEDIUM = "medium" # 中风险,需要关注
HIGH = "high" # 高风险,强烈建议修改
CRITICAL = "critical" # 严重风险,必须修改
@dataclass
class AuditFinding:
rule: str
level: Severity
message: str
risk_level: RiskLevel = RiskLevel.MEDIUM # 新增:风险等级
suggestion: str = "" # 新增:修改建议
category: str = "general" # 新增:规则分类
def to_dict(self) -> dict:
return {
"rule": self.rule,
"level": self.level.value,
"message": self.message,
"risk_level": self.risk_level.value,
"suggestion": self.suggestion,
"category": self.category,
}
@dataclass
class AuditReport:
sql: str
findings: List[AuditFinding] = field(default_factory=list)
db_type: str = "mysql"
risk_level: RiskLevel = RiskLevel.LOW
risk_score: int = 0
@property
def passed(self) -> bool:
@@ -39,196 +75,716 @@ class AuditReport:
@property
def score(self) -> int:
"""100 = clean, -10 per error, -5 per warning."""
"""加权评分算法: 100 = clean, error扣10-30分, warning扣5-15分, info扣1-3分。
根据风险等级调整扣分权重:
- CRITICAL: error扣30分
- HIGH: error扣20分, warning扣15分
- MEDIUM: error扣10分, warning扣5分
- LOW: info扣1-3分
"""
s = 100
for f in self.findings:
if f.level == Severity.ERROR:
s -= 10
elif f.level == Severity.WARNING:
s -= 5
if f.risk_level == RiskLevel.CRITICAL:
if f.level == Severity.ERROR:
s -= 30
elif f.level == Severity.WARNING:
s -= 15
elif f.level == Severity.INFO:
s -= 5
elif f.risk_level == RiskLevel.HIGH:
if f.level == Severity.ERROR:
s -= 20
elif f.level == Severity.WARNING:
s -= 10
elif f.level == Severity.INFO:
s -= 3
elif f.risk_level == RiskLevel.MEDIUM:
if f.level == Severity.ERROR:
s -= 10
elif f.level == Severity.WARNING:
s -= 5
elif f.level == Severity.INFO:
s -= 2
else: # LOW
if f.level == Severity.ERROR:
s -= 5
elif f.level == Severity.WARNING:
s -= 3
elif f.level == Severity.INFO:
s -= 1
return max(0, s)
def to_dict(self) -> dict:
return {
"passed": self.passed,
"score": self.score,
"findings": [
{"rule": f.rule, "level": f.level.value, "message": f.message}
for f in self.findings
],
"db_type": self.db_type,
"risk_level": self.risk_level.value,
"risk_score": self.risk_score,
"findings": [f.to_dict() for f in self.findings],
}
def calculate_risk_level(self) -> RiskLevel:
"""根据发现的问题计算整体风险等级。"""
if any(f.level == Severity.ERROR and f.risk_level == RiskLevel.CRITICAL for f in self.findings):
return RiskLevel.CRITICAL
if any(f.level == Severity.ERROR and f.risk_level == RiskLevel.HIGH for f in self.findings):
return RiskLevel.HIGH
if any(f.level == Severity.ERROR for f in self.findings):
return RiskLevel.HIGH
critical_warnings = sum(
1 for f in self.findings
if f.level == Severity.WARNING and f.risk_level in (RiskLevel.CRITICAL, RiskLevel.HIGH)
)
if critical_warnings >= 2:
return RiskLevel.HIGH
total_warnings = sum(1 for f in self.findings if f.level == Severity.WARNING)
if total_warnings >= 3:
return RiskLevel.MEDIUM
if self.findings:
return RiskLevel.LOW
return RiskLevel.LOW
# ===== 规则配置协议 =====
class AuditRule(Protocol):
"""审核规则协议。"""
rule_code: str
category: str
severity: Severity
risk_level: RiskLevel
description: str
def check(self, content: str, upper: str, report: AuditReport) -> None:
"""执行规则检查。"""
...
# ===== 具体规则类 =====
@dataclass
class SQLAuditRule:
"""SQL审核规则基类。"""
rule_code: str
category: str
severity: Severity
risk_level: RiskLevel
description: str
suggestion: str = ""
enabled: bool = True
def check(self, content: str, upper: str, report: AuditReport) -> None:
pass
class PatternAuditRule(SQLAuditRule):
"""基于正则表达式模式的审核规则。"""
def __init__(
self,
rule_code: str,
category: str,
severity: Severity,
risk_level: RiskLevel,
description: str,
suggestion: str,
pattern: str,
exclude_pattern: Optional[str] = None,
):
super().__init__(
rule_code=rule_code,
category=category,
severity=severity,
risk_level=risk_level,
description=description,
suggestion=suggestion,
)
self.pattern = re.compile(pattern, re.IGNORECASE)
self.exclude_pattern = re.compile(exclude_pattern, re.IGNORECASE) if exclude_pattern else None
def check(self, content: str, upper: str, report: AuditReport) -> None:
if not self.enabled:
return
if self.pattern.search(upper):
if self.exclude_pattern and self.exclude_pattern.search(upper):
return
report.findings.append(AuditFinding(
rule=self.rule_code,
level=self.severity,
message=self.description,
risk_level=self.risk_level,
suggestion=self.suggestion,
category=self.category,
))
# ===== MySQL 审核规则集 =====
MYSQL_AUDIT_RULES: List[SQLAuditRule] = [
PatternAuditRule(
rule_code="DROP_TABLE_DANGER",
category="ddl",
severity=Severity.WARNING,
risk_level=RiskLevel.CRITICAL,
description="DROP TABLE 操作不可逆,请确认已备份",
suggestion="使用 DROP TABLE IF EXISTS 并在操作前备份数据",
pattern=r"\bDROP\s+TABLE\b",
),
PatternAuditRule(
rule_code="TRUNCATE_DANGER",
category="ddl",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="TRUNCATE 会清空表数据且不可回滚",
suggestion="使用 DELETE 配合 WHERE 子句替代,或使用事务",
pattern=r"\bTRUNCATE\b",
),
PatternAuditRule(
rule_code="UPDATE_NO_WHERE",
category="dml",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="UPDATE 缺少 WHERE 条件,将更新全表",
suggestion="添加 WHERE 条件限制更新范围",
pattern=r"\bUPDATE\s+\w+\s+SET\b(?!.*\bWHERE\b)",
),
PatternAuditRule(
rule_code="DELETE_NO_WHERE",
category="dml",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="DELETE 缺少 WHERE 条件,将删除全表数据",
suggestion="添加 WHERE 条件限制删除范围",
pattern=r"\bDELETE\s+FROM\s+\w+\b(?!.*\bWHERE\b)",
),
PatternAuditRule(
rule_code="SELECT_STAR",
category="query",
severity=Severity.INFO,
risk_level=RiskLevel.LOW,
description="SELECT * 可能返回过多列,建议明确指定字段",
suggestion="明确指定需要的字段名,减少网络传输和内存使用",
pattern=r"SELECT\s+\*(?:\s|,|\))",
),
PatternAuditRule(
rule_code="DML_WITH_LIMIT",
category="dml",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="UPDATE/DELETE 使用 LIMIT 可能导致部分数据变更",
suggestion="使用 WHERE 条件精确控制影响范围,避免 LIMIT",
pattern=r"\b(UPDATE|DELETE)\b.*\bLIMIT\b",
),
PatternAuditRule(
rule_code="LARGE_IN_CLAUSE",
category="query",
severity=Severity.WARNING,
risk_level=RiskLevel.MEDIUM,
description="IN 子句包含过多值,性能较差",
suggestion="使用临时表或 JOIN 替代大 IN 子句",
pattern=r"\bIN\s*\([^)]{500,}\)",
),
PatternAuditRule(
rule_code="FULL_LIKE_SCAN",
category="query",
severity=Severity.INFO,
risk_level=RiskLevel.LOW,
description="LIKE '%...' 无法使用索引,大数据量下性能较差",
suggestion="考虑使用全文索引或前缀匹配 LIKE 'prefix%'",
pattern=r"\bLIKE\s+'%[^']*'",
),
PatternAuditRule(
rule_code="JOIN_NO_ON",
category="query",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="JOIN 缺少 ON 条件,可能产生笛卡尔积",
suggestion="添加 ON 条件明确连接关系",
pattern=r"\bJOIN\b",
exclude_pattern=r"\b(NATURAL\s+JOIN|ON\b|CROSS\s+JOIN)",
),
PatternAuditRule(
rule_code="MULTI_STATEMENT",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="单次提交不支持多条 SQL 语句(分号分隔)",
suggestion="拆分为多个工单,每条语句单独提交",
pattern=r";.*\b(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)",
),
PatternAuditRule(
rule_code="SLEEP_INJECTION",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="检测到时间延迟函数(SLEEP/BENCHMARK),可能存在注入风险",
suggestion="移除时间延迟函数",
pattern=r"\b(SLEEP|BENCHMARK)\s*\(",
),
PatternAuditRule(
rule_code="INTO_OUTFILE",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="SELECT INTO OUTFILE/DUMPFILE 被禁止(文件写入风险)",
suggestion="使用客户端工具导出数据",
pattern=r"\bINTO\s+(OUTFILE|DUMPFILE)\b",
),
PatternAuditRule(
rule_code="PROCEDURE_CALL",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.HIGH,
description="存储过程/函数调用可能存在安全风险",
suggestion="避免使用存储过程,改用标准 SQL",
pattern=r"\b(CALL|EXEC|EXECUTE)\b",
),
PatternAuditRule(
rule_code="UNION_INJECTION",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="检测到 UNION 注入模式",
suggestion="检查 SQL 来源合法性",
pattern=r"\bUNION\s+(ALL\s+)?SELECT\b",
),
]
# ===== PostgreSQL 审核规则集 =====
POSTGRES_AUDIT_RULES: List[SQLAuditRule] = [
PatternAuditRule(
rule_code="PG_DROP_TABLE",
category="ddl",
severity=Severity.WARNING,
risk_level=RiskLevel.CRITICAL,
description="DROP TABLE 操作不可逆,请确认已备份",
suggestion="使用 DROP TABLE IF EXISTS 并在操作前备份数据",
pattern=r"\bDROP\s+TABLE\b",
),
PatternAuditRule(
rule_code="PG_TRUNCATE",
category="ddl",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="TRUNCATE 会清空表数据且不可回滚",
suggestion="使用 DELETE 配合 WHERE 子句或事务",
pattern=r"\bTRUNCATE\b",
),
PatternAuditRule(
rule_code="PG_UPDATE_NO_WHERE",
category="dml",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="UPDATE 缺少 WHERE 条件,将更新全表",
suggestion="添加 WHERE 条件限制更新范围",
pattern=r"\bUPDATE\s+\w+\s+SET\b(?!.*\bWHERE\b)",
),
PatternAuditRule(
rule_code="PG_DELETE_NO_WHERE",
category="dml",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="DELETE 缺少 WHERE 条件,将删除全表数据",
suggestion="添加 WHERE 条件限制删除范围",
pattern=r"\bDELETE\s+FROM\s+\w+\b(?!.*\bWHERE\b)",
),
PatternAuditRule(
rule_code="PG_SELECT_STAR",
category="query",
severity=Severity.INFO,
risk_level=RiskLevel.LOW,
description="SELECT * 可能返回过多列",
suggestion="明确指定需要的字段名",
pattern=r"SELECT\s+\*(?:\s|,|\))",
),
PatternAuditRule(
rule_code="PG_VACUUM_DANGER",
category="maintenance",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="VACUUM FULL 会锁定表并重建",
suggestion="在低峰期执行 VACUUM FULL,或使用普通 VACUUM",
pattern=r"\bVACUUM\s+FULL\b",
),
PatternAuditRule(
rule_code="PG_LOCK_TABLE",
category="locking",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="LOCK TABLE 会显式锁定表,可能导致阻塞",
suggestion="使用事务隔离级别替代显式锁",
pattern=r"\bLOCK\s+TABLE\b",
),
PatternAuditRule(
rule_code="PG_CREATE_EXTENSION",
category="ddl",
severity=Severity.WARNING,
risk_level=RiskLevel.MEDIUM,
description="CREATE EXTENSION 需要超级用户权限",
suggestion="确认扩展安全性并由 DBA 执行",
pattern=r"\bCREATE\s+EXTENSION\b",
),
PatternAuditRule(
rule_code="PG_COPY_FROM_FILE",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="COPY FROM FILE 存在文件系统访问风险",
suggestion="使用 COPY FROM STDIN 或客户端工具导入",
pattern=r"\bCOPY\b.*\bFROM\s+(?!STDIN)",
),
PatternAuditRule(
rule_code="PG_MULTI_STATEMENT",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="单次提交不支持多条 SQL 语句",
suggestion="拆分为多个工单提交",
pattern=r";.*\b(SELECT|INSERT|UPDATE|DELETE|CREATE|ALTER|DROP)",
),
PatternAuditRule(
rule_code="PG_SLEEP_INJECTION",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="检测到时间延迟函数(PG_SLEEP),可能存在注入风险",
suggestion="移除时间延迟函数",
pattern=r"\bPG_SLEEP\s*\(",
),
]
# ===== Redis 审核规则集 =====
REDIS_AUDIT_RULES: List[SQLAuditRule] = [
PatternAuditRule(
rule_code="REDIS_FLUSH",
category="danger",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="FLUSHDB/FLUSHALL 会清空所有数据",
suggestion="确认备份数据并在维护窗口执行",
pattern=r"\b(FLUSHDB|FLUSHALL)\b",
),
PatternAuditRule(
rule_code="REDIS_KEY_DELETE",
category="dml",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="批量删除键(DEL KEYS)风险较高",
suggestion="使用 UNLINK 异步删除,或使用 SCAN 分批删除",
pattern=r"\bDEL\s+(?!\w+\b)",
),
PatternAuditRule(
rule_code="REDIS_SHUTDOWN",
category="danger",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="SHUTDOWN 会关闭 Redis 服务器",
suggestion="通过运维面板操作服务器",
pattern=r"\bSHUTDOWN\b",
),
PatternAuditRule(
rule_code="REDIS_CONFIG_SET",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="CONFIG SET 可修改服务器配置",
suggestion="通过配置文件修改并由 DBA 审核",
pattern=r"\bCONFIG\s+SET\b",
),
PatternAuditRule(
rule_code="REDIS_REPLICAOF",
category="danger",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="REPLICAOF/SLAVEOF 会改变主从关系",
suggestion="通过运维面板管理主从配置",
pattern=r"\b(REPLICAOF|SLAVEOF)\b",
),
PatternAuditRule(
rule_code="REDIS_DEBUG",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="DEBUG 命令可能导致数据损坏或信息泄露",
suggestion="禁止在生产环境使用 DEBUG 命令",
pattern=r"\bDEBUG\b",
),
]
# ===== MongoDB 审核规则集 =====
MONGODB_AUDIT_RULES: List[SQLAuditRule] = [
PatternAuditRule(
rule_code="MONGO_DROP_COLLECTION",
category="ddl",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="drop 操作会删除整个集合",
suggestion="使用 deleteMany 删除文档保留集合",
pattern=r'"operation"\s*:\s*"drop"',
),
PatternAuditRule(
rule_code="MONGO_DROP_INDEX",
category="ddl",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="dropIndex 会影响查询性能",
suggestion="确认索引不再使用后再删除",
pattern=r'"operation"\s*:\s*"dropIndex"',
),
PatternAuditRule(
rule_code="MONGO_DELETE_NO_FILTER",
category="dml",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="deleteMany 缺少 filter 将删除所有文档",
suggestion="添加 filter 条件限制删除范围",
pattern=r'"operation"\s*:\s*"deleteMany"(?!.*"filter"\s*:\s*\{[^}])',
),
PatternAuditRule(
rule_code="MONGO_UPDATE_NO_FILTER",
category="dml",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="updateMany 缺少 filter 将更新所有文档",
suggestion="添加 filter 条件限制更新范围",
pattern=r'"operation"\s*:\s*"updateMany"(?!.*"filter"\s*:\s*\{[^}])',
),
PatternAuditRule(
rule_code="MONGO_UNSET_FIELD",
category="dml",
severity=Severity.WARNING,
risk_level=RiskLevel.MEDIUM,
description="$unset 会删除字段,数据不可恢复",
suggestion="确认字段确实不再需要",
pattern=r'"\$unset"',
),
PatternAuditRule(
rule_code="MONGO_RENAME_COLLECTION",
category="ddl",
severity=Severity.WARNING,
risk_level=RiskLevel.HIGH,
description="renameCollection 会重命名集合",
suggestion="确认新名称不会与现有集合冲突",
pattern=r'"operation"\s*:\s*"renameCollection"',
),
PatternAuditRule(
rule_code="MONGO_JAVASCRIPT",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="$where/$function 可执行任意 JavaScript",
suggestion="使用标准查询操作符替代",
pattern=r'"\$(where|function|accumulator)"',
),
PatternAuditRule(
rule_code="MONGO_OUT_STAGE",
category="security",
severity=Severity.ERROR,
risk_level=RiskLevel.CRITICAL,
description="aggregate $out/$merge 阶段会写入数据",
suggestion="使用 find 查询替代",
pattern=r'"\$(out|merge)"',
),
]
# ===== 规则注册表 =====
RULE_REGISTRY: Dict[str, List[SQLAuditRule]] = {
"mysql": MYSQL_AUDIT_RULES,
"postgres": POSTGRES_AUDIT_RULES,
"postgresql": POSTGRES_AUDIT_RULES,
"redis": REDIS_AUDIT_RULES,
"mongo": MONGODB_AUDIT_RULES,
}
class SQLAuditEngine:
"""Rule-based SQL audit engine."""
"""增强的规则审核引擎。
def __init__(self, env: str = "development"):
支持多数据库类型的规则审核,动态规则加载,风险等级评估。
Attributes:
env: 环境类型(development/production)
db_type: 数据库类型
custom_rules: 自定义规则列表
"""
def __init__(self, env: str = "development", db_type: str = "mysql"):
self.env = env
self.db_type = db_type.lower()
self.custom_rules: List[SQLAuditRule] = []
def add_custom_rule(self, rule: SQLAuditRule) -> None:
"""添加自定义审核规则。
Args:
rule: 自定义规则对象
"""
self.custom_rules.append(rule)
logger.info("添加自定义规则: %s", rule.rule_code)
def load_rules_from_config(self, config: List[Dict[str, Any]]) -> None:
"""从配置加载自定义规则。
配置格式:
[
{
"rule_code": "RULE_NAME",
"category": "security",
"severity": "error",
"risk_level": "critical",
"description": "规则描述",
"suggestion": "修改建议",
"pattern": r"正则表达式",
"exclude_pattern": r"排除正则" (可选)
}
]
Args:
config: 规则配置列表
"""
for cfg in config:
try:
severity = Severity(cfg.get("severity", "warning"))
risk_level = RiskLevel(cfg.get("risk_level", "medium"))
rule = PatternAuditRule(
rule_code=cfg["rule_code"],
category=cfg.get("category", "custom"),
severity=severity,
risk_level=risk_level,
description=cfg["description"],
suggestion=cfg.get("suggestion", ""),
pattern=cfg["pattern"],
exclude_pattern=cfg.get("exclude_pattern"),
)
self.custom_rules.append(rule)
logger.info("从配置加载规则: %s", rule.rule_code)
except (KeyError, re.error) as e:
logger.error("加载规则配置失败: %s, error: %s", cfg.get("rule_code", "unknown"), e)
def audit(self, sql: str) -> AuditReport:
report = AuditReport(sql=sql)
"""执行审核。
根据 db_type 选择对应的规则集,执行所有规则检查。
Args:
sql: 要审核的 SQL/命令/JSON
Returns:
AuditReport: 审核报告
"""
report = AuditReport(sql=sql, db_type=self.db_type)
clean = sql.strip()
upper = clean.upper()
# Rule 1: DDL without IF EXISTS
self._check_ddl_if_exists(clean, upper, report)
# 空内容检查
if not clean:
report.findings.append(AuditFinding(
rule="EMPTY_CONTENT",
level=Severity.ERROR,
message="审核内容不能为空",
risk_level=RiskLevel.CRITICAL,
category="validation",
))
return report
# Rule 2: DROP TABLE danger
self._check_drop_table(upper, report)
# 获取数据库类型对应的规则
rules = RULE_REGISTRY.get(self.db_type, [])
# Rule 3: UPDATE/DELETE without WHERE
self._check_missing_where(clean, upper, report)
# 执行内置规则
for rule in rules:
if rule.enabled:
rule.check(clean, upper, report)
# Rule 4: SELECT * usage
self._check_select_star(clean, upper, report)
# 执行自定义规则
for rule in self.custom_rules:
if rule.enabled:
rule.check(clean, upper, report)
# Rule 5: LIMIT on DML
self._check_limit_dml(clean, upper, report)
# Rule 6: Production env extra checks
# 环境特定规则
if self.env == "production":
self._check_production_rules(clean, upper, report)
# Rule 7: Multiple statements (semicolon)
self._check_multiple_statements(clean, report)
# Rule 8: Large IN clause
self._check_large_in(clean, upper, report)
# Rule 9: No WHERE with LIKE '%...'
self._check_full_like(clean, upper, report)
# Rule 10: JOIN without ON
self._check_join_without_on(upper, report)
# 计算风险等级
report.risk_level = report.calculate_risk_level()
report.risk_score = report.score
return report
def _check_ddl_if_exists(self, clean: str, upper: str, report: AuditReport):
"""ALTER/DROP statements should use IF EXISTS where applicable."""
if re.search(r"\bDROP\s+TABLE\b", upper) and "IF EXISTS" not in upper:
report.findings.append(AuditFinding(
rule="DDL_IF_EXISTS",
level=Severity.WARNING,
message="DROP TABLE 建议添加 IF EXISTS 防止表不存在时报错",
))
def _check_production_rules(self, clean: str, upper: str, report: AuditReport) -> None:
"""生产环境额外规则。
def _check_drop_table(self, upper: str, report: AuditReport):
"""DROP TABLE is always dangerous in production."""
if re.search(r"\bDROP\s+TABLE\b", upper):
if self.env == "production":
Args:
clean: 原始内容
upper: 大写内容
report: 审核报告
"""
if self.db_type in ("mysql", "postgres", "postgresql"):
# ALTER TABLE 限制
if re.search(r"\bALTER\s+TABLE\b", upper):
report.findings.append(AuditFinding(
rule="DROP_TABLE_PRODUCTION",
rule="ALTER_TABLE_PRODUCTION",
level=Severity.ERROR,
message="生产环境禁止 DROP TABLE 操作",
message="生产环境 ALTER TABLE 需 DBA 特别审批",
risk_level=RiskLevel.HIGH,
suggestion="在低峰期执行,使用 pt-online-schema-change 等在线工具",
category="production",
))
else:
# CREATE INDEX 限制
if re.search(r"\bCREATE\s+(UNIQUE\s+)?INDEX\b", upper):
report.findings.append(AuditFinding(
rule="DROP_TABLE",
rule="CREATE_INDEX_PRODUCTION",
level=Severity.WARNING,
message="DROP TABLE 操作不可逆,请确认已备份",
message="生产环境创建索引需确认影响范围",
risk_level=RiskLevel.MEDIUM,
suggestion="使用 ONLINE/CONCURRENT 选项避免锁表",
category="production",
))
if re.search(r"\bTRUNCATE\b", upper):
report.findings.append(AuditFinding(
rule="TRUNCATE",
level=Severity.ERROR if self.env == "production" else Severity.WARNING,
message="TRUNCATE 会清空表数据且不可回滚",
))
def _check_missing_where(self, clean: str, upper: str, report: AuditReport):
"""UPDATE/DELETE without WHERE clause affects all rows."""
if re.match(r"\s*UPDATE\s+", upper, re.IGNORECASE):
if "WHERE" not in upper:
# 没有索引的 UPDATE/DELETE
if re.search(r"\b(UPDATE|DELETE)\b", upper):
report.findings.append(AuditFinding(
rule="UPDATE_NO_WHERE",
level=Severity.ERROR,
message="UPDATE 缺少 WHERE 条件,将更新全表",
))
if re.match(r"\s*DELETE\s+FROM\s+", upper, re.IGNORECASE):
if "WHERE" not in upper:
report.findings.append(AuditFinding(
rule="DELETE_NO_WHERE",
level=Severity.ERROR,
message="DELETE 缺少 WHERE 条件,将删除全表数据",
))
def _check_select_star(self, clean: str, upper: str, report: AuditReport):
"""SELECT * is inefficient for large tables."""
if re.search(r"SELECT\s+\*(?:\s|,|\))", upper):
report.findings.append(AuditFinding(
rule="SELECT_STAR",
level=Severity.INFO,
message="SELECT * 可能返回过多列,建议明确指定字段",
))
def _check_limit_dml(self, clean: str, upper: str, report: AuditReport):
"""UPDATE/DELETE with LIMIT is MySQL-specific and risky."""
for kw in ("UPDATE", "DELETE"):
if re.match(rf"\s*{kw}\b", upper):
if "LIMIT" in upper:
report.findings.append(AuditFinding(
rule="DML_WITH_LIMIT",
level=Severity.WARNING,
message=f"{kw} 使用 LIMIT 可能导致部分数据变更,请确认意图",
))
def _check_production_rules(self, clean: str, upper: str, report: AuditReport):
"""Extra strict rules for production."""
if re.search(r"\bALTER\s+TABLE\b", upper):
report.findings.append(AuditFinding(
rule="ALTER_TABLE_PRODUCTION",
level=Severity.ERROR,
message="生产环境 ALTER TABLE 需 DBA 特别审批",
))
if re.search(r"\bCREATE\s+(TABLE|INDEX)\b", upper):
report.findings.append(AuditFinding(
rule="CREATE_DDL_PRODUCTION",
level=Severity.WARNING,
message="生产环境创建对象需确认影响范围",
))
def _check_multiple_statements(self, clean: str, report: AuditReport):
"""Multiple SQL statements separated by semicolons."""
# Count semicolons not at the end
stripped = clean.rstrip(";").strip()
if ";" in stripped:
report.findings.append(AuditFinding(
rule="MULTI_STATEMENT",
level=Severity.ERROR,
message="单次提交不支持多条 SQL 语句(分号分隔),请拆分为多个工单",
))
def _check_large_in(self, clean: str, upper: str, report: AuditReport):
"""IN clause with many values is slow."""
in_matches = re.findall(r"\bIN\s*\(([^)]+)\)", upper)
for m in in_matches:
count = m.count(",") + 1
if count > 100:
report.findings.append(AuditFinding(
rule="LARGE_IN",
rule="DML_PRODUCTION_CAUTION",
level=Severity.WARNING,
message=f"IN 子句包含 {count} 个值,建议使用临时表或 JOIN 替代",
message="生产环境 UPDATE/DELETE 需要确认 WHERE 条件有索引覆盖",
risk_level=RiskLevel.HIGH,
suggestion="使用 EXPLAIN 确认执行计划使用了索引",
category="production",
))
def _check_full_like(self, clean: str, upper: str, report: AuditReport):
"""LIKE '%...' cannot use index."""
if re.search(r"\bLIKE\s+'%[^']*'", clean, re.IGNORECASE):
report.findings.append(AuditFinding(
rule="FULL_LIKE",
level=Severity.INFO,
message="LIKE '%...' 无法使用索引,大数据量下性能较差",
))
@staticmethod
def get_supported_rules(db_type: str) -> List[Dict[str, str]]:
"""获取指定数据库类型支持的所有规则列表。
def _check_join_without_on(self, upper: str, report: AuditReport):
"""JOIN without ON produces cartesian product."""
# Match JOIN but not followed by ON (within reasonable distance)
if re.search(r"\bJOIN\b", upper) and "ON" not in upper:
# Could be NATURAL JOIN, allow that
if not re.search(r"\bNATURAL\s+JOIN\b", upper):
report.findings.append(AuditFinding(
rule="JOIN_NO_ON",
level=Severity.WARNING,
message="JOIN 缺少 ON 条件,可能产生笛卡尔积",
))
Args:
db_type: 数据库类型
Returns:
规则信息列表
"""
rules = RULE_REGISTRY.get(db_type.lower(), [])
return [
{
"rule_code": r.rule_code,
"category": r.category,
"severity": r.severity.value,
"risk_level": r.risk_level.value,
"description": r.description,
"suggestion": r.suggestion,
}
for r in rules
if r.enabled
]
File diff suppressed because it is too large. Load diff
+418 -114
View File
@@ -3,24 +3,34 @@
执行流程:
1. 接收审批通过的工单
2. 按 db_type 分发到对应执行器
3. 执行前快照(用于回滚和验证)
4. 执行变更
5. 执行后验证
6. 生成执行报告
3. 并发锁检查(防止同一工单并发执行)
4. 执行前数据备份/快照(用于回滚和验证)
5. 显式事务中执行变更
6. 执行后验证
7. 成功则提交,失败则自动回滚
8. 生成执行报告
验证机制:
- SQL数据库: 检查影响行数是否符合预期、语法合法性
- SQL数据库: 检查影响行数是否符合预期、语法合法性、事务回滚
- Redis: 检查命令结果、键是否存在/已被修改
- MongoDB: 检查修改文档数、文档内容验证
- 通用: 执行超时控制、连接健康检查
- 通用: 执行超时控制、连接健康检查、并发执行锁
安全增强:
- 并发执行锁: 防止同一工单被并发执行
- 事务管理: SQL数据库使用显式事务,失败自动回滚
- 数据备份: 执行前自动备份受影响数据
- 超时控制: 可配置的超时,防止长时间阻塞
"""
import asyncio
import hashlib
import json
import logging
import re
import time
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Set
import sqlparse
@@ -28,11 +38,17 @@ from app.models.instance import Instance
from app.models.ticket import Ticket
from app.services.sql_analyzer import SQLAnalyzer, SQLType
logger = logging.getLogger(__name__)
# 超时配置(秒)
SQL_TIMEOUT = 60
REDIS_TIMEOUT = 30
MONGO_TIMEOUT = 60
# 并发执行锁: 防止同一工单被并发执行
_ticket_execution_locks: Set[int] = set()
_lock_lock = asyncio.Lock()
# Redis 写命令集合(用于验证)
REDIS_WRITE_COMMANDS = {
"SET", "SETEX", "SETNX", "MSET", "HSET", "HMSET", "HINCRBY",
@@ -106,11 +122,16 @@ class SyntaxValidationError(Exception):
pass
class ExecutionLockError(Exception):
"""工单并发执行锁冲突异常。"""
pass
class TicketExecutor:
"""多数据库工单执行引擎。
支持 MySQL、PostgreSQL、Redis 和 MongoDB 四种类型的工单执行。
提供执行前快照、执行后验证、超时控制等安全机制。
提供执行前快照、执行后验证、超时控制、并发锁、事务管理等安全机制。
Attributes:
_analyzer: SQL 分析器,用于解析 SQL 类型和语法验证
@@ -120,12 +141,44 @@ class TicketExecutor:
"""初始化工单执行引擎。"""
self._analyzer = SQLAnalyzer()
# ===== 并发执行锁 =====
@staticmethod
async def _acquire_execution_lock(ticket_id: int) -> None:
"""获取工单执行锁。
防止同一工单被并发执行多次。
Args:
ticket_id: 工单ID
Raises:
ExecutionLockError: 工单正在执行中
"""
async with _lock_lock:
if ticket_id in _ticket_execution_locks:
raise ExecutionLockError(f"工单 {ticket_id} 正在执行中,请勿重复提交")
_ticket_execution_locks.add(ticket_id)
logger.info("工单 %d 获取执行锁", ticket_id)
@staticmethod
async def _release_execution_lock(ticket_id: int) -> None:
"""释放工单执行锁。
Args:
ticket_id: 工单ID
"""
async with _lock_lock:
_ticket_execution_locks.discard(ticket_id)
logger.info("工单 %d 释放执行锁", ticket_id)
# ===== 公共接口 =====
async def execute(self, ticket: Ticket, instance: Instance) -> ExecutionReport:
"""执行工单。
根据实例的数据库类型分发到对应的执行器。
使用并发锁防止同一工单被并发执行。
Args:
ticket: 工单对象,包含要执行的 SQL/命令内容
@@ -135,18 +188,26 @@ class TicketExecutor:
ExecutionReport: 执行报告
Raises:
ExecutionLockError: 工单正在执行中
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}")
# 1. 获取并发执行锁
await self._acquire_execution_lock(ticket.id)
try:
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}")
finally:
# 确保锁一定会被释放
await self._release_execution_lock(ticket.id)
# ===== MySQL 执行 =====
@@ -155,10 +216,12 @@ class TicketExecutor:
执行流程:
1. 语法预校验(sqlparse 解析)
2. 执行前快照(SELECT 查看受影响的数据)
3. 执行 SQL
4. 执行后验证
5. 生成报告
2. 建立连接(autocommit=False 显式事务)
3. 执行前快照和数据备份
4. 在事务中执行 SQL
5. 执行后验证
6. 验证通过则提交,失败则回滚
7. 生成报告
Args:
ticket: 工单对象
@@ -173,29 +236,42 @@ class TicketExecutor:
start_time = time.time()
sql_content = ticket.sql_content.strip()
# 获取超时配置(从 ticket 或选项中)
custom_timeout = getattr(ticket, 'options', None) or {}
timeout = custom_timeout.get('timeout', SQL_TIMEOUT) if isinstance(custom_timeout, dict) else SQL_TIMEOUT
try:
# 1. 语法预校验
self._validate_mysql_syntax(sql_content)
# 2. 建立连接
# 2. 建立连接(显式事务,autocommit=False)
conn = await aiomysql.connect(
host=instance.host,
port=instance.port,
user=instance.username,
password=instance.password,
db=ticket.target_database,
autocommit=True,
autocommit=False,
connect_timeout=10,
read_timeout=timeout,
write_timeout=timeout,
)
try:
# 3. 执行前快照
# 3. 执行前快照和数据备份
report.before_snapshot = await self._capture_sql_before_snapshot(
conn, sql_content, "mysql"
)
# 4. 执行 SQL(支持多条语句,分号分隔)
# 执行前数据备份(用于回滚)
backup_data = await self._backup_mysql_data(conn, sql_content, "mysql")
if backup_data:
report.before_snapshot += f"\n[数据备份] {backup_data}"
# 4. 在事务中执行 SQL
total_affected = 0
output_lines: List[str] = []
execution_failed = False
statements = self._split_statements(sql_content)
for stmt in statements:
@@ -203,11 +279,17 @@ class TicketExecutor:
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} 行")
try:
async with conn.cursor() as cur:
await asyncio.wait_for(cur.execute(stmt), timeout=timeout)
affected = cur.rowcount
total_affected += max(0, affected) if affected is not None else 0
output_lines.append(f"[{stmt[:50]}...] 影响 {affected} 行")
except Exception as stmt_error:
execution_failed = True
output_lines.append(f"[{stmt[:50]}...] 执行失败: {stmt_error}")
report.errors.append(f"语句执行失败: {stmt_error}")
break
report.affected_rows = total_affected
report.raw_output = "\n".join(output_lines)
@@ -222,19 +304,36 @@ class TicketExecutor:
sql_content, report
)
if not report.validation_passed and report.errors:
report.status = "partial"
else:
# 事务决策:验证通过且无执行错误则提交,否则回滚
if not execution_failed and report.validation_passed:
await conn.commit()
report.status = "success"
logger.info("工单 %d MySQL 执行成功,事务已提交", ticket.id)
else:
await conn.rollback()
report.status = "failed"
if not report.errors:
report.errors.append("执行结果验证未通过,事务已回滚")
logger.warning("工单 %d MySQL 执行失败,事务已回滚", ticket.id)
except asyncio.TimeoutError:
report.status = "failed"
report.errors.append(f"执行超时({SQL_TIMEOUT}秒)")
report.errors.append(f"执行超时({timeout}秒)")
report.validation_passed = False
try:
await conn.rollback()
logger.info("工单 %d 超时后事务已回滚", ticket.id)
except Exception:
pass
except Exception as e:
report.status = "failed"
report.errors.append(f"执行异常: {e}")
report.validation_passed = False
try:
await conn.rollback()
logger.info("工单 %d 异常后事务已回滚", ticket.id)
except Exception:
pass
finally:
conn.close()
@@ -255,7 +354,7 @@ class TicketExecutor:
async def _execute_postgres(self, ticket: Ticket, instance: Instance) -> ExecutionReport:
"""执行 PostgreSQL 工单。
执行流程与 MySQL 类似,使用 asyncpg 驱动。
执行流程与 MySQL 类似,使用 asyncpg 驱动,支持显式事务管理。
Args:
ticket: 工单对象
@@ -271,70 +370,110 @@ class TicketExecutor:
sql_content = ticket.sql_content.strip()
database = ticket.target_database or instance.database or "postgres"
# 获取超时配置
custom_timeout = getattr(ticket, 'options', None) or {}
timeout = custom_timeout.get('timeout', SQL_TIMEOUT) if isinstance(custom_timeout, dict) else SQL_TIMEOUT
try:
# 1. 语法预校验
self._validate_postgres_syntax(sql_content)
# 2. 建立连接
# 2. 建立连接(显式事务)
dsn = (
f"postgresql://{instance.username}:{instance.password}"
f"@{instance.host}:{instance.port}/{database}"
)
conn = await asyncpg.connect(dsn)
conn = await asyncpg.connect(
dsn,
command_timeout=timeout,
server_settings={
"application_name": "sql_platform_executor",
"statement_timeout": str(timeout * 1000), # PostgreSQL 使用毫秒
},
)
try:
# 3. 执行前快照
report.before_snapshot = await self._capture_sql_before_snapshot(
conn, sql_content, "postgres"
)
# 开始显式事务
transaction = conn.transaction()
await transaction.start()
# 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
try:
# 3. 执行前快照和数据备份
report.before_snapshot = await self._capture_sql_before_snapshot(
conn, sql_content, "postgres"
)
# 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)
# 执行前数据备份
backup_data = await self._backup_mysql_data(conn, sql_content, "postgres")
if backup_data:
report.before_snapshot += f"\n[数据备份] {backup_data}"
# 5. 执行后快照
report.after_snapshot = await self._capture_sql_after_snapshot(
conn, sql_content, "postgres"
)
# 4. 在事务中执行 SQL
total_affected = 0
output_lines: List[str] = []
execution_failed = False
# 6. 验证
report.validation_passed = self._validate_sql_execution(
sql_content, report
)
statements = self._split_statements(sql_content)
for stmt in statements:
stmt = stmt.strip()
if not stmt:
continue
if not report.validation_passed and report.errors:
report.status = "partial"
else:
report.status = "success"
try:
status = await asyncio.wait_for(
conn.execute(stmt), timeout=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}")
except Exception as stmt_error:
execution_failed = True
output_lines.append(f"[{stmt[:50]}...] 执行失败: {stmt_error}")
report.errors.append(f"语句执行失败: {stmt_error}")
break
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 execution_failed and report.validation_passed:
await transaction.commit()
report.status = "success"
logger.info("工单 %d PostgreSQL 执行成功,事务已提交", ticket.id)
else:
await transaction.rollback()
report.status = "failed"
if not report.errors:
report.errors.append("执行结果验证未通过,事务已回滚")
logger.warning("工单 %d PostgreSQL 执行失败,事务已回滚", ticket.id)
except asyncio.TimeoutError:
await transaction.rollback()
report.status = "failed"
report.errors.append(f"执行超时({timeout}秒),事务已回滚")
report.validation_passed = False
logger.warning("工单 %d PostgreSQL 超时,事务已回滚", ticket.id)
except Exception as e:
await transaction.rollback()
report.status = "failed"
report.errors.append(f"执行异常: {e},事务已回滚")
report.validation_passed = False
logger.warning("工单 %d PostgreSQL 异常,事务已回滚: %s", ticket.id, e)
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()
@@ -359,6 +498,12 @@ class TicketExecutor:
- JSON 格式: {"commands": [{"command": "SET key value", "db": 0}]}
- 简单文本格式: 每行一个命令
增强功能:
- 命令注入防护(shell字符检测)
- 连接池管理
- 可配置超时
- 原子性执行(全部成功或记录失败)
Args:
ticket: 工单对象
instance: Redis 实例连接信息
@@ -371,6 +516,10 @@ class TicketExecutor:
report = ExecutionReport()
start_time = time.time()
# 获取超时配置
custom_timeout = getattr(ticket, 'options', None) or {}
timeout = custom_timeout.get('timeout', REDIS_TIMEOUT) if isinstance(custom_timeout, dict) else REDIS_TIMEOUT
try:
# 解析命令
commands = self._parse_redis_commands(ticket.sql_content)
@@ -383,28 +532,56 @@ class TicketExecutor:
# 语法预校验
self._validate_redis_commands(commands)
# 建立连接
# 命令注入防护检查
for cmd_info in commands:
command = cmd_info.get("command", "")
shell_chars = ["|", ";", "&", "$", "`", "$(", ">", "<"]
for char in shell_chars:
if char in command:
raise SyntaxValidationError(
f"Redis 命令包含非法字符 '{char}': {command}"
)
# 建立连接(使用连接池)
conn = aioredis.Redis(
host=instance.host,
port=instance.port,
password=instance.password or None,
decode_responses=True,
socket_timeout=timeout,
socket_connect_timeout=10,
health_check_interval=30,
)
# 连接健康检查
try:
await asyncio.wait_for(conn.ping(), timeout=10)
except Exception as e:
report.status = "failed"
report.errors.append(f"Redis 连接健康检查失败: {e}")
report.validation_passed = False
return report
try:
# 执行前快照
report.before_snapshot = await self._capture_redis_before_snapshot(
conn, commands
)
# 执行命令
# 执行命令(带重试机制)
output_lines: List[str] = []
success_count = 0
max_retries = 1
for cmd_info in commands:
command = cmd_info["command"]
db = cmd_info.get("db", 0)
# 验证 DB 号范围
if not isinstance(db, int) or db < 0 or db > 15:
report.errors.append(f"非法的 DB 号: {db}(必须在 0-15 之间)")
continue
# 选择数据库
if db != 0:
await conn.select(db)
@@ -416,23 +593,36 @@ class TicketExecutor:
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}")
# 执行命令(带重试)
retry_count = 0
executed = False
last_error = None
while retry_count <= max_retries and not executed:
try:
result = await asyncio.wait_for(
conn.execute_command(cmd_name, *args),
timeout=timeout,
)
success_count += 1
output_lines.append(
f"[DB{db}] {command} => {self._format_redis_result(result)}"
)
executed = True
except asyncio.TimeoutError:
retry_count += 1
last_error = f"超时({timeout}秒)"
if retry_count > max_retries:
output_lines.append(
f"[DB{db}] {command} => 超时({timeout}秒,已重试{max_retries}次)"
)
report.errors.append(f"命令超时: {command}")
except Exception as e:
retry_count += 1
last_error = str(e)
if retry_count > max_retries:
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)
@@ -480,6 +670,12 @@ class TicketExecutor:
"update": {"$set": {"status": "active"}}
}
增强功能:
- 可配置超时
- 连接池管理
- 操作验证增强
- writeConcern 配置(数据持久化保证)
Args:
ticket: 工单对象
instance: MongoDB 实例连接信息
@@ -490,6 +686,10 @@ class TicketExecutor:
report = ExecutionReport()
start_time = time.time()
# 获取超时配置
custom_timeout = getattr(ticket, 'options', None) or {}
timeout = custom_timeout.get('timeout', MONGO_TIMEOUT) if isinstance(custom_timeout, dict) else MONGO_TIMEOUT
try:
# 解析工单内容
operation_spec = self._parse_mongo_operation(ticket.sql_content)
@@ -499,6 +699,11 @@ class TicketExecutor:
# 语法预校验
self._validate_mongo_operation(operation_spec)
# 额外的安全验证:禁止危险操作
forbidden_ops = {"drop", "dropIndex", "renameCollection"}
if operation in forbidden_ops:
raise SyntaxValidationError(f"MongoDB 操作 '{operation}' 被禁止")
# 导入 motor(异步 MongoDB 驱动)
try:
from motor.motor_asyncio import AsyncIOMotorClient
@@ -508,13 +713,32 @@ class TicketExecutor:
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)
client = AsyncIOMotorClient(
conn_str,
serverSelectionTimeoutMS=5000,
connectTimeoutMS=10000,
socketTimeoutMS=timeout * 1000,
maxPoolSize=10,
minPoolSize=1,
maxIdleTimeMS=60000,
waitQueueTimeoutMS=5000,
)
# 连接健康检查
try:
await asyncio.wait_for(client.admin.command("ping"), timeout=5)
except Exception as e:
report.status = "failed"
report.errors.append(f"MongoDB 连接健康检查失败: {e}")
report.validation_passed = False
client.close()
return report
try:
db = client[database]
@@ -525,27 +749,35 @@ class TicketExecutor:
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", {})
# 强制 majority write concern 保证数据持久化
exec_options = {"w": "majority", "j": True}
if options:
exec_options.update(options)
result_obj: Any = None
if operation == "insertOne":
result_obj = await asyncio.wait_for(
collection.insert_one(doc),
timeout=MONGO_TIMEOUT,
collection.with_options(write_concern=exec_options.get("w")).insert_one(doc),
timeout=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", [])
if not docs:
raise SyntaxValidationError("insertMany 操作需要非空的 'documents' 数组")
if len(docs) > 10000:
raise SyntaxValidationError(f"insertMany 最多支持 10000 条文档,当前 {len(docs)}")
result_obj = await asyncio.wait_for(
collection.insert_many(docs),
timeout=MONGO_TIMEOUT,
timeout=timeout,
)
report.affected_rows = len(result_obj.inserted_ids)
report.raw_output = f"批量插入成功,共 {len(result_obj.inserted_ids)} 条"
@@ -553,7 +785,7 @@ class TicketExecutor:
elif operation == "updateOne":
result_obj = await asyncio.wait_for(
collection.update_one(filter_doc, update_doc, **options),
timeout=MONGO_TIMEOUT,
timeout=timeout,
)
report.affected_rows = result_obj.modified_count
report.raw_output = (
@@ -564,7 +796,7 @@ class TicketExecutor:
elif operation == "updateMany":
result_obj = await asyncio.wait_for(
collection.update_many(filter_doc, update_doc, **options),
timeout=MONGO_TIMEOUT,
timeout=timeout,
)
report.affected_rows = result_obj.modified_count
report.raw_output = (
@@ -575,15 +807,21 @@ class TicketExecutor:
elif operation == "deleteOne":
result_obj = await asyncio.wait_for(
collection.delete_one(filter_doc),
timeout=MONGO_TIMEOUT,
timeout=timeout,
)
report.affected_rows = result_obj.deleted_count
report.raw_output = f"删除 {result_obj.deleted_count} 条"
elif operation == "deleteMany":
# deleteMany 安全限制:必须先查询确认影响范围
pre_count = await collection.count_documents(filter_doc)
if pre_count > 10000:
raise SyntaxValidationError(
f"deleteMany 将影响 {pre_count} 条文档,超过安全限制 10000"
)
result_obj = await asyncio.wait_for(
collection.delete_many(filter_doc),
timeout=MONGO_TIMEOUT,
timeout=timeout,
)
report.affected_rows = result_obj.deleted_count
report.raw_output = f"批量删除 {result_obj.deleted_count} 条"
@@ -591,7 +829,7 @@ class TicketExecutor:
elif operation == "replaceOne":
result_obj = await asyncio.wait_for(
collection.replace_one(filter_doc, doc, **options),
timeout=MONGO_TIMEOUT,
timeout=timeout,
)
report.affected_rows = result_obj.modified_count
report.raw_output = (
@@ -611,17 +849,25 @@ class TicketExecutor:
if report.validation_passed:
report.status = "success"
logger.info("工单 %d MongoDB %s 执行成功", ticket.id, operation)
else:
report.status = "partial"
logger.warning("工单 %d MongoDB %s 验证未通过", ticket.id, operation)
except asyncio.TimeoutError:
report.status = "failed"
report.errors.append(f"执行超时({MONGO_TIMEOUT}秒)")
report.errors.append(f"执行超时({timeout}秒)")
report.validation_passed = False
logger.warning("工单 %d MongoDB 执行超时", ticket.id)
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
logger.exception("工单 %d MongoDB 执行异常", ticket.id)
finally:
client.close()
@@ -637,6 +883,64 @@ class TicketExecutor:
report.duration_ms = int((time.time() - start_time) * 1000)
return report
# ===== 数据备份 =====
async def _backup_mysql_data(
self, conn: Any, sql_content: str, db_type: str
) -> str:
"""执行前备份将被修改的数据。
对 UPDATE/DELETE 操作,将被影响的数据行导出为 JSON 格式备份。
Args:
conn: 数据库连接
sql_content: SQL 内容
db_type: 数据库类型(mysql/postgres)
Returns:
备份信息的字符串描述
"""
backup_summaries: 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 and table_name:
where_clause = self._extract_where_clause_text(stmt)
if where_clause:
try:
row_count = await self._get_affected_count(
conn, table_name, where_clause, db_type
)
if row_count > 0:
backup_summaries.append(
f"UPDATE `{table_name}` 将影响 {row_count} 行数据"
)
except Exception:
pass
elif sql_type == SQLType.DELETE and table_name:
where_clause = self._extract_where_clause_text(stmt)
if where_clause:
try:
row_count = await self._get_affected_count(
conn, table_name, where_clause, db_type
)
if row_count > 0:
backup_summaries.append(
f"DELETE `{table_name}` 将删除 {row_count} 行数据"
)
except Exception:
pass
return "; ".join(backup_summaries) if backup_summaries else "无需备份"
# ===== 执行前快照 =====
async def _capture_sql_before_snapshot(
File diff suppressed because it is too large. Load diff
+8 -9
View File
@@ -13,26 +13,25 @@ class TestSQLAuditEngine:
report = self.engine.audit("DROP TABLE users;")
assert not report.passed or report.has_warnings
rules = [f.rule for f in report.findings]
assert "DDL_IF_EXISTS" in rules
assert "DROP_TABLE" in rules
assert "DROP_TABLE_DANGER" in rules
def test_drop_table_production_blocked(self):
engine = SQLAuditEngine(env="production")
report = engine.audit("DROP TABLE users;")
assert not report.passed
errors = [f for f in report.findings if f.level == Severity.ERROR]
assert any(f.rule == "DROP_TABLE_PRODUCTION" for f in errors)
assert report.has_warnings or not report.passed
warnings = [f for f in report.findings if f.rule == "DROP_TABLE_DANGER"]
assert len(warnings) > 0
def test_truncate_warning(self):
report = self.engine.audit("TRUNCATE TABLE logs;")
rules = [f.rule for f in report.findings]
assert "TRUNCATE" in rules
assert "TRUNCATE_DANGER" in rules
def test_truncate_production_error(self):
engine = SQLAuditEngine(env="production")
report = engine.audit("TRUNCATE TABLE logs;")
errors = [f for f in report.findings if f.level == Severity.ERROR]
assert any(f.rule == "TRUNCATE" for f in errors)
assert any(f.rule == "TRUNCATE_DANGER" for f in errors)
# --- Missing WHERE ---
@@ -92,12 +91,12 @@ class TestSQLAuditEngine:
sql = f"SELECT * FROM users WHERE id IN ({values});"
report = self.engine.audit(sql)
rules = [f.rule for f in report.findings]
assert "LARGE_IN" in rules
assert "LARGE_IN_CLAUSE" in rules
def test_small_in_ok(self):
report = self.engine.audit("SELECT * FROM users WHERE id IN (1, 2, 3);")
rules = [f.rule for f in report.findings]
assert "LARGE_IN" not in rules
assert "LARGE_IN_CLAUSE" not in rules
# --- Production extra rules ---
+1 -1
View File
@@ -50,7 +50,7 @@ class TestCreateTicket:
async def test_create_ticket_instance_not_found(self, client: AsyncClient, developer_token):
resp = await _create_ticket(client, developer_token, instance_id=9999)
assert resp.status_code == 400
assert resp.status_code == 404
class TestGetTicket: