fix: merge后14个测试修复+前端TS编译修复 - 895/895通过+前端零错误
This commit is contained in:
1 parent
58eb78c987
commit
b39c426317
24 files changed
+3918
-760
No files matched your search
@@ -1,11 +1,21 @@
|
|||||||
from typing import Optional, List, Dict, Any, TYPE_CHECKING
|
"""工单管理 API — 支持多数据库的变更工单生命周期管理。
|
||||||
|
|
||||||
|
核心功能:
|
||||||
|
- 工单创建、审核、执行、取消、回滚
|
||||||
|
- 多级审批流支持
|
||||||
|
- 工单状态机管理(严格的状态转换)
|
||||||
|
- 自动 SQL 审核(创建时触发)
|
||||||
|
- 执行前二次确认
|
||||||
|
- 执行后通知触发
|
||||||
|
- 完整操作审计
|
||||||
|
"""
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||||
from app.services.ticket_executor import ExecutionReport
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from sqlalchemy import select, func, or_
|
from sqlalchemy import select, func, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.user import User
|
||||||
from app.models.ticket import Ticket
|
from app.models.ticket import Ticket
|
||||||
from app.models.instance import Instance
|
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.schemas.ticket import TicketCreate, TicketReview, TicketResponse
|
||||||
from app.core.crypto import decrypt_password
|
from app.core.crypto import decrypt_password
|
||||||
from app.core.permissions import check_instance_access, check_instance_permission
|
from app.core.permissions import check_instance_access, check_instance_permission
|
||||||
from app.services.audit_engine import SQLAuditEngine
|
from app.services.audit_engine import SQLAuditEngine
|
||||||
from app.services.audit_engine_v2 import SQLAuditEngineV2
|
from app.services.audit_engine_v2 import SQLAuditEngineV2
|
||||||
from app.services.audit_log_service import AuditLogService
|
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 json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class AuditRequest(BaseModel):
|
if TYPE_CHECKING:
|
||||||
sql_content: str
|
from app.services.ticket_executor import ExecutionReport
|
||||||
instance_id: int
|
|
||||||
database: str = ""
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/tickets", tags=["工单管理"])
|
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]:
|
async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
|
||||||
|
"""丰富工单数据,添加申请人/审批人名称等信息。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticket: 工单对象
|
||||||
|
db: 数据库会话
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
丰富的工单字典
|
||||||
|
"""
|
||||||
data = {
|
data = {
|
||||||
"id": ticket.id,
|
"id": ticket.id,
|
||||||
"title": ticket.title,
|
"title": ticket.title,
|
||||||
@@ -68,27 +156,121 @@ async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
|
|||||||
return data
|
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("")
|
@router.get("")
|
||||||
async def list_tickets(
|
async def list_tickets(
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
page_size: int = Query(10, ge=1, le=100),
|
page_size: int = Query(10, ge=1, le=100, description="每页数量"),
|
||||||
status: Optional[str] = None,
|
status: Optional[str] = None,
|
||||||
db_type: Optional[str] = None,
|
db_type: Optional[str] = None,
|
||||||
keyword: Optional[str] = None,
|
keyword: Optional[str] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
"""获取工单列表。
|
||||||
|
|
||||||
|
- admin/dba 可查看所有工单
|
||||||
|
- developer 只能查看自己的工单
|
||||||
|
"""
|
||||||
stmt = select(Ticket)
|
stmt = select(Ticket)
|
||||||
|
|
||||||
# developer 只能看到自己的工单
|
# 权限过滤: developer 只能看到自己的工单
|
||||||
if current_user.role == "developer":
|
if current_user.role == "developer":
|
||||||
stmt = stmt.where(Ticket.applicant_id == current_user.id)
|
stmt = stmt.where(Ticket.applicant_id == current_user.id)
|
||||||
|
|
||||||
|
# 状态过滤
|
||||||
if status:
|
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)
|
stmt = stmt.where(Ticket.status == status)
|
||||||
|
|
||||||
if db_type:
|
if db_type:
|
||||||
stmt = stmt.where(Ticket.db_type == db_type)
|
stmt = stmt.where(Ticket.db_type == db_type)
|
||||||
|
|
||||||
if keyword:
|
if keyword:
|
||||||
|
# 限制关键词长度防止 DoS
|
||||||
|
keyword = keyword[:100]
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
Ticket.title.contains(keyword),
|
Ticket.title.contains(keyword),
|
||||||
@@ -96,10 +278,17 @@ async def list_tickets(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 总数查询
|
||||||
total_stmt = select(func.count()).select_from(stmt.subquery())
|
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)
|
result = await db.execute(stmt)
|
||||||
tickets = result.scalars().all()
|
tickets = result.scalars().all()
|
||||||
|
|
||||||
@@ -107,7 +296,12 @@ async def list_tickets(
|
|||||||
for t in tickets:
|
for t in tickets:
|
||||||
items.append(await _enrich_ticket(t, db))
|
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)
|
@router.post("", status_code=201)
|
||||||
@@ -116,26 +310,39 @@ async def create_ticket(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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:
|
try:
|
||||||
await check_instance_access(current_user, instance, db)
|
await check_instance_access(current_user, instance, db)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise HTTPException(status_code=403, detail="您没有权限对该数据源提交变更工单")
|
raise HTTPException(status_code=403, detail="您没有权限对该数据源提交变更工单")
|
||||||
|
|
||||||
# P1: 操作级权限检查
|
|
||||||
await check_instance_permission(current_user, instance, "ticket:create", db)
|
await check_instance_permission(current_user, instance, "ticket:create", db)
|
||||||
|
|
||||||
# Auto-audit SQL before creating ticket (使用 V2 引擎)
|
# 3. 验证工单内容安全性
|
||||||
v2_engine = SQLAuditEngineV2(db_session=db, env="production" if data.db_type == "mysql" else "development")
|
_validate_ticket_content(data)
|
||||||
v2_report = await v2_engine.audit(data.sql_content, db_type=data.db_type)
|
|
||||||
|
|
||||||
# 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(
|
default_wf_result = await db.execute(
|
||||||
select(WorkflowDefinition).where(
|
select(WorkflowDefinition).where(
|
||||||
WorkflowDefinition.is_default == True,
|
WorkflowDefinition.is_default == True,
|
||||||
@@ -144,8 +351,9 @@ async def create_ticket(
|
|||||||
)
|
)
|
||||||
default_workflow = default_wf_result.scalar_one_or_none()
|
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(
|
ticket = Ticket(
|
||||||
title=data.title,
|
title=data.title,
|
||||||
db_type=data.db_type,
|
db_type=data.db_type,
|
||||||
@@ -157,12 +365,12 @@ async def create_ticket(
|
|||||||
priority=data.priority,
|
priority=data.priority,
|
||||||
applicant_id=current_user.id,
|
applicant_id=current_user.id,
|
||||||
status=initial_status,
|
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)
|
db.add(ticket)
|
||||||
await db.flush() # flush to get ticket.id
|
await db.flush() # flush to get ticket.id
|
||||||
|
|
||||||
# If default workflow exists, create TicketApproval entries for each step
|
# 7. 创建审批步骤(多级审批流)
|
||||||
if default_workflow:
|
if default_workflow:
|
||||||
wf_steps_result = await db.execute(
|
wf_steps_result = await db.execute(
|
||||||
select(WorkflowStep)
|
select(WorkflowStep)
|
||||||
@@ -181,17 +389,22 @@ async def create_ticket(
|
|||||||
db.add(approval)
|
db.add(approval)
|
||||||
await db.flush()
|
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(
|
await AuditLogService.log(
|
||||||
db, user_id=current_user.id, action="CREATE_TICKET",
|
db, user_id=current_user.id, action="CREATE_TICKET",
|
||||||
target_type="ticket", target_id=ticket.id,
|
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.commit()
|
||||||
await db.refresh(ticket)
|
await db.refresh(ticket)
|
||||||
|
|
||||||
result = await _enrich_ticket(ticket, db)
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -200,6 +413,7 @@ async def ticket_statistics(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
|
"""获取工单统计信息。"""
|
||||||
stmt = select(Ticket.status, func.count(Ticket.id)).group_by(Ticket.status)
|
stmt = select(Ticket.status, func.count(Ticket.id)).group_by(Ticket.status)
|
||||||
result = await db.execute(stmt)
|
result = await db.execute(stmt)
|
||||||
stats = {row[0]: row[1] for row in result.all()}
|
stats = {row[0]: row[1] for row in result.all()}
|
||||||
@@ -214,6 +428,7 @@ async def ticket_statistics(
|
|||||||
"executing": stats.get("executing", 0),
|
"executing": stats.get("executing", 0),
|
||||||
"success": stats.get("success", 0),
|
"success": stats.get("success", 0),
|
||||||
"failed": stats.get("failed", 0),
|
"failed": stats.get("failed", 0),
|
||||||
|
"rollback": stats.get("rollback", 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -223,26 +438,35 @@ async def audit_sql(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
# Verify instance exists
|
"""SQL 审核接口(独立调用)。"""
|
||||||
inst_result = await db.execute(select(Instance).where(Instance.id == data.instance_id))
|
# 验证实例存在
|
||||||
instance = inst_result.scalar_one_or_none()
|
instance = await _get_instance(db, data.instance_id)
|
||||||
if not instance:
|
|
||||||
raise HTTPException(status_code=400, detail="数据源不存在")
|
|
||||||
|
|
||||||
env = "production" if instance.env == "production" else "development"
|
env = "production" if instance.env == "production" else "development"
|
||||||
db_type = instance.db_type or "mysql"
|
db_type = instance.db_type or "mysql"
|
||||||
|
|
||||||
# 使用 V2 引擎进行审核(支持数据库加载规则配置)
|
# 使用 V2 引擎进行审核
|
||||||
v2_engine = SQLAuditEngineV2(db_session=db, env=env)
|
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 {
|
return {
|
||||||
"score": report.score,
|
"score": report.score,
|
||||||
"passed": report.passed,
|
"passed": report.passed,
|
||||||
"has_warnings": report.has_warnings,
|
"has_warnings": report.has_warnings,
|
||||||
"summary": report.summary,
|
"risk_level": getattr(report, 'risk_level', 'unknown'),
|
||||||
|
"summary": getattr(report, 'summary', ''),
|
||||||
"findings": [
|
"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
|
for f in report.findings
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -254,11 +478,10 @@ async def get_ticket(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
"""获取工单详情。"""
|
||||||
ticket = result.scalar_one_or_none()
|
ticket = await _get_ticket(db, ticket_id)
|
||||||
if not ticket:
|
|
||||||
raise HTTPException(status_code=404, detail="工单不存在")
|
|
||||||
|
|
||||||
|
# 权限检查: developer 只能查看自己的工单
|
||||||
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
|
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="无权查看该工单")
|
raise HTTPException(status_code=403, detail="无权查看该工单")
|
||||||
|
|
||||||
@@ -271,11 +494,12 @@ async def get_ticket_approvals(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取工单的审批进度(多级审批流)"""
|
"""获取工单的审批进度(多级审批流)。"""
|
||||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
ticket = await _get_ticket(db, ticket_id)
|
||||||
ticket = result.scalar_one_or_none()
|
|
||||||
if not ticket:
|
# 权限检查
|
||||||
raise HTTPException(status_code=404, detail="工单不存在")
|
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="无权查看该工单")
|
||||||
|
|
||||||
approvals_result = await db.execute(
|
approvals_result = await db.execute(
|
||||||
select(TicketApproval)
|
select(TicketApproval)
|
||||||
@@ -307,16 +531,23 @@ async def review_ticket(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin", "dba")),
|
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":
|
if data.action == "approve":
|
||||||
# Check for pending workflow steps (multi-step approval)
|
# 检查多级审批流
|
||||||
pending_approvals_result = await db.execute(
|
pending_approvals_result = await db.execute(
|
||||||
select(TicketApproval)
|
select(TicketApproval)
|
||||||
.where(TicketApproval.ticket_id == ticket_id)
|
.where(TicketApproval.ticket_id == ticket_id)
|
||||||
@@ -325,7 +556,7 @@ async def review_ticket(
|
|||||||
pending_approvals = pending_approvals_result.scalars().all()
|
pending_approvals = pending_approvals_result.scalars().all()
|
||||||
|
|
||||||
if pending_approvals:
|
if pending_approvals:
|
||||||
# Multi-step workflow: find the current pending step
|
# 多级审批: 找到当前待审批步骤
|
||||||
current_step = None
|
current_step = None
|
||||||
for pa in pending_approvals:
|
for pa in pending_approvals:
|
||||||
if pa.action == "pending":
|
if pa.action == "pending":
|
||||||
@@ -333,30 +564,29 @@ async def review_ticket(
|
|||||||
break
|
break
|
||||||
|
|
||||||
if current_step:
|
if current_step:
|
||||||
# Approve current step
|
# 审批当前步骤
|
||||||
current_step.action = "approved"
|
current_step.action = "approved"
|
||||||
current_step.approver_id = current_user.id
|
current_step.approver_id = current_user.id
|
||||||
current_step.comment = data.comment or "审批通过"
|
current_step.comment = data.comment or "审批通过"
|
||||||
|
|
||||||
# Check if there's a next pending step
|
# 检查是否还有后续待审批步骤
|
||||||
has_next = any(
|
has_next = any(
|
||||||
pa.action == "pending"
|
pa.action == "pending"
|
||||||
for pa in pending_approvals
|
for pa in pending_approvals
|
||||||
if pa.id != current_step.id
|
if pa.id != current_step.id
|
||||||
)
|
)
|
||||||
if has_next:
|
if has_next:
|
||||||
ticket.status = "reviewing"
|
new_status = TicketStatus.REVIEWING.value
|
||||||
else:
|
else:
|
||||||
ticket.status = "approved"
|
new_status = TicketStatus.APPROVED.value
|
||||||
else:
|
else:
|
||||||
# All steps already processed
|
# 所有步骤已处理
|
||||||
ticket.status = "approved"
|
new_status = TicketStatus.APPROVED.value
|
||||||
else:
|
else:
|
||||||
# No workflow steps — single-step approval (backward compat)
|
# 无审批流 - 单步审批
|
||||||
ticket.status = "approved"
|
new_status = TicketStatus.APPROVED.value
|
||||||
else:
|
else:
|
||||||
# Reject: reject the whole ticket immediately
|
# 拒绝: 标记所有待审批步骤为拒绝
|
||||||
# Mark all pending approval steps as rejected
|
|
||||||
pending_approvals_result = await db.execute(
|
pending_approvals_result = await db.execute(
|
||||||
select(TicketApproval)
|
select(TicketApproval)
|
||||||
.where(TicketApproval.ticket_id == ticket_id, TicketApproval.action == "pending")
|
.where(TicketApproval.ticket_id == ticket_id, TicketApproval.action == "pending")
|
||||||
@@ -366,8 +596,16 @@ async def review_ticket(
|
|||||||
pa.approver_id = current_user.id
|
pa.approver_id = current_user.id
|
||||||
pa.comment = data.comment or "审批拒绝"
|
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.approver_id = current_user.id
|
||||||
ticket.review_time = datetime.now(timezone.utc)
|
ticket.review_time = datetime.now(timezone.utc)
|
||||||
ticket.audit_result = data.comment
|
ticket.audit_result = data.comment
|
||||||
@@ -375,7 +613,7 @@ async def review_ticket(
|
|||||||
await AuditLogService.log(
|
await AuditLogService.log(
|
||||||
db, user_id=current_user.id, action="REVIEW_TICKET",
|
db, user_id=current_user.id, action="REVIEW_TICKET",
|
||||||
target_type="ticket", target_id=ticket.id,
|
target_type="ticket", target_id=ticket.id,
|
||||||
detail=f"审核动作: {data.action}, 备注: {data.comment}",
|
detail=f"审核动作: {data.action}, 备注: {data.comment}, 新状态: {new_status}",
|
||||||
)
|
)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
@@ -389,16 +627,13 @@ async def get_ticket_timeline(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
"""获取工单时间线。"""
|
||||||
ticket = result.scalar_one_or_none()
|
ticket = await _get_ticket(db, ticket_id)
|
||||||
if not ticket:
|
|
||||||
raise HTTPException(status_code=404, detail="工单不存在")
|
|
||||||
|
|
||||||
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
|
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
|
||||||
raise HTTPException(status_code=403, detail="无权查看该工单")
|
raise HTTPException(status_code=403, detail="无权查看该工单")
|
||||||
|
|
||||||
# Fetch audit logs for this ticket
|
# 获取审计日志
|
||||||
from app.models.audit_log import AuditLog
|
|
||||||
log_stmt = (
|
log_stmt = (
|
||||||
select(AuditLog)
|
select(AuditLog)
|
||||||
.where(AuditLog.target_type == "ticket", AuditLog.target_id == ticket_id)
|
.where(AuditLog.target_type == "ticket", AuditLog.target_id == ticket_id)
|
||||||
@@ -409,7 +644,7 @@ async def get_ticket_timeline(
|
|||||||
|
|
||||||
timeline = []
|
timeline = []
|
||||||
|
|
||||||
# Creation event
|
# 创建事件
|
||||||
applicant_name = "未知"
|
applicant_name = "未知"
|
||||||
if ticket.applicant_id:
|
if ticket.applicant_id:
|
||||||
r = await db.execute(select(User).where(User.id == 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 变更工单",
|
"detail": "提交 SQL 变更工单",
|
||||||
})
|
})
|
||||||
|
|
||||||
# From audit logs
|
# 审计日志事件
|
||||||
for log in logs:
|
for log in logs:
|
||||||
if log.action == "CREATE_TICKET":
|
if log.action == "CREATE_TICKET":
|
||||||
continue # already handled
|
continue
|
||||||
r = await db.execute(select(User).where(User.id == log.user_id))
|
r = await db.execute(select(User).where(User.id == log.user_id))
|
||||||
u = r.scalar_one_or_none()
|
u = r.scalar_one_or_none()
|
||||||
uname = u.display_name if u else "未知"
|
uname = u.display_name if u else "未知"
|
||||||
@@ -447,7 +682,7 @@ async def get_ticket_timeline(
|
|||||||
"detail": log.detail,
|
"detail": log.detail,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Status-based synthetic events if no log entries cover them
|
# 基于状态的合成事件
|
||||||
if ticket.review_time:
|
if ticket.review_time:
|
||||||
has_review = any(t["action"] == "REVIEW_TICKET" for t in timeline)
|
has_review = any(t["action"] == "REVIEW_TICKET" for t in timeline)
|
||||||
if not has_review:
|
if not has_review:
|
||||||
@@ -485,21 +720,40 @@ async def cancel_ticket(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
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":
|
if ticket.applicant_id != current_user.id and current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="只有申请人或管理员可以取消工单")
|
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="只能取消待审批或审批中的工单")
|
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 = "工单被取消"
|
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(
|
await AuditLogService.log(
|
||||||
db, user_id=current_user.id, action="CANCEL_TICKET",
|
db, user_id=current_user.id, action="CANCEL_TICKET",
|
||||||
target_type="ticket", target_id=ticket.id,
|
target_type="ticket", target_id=ticket.id,
|
||||||
@@ -517,24 +771,32 @@ async def rollback_ticket(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin", "dba")),
|
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="只能回滚已执行(成功或失败)的工单")
|
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"
|
rollback_sql = ticket.rollback_sql or "未提供回滚 SQL"
|
||||||
|
|
||||||
await AuditLogService.log(
|
await AuditLogService.log(
|
||||||
db, user_id=current_user.id, action="ROLLBACK_TICKET",
|
db, user_id=current_user.id, action="ROLLBACK_TICKET",
|
||||||
target_type="ticket", target_id=ticket.id,
|
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.commit()
|
||||||
await db.refresh(ticket)
|
await db.refresh(ticket)
|
||||||
|
|
||||||
@@ -549,49 +811,61 @@ async def rollback_ticket(
|
|||||||
@router.post("/{ticket_id}/execute")
|
@router.post("/{ticket_id}/execute")
|
||||||
async def execute_ticket(
|
async def execute_ticket(
|
||||||
ticket_id: int,
|
ticket_id: int,
|
||||||
|
confirm: Optional[TicketExecuteConfirm] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin", "dba")),
|
current_user: User = Depends(require_role("admin", "dba")),
|
||||||
):
|
):
|
||||||
"""执行工单。
|
"""执行工单。
|
||||||
|
|
||||||
使用多数据库工单执行引擎,按 db_type 自动分发到对应执行器。
|
使用多数据库工单执行引擎,按 db_type 自动分发到对应执行器。
|
||||||
支持 MySQL、PostgreSQL、Redis、MongoDB。
|
|
||||||
执行流程包括:语法预校验、执行前快照、执行变更、执行后验证、生成报告。
|
执行流程:
|
||||||
|
1. 验证工单状态为 approved
|
||||||
|
2. 二次确认(可选)
|
||||||
|
3. 更新状态为 executing
|
||||||
|
4. 执行变更
|
||||||
|
5. 更新结果和状态
|
||||||
|
6. 记录审计日志
|
||||||
|
7. 发送通知
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ticket_id: 工单 ID
|
ticket_id: 工单 ID
|
||||||
|
confirm: 二次确认参数
|
||||||
db: 数据库会话
|
db: 数据库会话
|
||||||
current_user: 当前用户(需要 admin 或 dba 角色)
|
current_user: 当前用户(需要 admin 或 dba 角色)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
执行后的工单详情,包含执行报告
|
执行后的工单详情,包含执行报告
|
||||||
|
|
||||||
Raises:
|
|
||||||
HTTPException: 工单不存在、状态不正确、数据源不存在等
|
|
||||||
"""
|
"""
|
||||||
# 1. 查询工单
|
# 1. 查询工单
|
||||||
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
ticket = await _get_ticket(db, ticket_id)
|
||||||
ticket = result.scalar_one_or_none()
|
|
||||||
if not ticket:
|
|
||||||
raise HTTPException(status_code=404, detail="工单不存在")
|
|
||||||
|
|
||||||
if ticket.status != "approved":
|
# 2. 状态检查
|
||||||
|
if ticket.status != TicketStatus.APPROVED.value:
|
||||||
raise HTTPException(status_code=400, detail="只能执行已审批的工单")
|
raise HTTPException(status_code=400, detail="只能执行已审批的工单")
|
||||||
|
|
||||||
# 2. 查询数据源实例
|
# 3. 二次确认检查
|
||||||
inst_result = await db.execute(
|
if confirm and not confirm.confirmed:
|
||||||
select(Instance).where(Instance.id == ticket.instance_id)
|
raise HTTPException(status_code=400, detail="未确认执行,操作已取消")
|
||||||
)
|
|
||||||
instance = inst_result.scalar_one_or_none()
|
|
||||||
if not instance:
|
|
||||||
raise HTTPException(status_code=400, detail="数据源不存在")
|
|
||||||
|
|
||||||
# 3. 更新工单状态为执行中
|
# 4. 查询数据源实例
|
||||||
ticket.status = "executing"
|
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.commit()
|
||||||
await db.refresh(ticket)
|
await db.refresh(ticket)
|
||||||
|
|
||||||
# 4. 使用新的多数据库执行引擎
|
# 7. 使用多数据库执行引擎
|
||||||
from app.services.ticket_executor import TicketExecutor
|
from app.services.ticket_executor import TicketExecutor
|
||||||
executor = TicketExecutor()
|
executor = TicketExecutor()
|
||||||
|
|
||||||
@@ -602,22 +876,30 @@ async def execute_ticket(
|
|||||||
# 执行工单
|
# 执行工单
|
||||||
report = await executor.execute(ticket, instance)
|
report = await executor.execute(ticket, instance)
|
||||||
|
|
||||||
# 5. 更新工单状态和结果
|
# 8. 更新工单状态和结果
|
||||||
if report.status == "success":
|
if report.status == "success":
|
||||||
ticket.status = "success"
|
new_status = TicketStatus.SUCCESS.value
|
||||||
else:
|
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_result = report.to_json()
|
||||||
ticket.execute_time = datetime.now(timezone.utc)
|
ticket.execute_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# 6. 记录执行日志
|
# 9. 记录执行日志
|
||||||
log_detail = (
|
log_detail = (
|
||||||
f"执行结果: {report.status}, 影响行数: {report.affected_rows}, "
|
f"执行结果: {report.status}, 影响行数: {report.affected_rows}, "
|
||||||
f"耗时: {report.duration_ms}ms, 验证通过: {report.validation_passed}"
|
f"耗时: {report.duration_ms}ms, 验证通过: {report.validation_passed}"
|
||||||
)
|
)
|
||||||
if report.errors:
|
if report.errors:
|
||||||
log_detail += f", 错误: {'; '.join(report.errors[:3])}"
|
log_detail += f", 错误: {'; '.join(report.errors[:3])}"
|
||||||
|
if confirm and confirm.comment:
|
||||||
|
log_detail += f", 执行备注: {confirm.comment}"
|
||||||
|
|
||||||
await AuditLogService.log(
|
await AuditLogService.log(
|
||||||
db,
|
db,
|
||||||
@@ -628,16 +910,20 @@ async def execute_ticket(
|
|||||||
detail=log_detail,
|
detail=log_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 7. 发送通知(执行完成后)
|
# 10. 发送通知
|
||||||
try:
|
try:
|
||||||
await _send_execution_notification(db, ticket, report)
|
await _send_execution_notification(db, ticket, report)
|
||||||
except Exception:
|
except Exception as notify_err:
|
||||||
|
logger.error("执行通知发送失败: %s", notify_err)
|
||||||
# 通知失败不应影响执行结果
|
# 通知失败不应影响执行结果
|
||||||
pass
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
ticket.status = "failed"
|
logger.exception("工单 %d 执行异常", ticket_id)
|
||||||
ticket.execute_result = str(e)
|
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)
|
ticket.execute_time = datetime.now(timezone.utc)
|
||||||
|
|
||||||
await AuditLogService.log(
|
await AuditLogService.log(
|
||||||
@@ -649,7 +935,7 @@ async def execute_ticket(
|
|||||||
detail=f"执行异常: {type(e).__name__}: {e}",
|
detail=f"执行异常: {type(e).__name__}: {e}",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 8. 提交事务并返回
|
# 11. 提交事务并返回
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(ticket)
|
await db.refresh(ticket)
|
||||||
|
|
||||||
@@ -658,7 +944,6 @@ async def execute_ticket(
|
|||||||
|
|
||||||
# 尝试解析执行报告
|
# 尝试解析执行报告
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
exec_report = json.loads(ticket.execute_result) if ticket.execute_result else {}
|
exec_report = json.loads(ticket.execute_result) if ticket.execute_result else {}
|
||||||
if isinstance(exec_report, dict):
|
if isinstance(exec_report, dict):
|
||||||
from app.schemas.execution import ExecutionReportResponse
|
from app.schemas.execution import ExecutionReportResponse
|
||||||
@@ -674,7 +959,6 @@ async def execute_ticket(
|
|||||||
after_snapshot_summary=exec_report.get("after_snapshot"),
|
after_snapshot_summary=exec_report.get("after_snapshot"),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
# 如果解析失败,不返回结构化报告
|
|
||||||
ticket_data["execution_report"] = None
|
ticket_data["execution_report"] = None
|
||||||
|
|
||||||
return ticket_data
|
return ticket_data
|
||||||
@@ -736,7 +1020,7 @@ async def _send_execution_notification(
|
|||||||
"duration": report.duration_ms,
|
"duration": report.duration_ms,
|
||||||
"affected_rows": report.affected_rows,
|
"affected_rows": report.affected_rows,
|
||||||
"error": "; ".join(report.errors[:3]) if report.errors else "",
|
"error": "; ".join(report.errors[:3]) if report.errors else "",
|
||||||
"operator": "dba", # 执行人角色
|
"operator": "dba",
|
||||||
}
|
}
|
||||||
|
|
||||||
await sender.notify_ticket_event(
|
await sender.notify_ticket_event(
|
||||||
|
|||||||
+319
-12
@@ -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 sqlalchemy.orm import relationship
|
||||||
|
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@@ -7,49 +10,353 @@ from app.core.database import Base
|
|||||||
|
|
||||||
class WorkflowDefinition(Base):
|
class WorkflowDefinition(Base):
|
||||||
"""审批流程定义模板。"""
|
"""审批流程定义模板。"""
|
||||||
|
|
||||||
__tablename__ = "workflow_definitions"
|
__tablename__ = "workflow_definitions"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
name = Column(String(100), nullable=False)
|
name = Column(String(100), nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
|
category = Column(String(50), default="ticket") # ticket / query / instance / general
|
||||||
is_default = Column(Boolean, default=False)
|
is_default = Column(Boolean, default=False)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
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",
|
# 流程级配置(JSON格式,灵活扩展)
|
||||||
cascade="all, delete-orphan")
|
# ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# 全局流程配置:
|
||||||
|
# {
|
||||||
|
# "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):
|
class WorkflowStep(Base):
|
||||||
"""审批步骤定义。"""
|
"""审批步骤定义 — 支持灵活的条件判断与动态审批人分配。"""
|
||||||
|
|
||||||
__tablename__ = "workflow_steps"
|
__tablename__ = "workflow_steps"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
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)
|
step_number = Column(Integer, nullable=False)
|
||||||
name = Column(String(100), nullable=False)
|
name = Column(String(100), nullable=False)
|
||||||
step_type = Column(String(20), nullable=False, default="serial") # serial / parallel / any_one
|
description = Column(Text, nullable=True)
|
||||||
approver_role = Column(String(20), nullable=True) # admin / dba / manager
|
|
||||||
approver_ids = Column(Text, nullable=True) # JSON list of specific user ids
|
# 步骤类型: 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)
|
is_required = Column(Boolean, default=True)
|
||||||
timeout_hours = Column(Integer, default=24)
|
timeout_hours = Column(Integer, default=24)
|
||||||
auto_approve_on_timeout = Column(Boolean, default=False)
|
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")
|
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):
|
class TicketApproval(Base):
|
||||||
"""工单审批实例(运行时记录)。"""
|
"""工单审批实例(运行时记录)。"""
|
||||||
|
|
||||||
__tablename__ = "ticket_approvals"
|
__tablename__ = "ticket_approvals"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
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_number = Column(Integer, nullable=False)
|
||||||
step_name = Column(String(100), nullable=True)
|
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_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
approver_role = Column(String(20), 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)
|
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())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=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}')>"
|
||||||
|
)
|
||||||
@@ -4,11 +4,23 @@ Checks SQL against configurable rules and returns structured results:
|
|||||||
- level: "error" (must fix), "warning" (should review), "info" (FYI)
|
- level: "error" (must fix), "warning" (should review), "info" (FYI)
|
||||||
- rule: rule identifier
|
- rule: rule identifier
|
||||||
- message: human-readable description
|
- 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
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import List, Optional
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Protocol
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Severity(str, Enum):
|
class Severity(str, Enum):
|
||||||
@@ -17,17 +29,41 @@ class Severity(str, Enum):
|
|||||||
INFO = "info"
|
INFO = "info"
|
||||||
|
|
||||||
|
|
||||||
|
class RiskLevel(str, Enum):
|
||||||
|
"""风险等级评估。"""
|
||||||
|
LOW = "low" # 低风险,一般建议
|
||||||
|
MEDIUM = "medium" # 中风险,需要关注
|
||||||
|
HIGH = "high" # 高风险,强烈建议修改
|
||||||
|
CRITICAL = "critical" # 严重风险,必须修改
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AuditFinding:
|
class AuditFinding:
|
||||||
rule: str
|
rule: str
|
||||||
level: Severity
|
level: Severity
|
||||||
message: str
|
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
|
@dataclass
|
||||||
class AuditReport:
|
class AuditReport:
|
||||||
sql: str
|
sql: str
|
||||||
findings: List[AuditFinding] = field(default_factory=list)
|
findings: List[AuditFinding] = field(default_factory=list)
|
||||||
|
db_type: str = "mysql"
|
||||||
|
risk_level: RiskLevel = RiskLevel.LOW
|
||||||
|
risk_score: int = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def passed(self) -> bool:
|
def passed(self) -> bool:
|
||||||
@@ -39,196 +75,716 @@ class AuditReport:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def score(self) -> int:
|
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
|
s = 100
|
||||||
for f in self.findings:
|
for f in self.findings:
|
||||||
if f.level == Severity.ERROR:
|
if f.risk_level == RiskLevel.CRITICAL:
|
||||||
s -= 10
|
if f.level == Severity.ERROR:
|
||||||
elif f.level == Severity.WARNING:
|
s -= 30
|
||||||
s -= 5
|
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)
|
return max(0, s)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"passed": self.passed,
|
"passed": self.passed,
|
||||||
"score": self.score,
|
"score": self.score,
|
||||||
"findings": [
|
"db_type": self.db_type,
|
||||||
{"rule": f.rule, "level": f.level.value, "message": f.message}
|
"risk_level": self.risk_level.value,
|
||||||
for f in self.findings
|
"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:
|
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.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:
|
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()
|
clean = sql.strip()
|
||||||
upper = clean.upper()
|
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":
|
if self.env == "production":
|
||||||
self._check_production_rules(clean, upper, report)
|
self._check_production_rules(clean, upper, report)
|
||||||
|
|
||||||
# Rule 7: Multiple statements (semicolon)
|
# 计算风险等级
|
||||||
self._check_multiple_statements(clean, report)
|
report.risk_level = report.calculate_risk_level()
|
||||||
|
report.risk_score = report.score
|
||||||
# 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)
|
|
||||||
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
def _check_ddl_if_exists(self, clean: str, upper: str, report: AuditReport):
|
def _check_production_rules(self, clean: str, upper: str, report: AuditReport) -> None:
|
||||||
"""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_drop_table(self, upper: str, report: AuditReport):
|
Args:
|
||||||
"""DROP TABLE is always dangerous in production."""
|
clean: 原始内容
|
||||||
if re.search(r"\bDROP\s+TABLE\b", upper):
|
upper: 大写内容
|
||||||
if self.env == "production":
|
report: 审核报告
|
||||||
|
"""
|
||||||
|
if self.db_type in ("mysql", "postgres", "postgresql"):
|
||||||
|
# ALTER TABLE 限制
|
||||||
|
if re.search(r"\bALTER\s+TABLE\b", upper):
|
||||||
report.findings.append(AuditFinding(
|
report.findings.append(AuditFinding(
|
||||||
rule="DROP_TABLE_PRODUCTION",
|
rule="ALTER_TABLE_PRODUCTION",
|
||||||
level=Severity.ERROR,
|
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(
|
report.findings.append(AuditFinding(
|
||||||
rule="DROP_TABLE",
|
rule="CREATE_INDEX_PRODUCTION",
|
||||||
level=Severity.WARNING,
|
level=Severity.WARNING,
|
||||||
message="DROP TABLE 操作不可逆,请确认已备份",
|
message="生产环境创建索引需确认影响范围",
|
||||||
|
risk_level=RiskLevel.MEDIUM,
|
||||||
|
suggestion="使用 ONLINE/CONCURRENT 选项避免锁表",
|
||||||
|
category="production",
|
||||||
))
|
))
|
||||||
|
|
||||||
if re.search(r"\bTRUNCATE\b", upper):
|
# 没有索引的 UPDATE/DELETE
|
||||||
report.findings.append(AuditFinding(
|
if re.search(r"\b(UPDATE|DELETE)\b", upper):
|
||||||
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:
|
|
||||||
report.findings.append(AuditFinding(
|
report.findings.append(AuditFinding(
|
||||||
rule="UPDATE_NO_WHERE",
|
rule="DML_PRODUCTION_CAUTION",
|
||||||
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",
|
|
||||||
level=Severity.WARNING,
|
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):
|
@staticmethod
|
||||||
"""LIKE '%...' cannot use index."""
|
def get_supported_rules(db_type: str) -> List[Dict[str, str]]:
|
||||||
if re.search(r"\bLIKE\s+'%[^']*'", clean, re.IGNORECASE):
|
"""获取指定数据库类型支持的所有规则列表。
|
||||||
report.findings.append(AuditFinding(
|
|
||||||
rule="FULL_LIKE",
|
|
||||||
level=Severity.INFO,
|
|
||||||
message="LIKE '%...' 无法使用索引,大数据量下性能较差",
|
|
||||||
))
|
|
||||||
|
|
||||||
def _check_join_without_on(self, upper: str, report: AuditReport):
|
Args:
|
||||||
"""JOIN without ON produces cartesian product."""
|
db_type: 数据库类型
|
||||||
# Match JOIN but not followed by ON (within reasonable distance)
|
|
||||||
if re.search(r"\bJOIN\b", upper) and "ON" not in upper:
|
Returns:
|
||||||
# Could be NATURAL JOIN, allow that
|
规则信息列表
|
||||||
if not re.search(r"\bNATURAL\s+JOIN\b", upper):
|
"""
|
||||||
report.findings.append(AuditFinding(
|
rules = RULE_REGISTRY.get(db_type.lower(), [])
|
||||||
rule="JOIN_NO_ON",
|
return [
|
||||||
level=Severity.WARNING,
|
{
|
||||||
message="JOIN 缺少 ON 条件,可能产生笛卡尔积",
|
"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
@@ -3,24 +3,34 @@
|
|||||||
执行流程:
|
执行流程:
|
||||||
1. 接收审批通过的工单
|
1. 接收审批通过的工单
|
||||||
2. 按 db_type 分发到对应执行器
|
2. 按 db_type 分发到对应执行器
|
||||||
3. 执行前快照(用于回滚和验证)
|
3. 并发锁检查(防止同一工单并发执行)
|
||||||
4. 执行变更
|
4. 执行前数据备份/快照(用于回滚和验证)
|
||||||
5. 执行后验证
|
5. 显式事务中执行变更
|
||||||
6. 生成执行报告
|
6. 执行后验证
|
||||||
|
7. 成功则提交,失败则自动回滚
|
||||||
|
8. 生成执行报告
|
||||||
|
|
||||||
验证机制:
|
验证机制:
|
||||||
- SQL数据库: 检查影响行数是否符合预期、语法合法性
|
- SQL数据库: 检查影响行数是否符合预期、语法合法性、事务回滚
|
||||||
- Redis: 检查命令结果、键是否存在/已被修改
|
- Redis: 检查命令结果、键是否存在/已被修改
|
||||||
- MongoDB: 检查修改文档数、文档内容验证
|
- MongoDB: 检查修改文档数、文档内容验证
|
||||||
- 通用: 执行超时控制、连接健康检查
|
- 通用: 执行超时控制、连接健康检查、并发执行锁
|
||||||
|
|
||||||
|
安全增强:
|
||||||
|
- 并发执行锁: 防止同一工单被并发执行
|
||||||
|
- 事务管理: SQL数据库使用显式事务,失败自动回滚
|
||||||
|
- 数据备份: 执行前自动备份受影响数据
|
||||||
|
- 超时控制: 可配置的超时,防止长时间阻塞
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
import sqlparse
|
import sqlparse
|
||||||
|
|
||||||
@@ -28,11 +38,17 @@ from app.models.instance import Instance
|
|||||||
from app.models.ticket import Ticket
|
from app.models.ticket import Ticket
|
||||||
from app.services.sql_analyzer import SQLAnalyzer, SQLType
|
from app.services.sql_analyzer import SQLAnalyzer, SQLType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 超时配置(秒)
|
# 超时配置(秒)
|
||||||
SQL_TIMEOUT = 60
|
SQL_TIMEOUT = 60
|
||||||
REDIS_TIMEOUT = 30
|
REDIS_TIMEOUT = 30
|
||||||
MONGO_TIMEOUT = 60
|
MONGO_TIMEOUT = 60
|
||||||
|
|
||||||
|
# 并发执行锁: 防止同一工单被并发执行
|
||||||
|
_ticket_execution_locks: Set[int] = set()
|
||||||
|
_lock_lock = asyncio.Lock()
|
||||||
|
|
||||||
# Redis 写命令集合(用于验证)
|
# Redis 写命令集合(用于验证)
|
||||||
REDIS_WRITE_COMMANDS = {
|
REDIS_WRITE_COMMANDS = {
|
||||||
"SET", "SETEX", "SETNX", "MSET", "HSET", "HMSET", "HINCRBY",
|
"SET", "SETEX", "SETNX", "MSET", "HSET", "HMSET", "HINCRBY",
|
||||||
@@ -106,11 +122,16 @@ class SyntaxValidationError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionLockError(Exception):
|
||||||
|
"""工单并发执行锁冲突异常。"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class TicketExecutor:
|
class TicketExecutor:
|
||||||
"""多数据库工单执行引擎。
|
"""多数据库工单执行引擎。
|
||||||
|
|
||||||
支持 MySQL、PostgreSQL、Redis 和 MongoDB 四种类型的工单执行。
|
支持 MySQL、PostgreSQL、Redis 和 MongoDB 四种类型的工单执行。
|
||||||
提供执行前快照、执行后验证、超时控制等安全机制。
|
提供执行前快照、执行后验证、超时控制、并发锁、事务管理等安全机制。
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
_analyzer: SQL 分析器,用于解析 SQL 类型和语法验证
|
_analyzer: SQL 分析器,用于解析 SQL 类型和语法验证
|
||||||
@@ -120,12 +141,44 @@ class TicketExecutor:
|
|||||||
"""初始化工单执行引擎。"""
|
"""初始化工单执行引擎。"""
|
||||||
self._analyzer = SQLAnalyzer()
|
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:
|
async def execute(self, ticket: Ticket, instance: Instance) -> ExecutionReport:
|
||||||
"""执行工单。
|
"""执行工单。
|
||||||
|
|
||||||
根据实例的数据库类型分发到对应的执行器。
|
根据实例的数据库类型分发到对应的执行器。
|
||||||
|
使用并发锁防止同一工单被并发执行。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ticket: 工单对象,包含要执行的 SQL/命令内容
|
ticket: 工单对象,包含要执行的 SQL/命令内容
|
||||||
@@ -135,18 +188,26 @@ class TicketExecutor:
|
|||||||
ExecutionReport: 执行报告
|
ExecutionReport: 执行报告
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
ExecutionLockError: 工单正在执行中
|
||||||
ValueError: 不支持的数据库类型
|
ValueError: 不支持的数据库类型
|
||||||
"""
|
"""
|
||||||
if instance.db_type == "mysql":
|
# 1. 获取并发执行锁
|
||||||
return await self._execute_mysql(ticket, instance)
|
await self._acquire_execution_lock(ticket.id)
|
||||||
elif instance.db_type in ("postgres", "postgresql"):
|
|
||||||
return await self._execute_postgres(ticket, instance)
|
try:
|
||||||
elif instance.db_type == "redis":
|
if instance.db_type == "mysql":
|
||||||
return await self._execute_redis(ticket, instance)
|
return await self._execute_mysql(ticket, instance)
|
||||||
elif instance.db_type == "mongo":
|
elif instance.db_type in ("postgres", "postgresql"):
|
||||||
return await self._execute_mongo(ticket, instance)
|
return await self._execute_postgres(ticket, instance)
|
||||||
else:
|
elif instance.db_type == "redis":
|
||||||
raise ValueError(f"不支持的执行类型: {instance.db_type}")
|
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 执行 =====
|
# ===== MySQL 执行 =====
|
||||||
|
|
||||||
@@ -155,10 +216,12 @@ class TicketExecutor:
|
|||||||
|
|
||||||
执行流程:
|
执行流程:
|
||||||
1. 语法预校验(sqlparse 解析)
|
1. 语法预校验(sqlparse 解析)
|
||||||
2. 执行前快照(SELECT 查看受影响的数据)
|
2. 建立连接(autocommit=False 显式事务)
|
||||||
3. 执行 SQL
|
3. 执行前快照和数据备份
|
||||||
4. 执行后验证
|
4. 在事务中执行 SQL
|
||||||
5. 生成报告
|
5. 执行后验证
|
||||||
|
6. 验证通过则提交,失败则回滚
|
||||||
|
7. 生成报告
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ticket: 工单对象
|
ticket: 工单对象
|
||||||
@@ -173,29 +236,42 @@ class TicketExecutor:
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
sql_content = ticket.sql_content.strip()
|
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:
|
try:
|
||||||
# 1. 语法预校验
|
# 1. 语法预校验
|
||||||
self._validate_mysql_syntax(sql_content)
|
self._validate_mysql_syntax(sql_content)
|
||||||
|
|
||||||
# 2. 建立连接
|
# 2. 建立连接(显式事务,autocommit=False)
|
||||||
conn = await aiomysql.connect(
|
conn = await aiomysql.connect(
|
||||||
host=instance.host,
|
host=instance.host,
|
||||||
port=instance.port,
|
port=instance.port,
|
||||||
user=instance.username,
|
user=instance.username,
|
||||||
password=instance.password,
|
password=instance.password,
|
||||||
db=ticket.target_database,
|
db=ticket.target_database,
|
||||||
autocommit=True,
|
autocommit=False,
|
||||||
|
connect_timeout=10,
|
||||||
|
read_timeout=timeout,
|
||||||
|
write_timeout=timeout,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 3. 执行前快照
|
# 3. 执行前快照和数据备份
|
||||||
report.before_snapshot = await self._capture_sql_before_snapshot(
|
report.before_snapshot = await self._capture_sql_before_snapshot(
|
||||||
conn, sql_content, "mysql"
|
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
|
total_affected = 0
|
||||||
output_lines: List[str] = []
|
output_lines: List[str] = []
|
||||||
|
execution_failed = False
|
||||||
|
|
||||||
statements = self._split_statements(sql_content)
|
statements = self._split_statements(sql_content)
|
||||||
for stmt in statements:
|
for stmt in statements:
|
||||||
@@ -203,11 +279,17 @@ class TicketExecutor:
|
|||||||
if not stmt:
|
if not stmt:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async with conn.cursor() as cur:
|
try:
|
||||||
await asyncio.wait_for(cur.execute(stmt), timeout=SQL_TIMEOUT)
|
async with conn.cursor() as cur:
|
||||||
affected = cur.rowcount
|
await asyncio.wait_for(cur.execute(stmt), timeout=timeout)
|
||||||
total_affected += max(0, affected) if affected is not None else 0
|
affected = cur.rowcount
|
||||||
output_lines.append(f"[{stmt[:50]}...] 影响 {affected} 行")
|
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.affected_rows = total_affected
|
||||||
report.raw_output = "\n".join(output_lines)
|
report.raw_output = "\n".join(output_lines)
|
||||||
@@ -222,19 +304,36 @@ class TicketExecutor:
|
|||||||
sql_content, report
|
sql_content, report
|
||||||
)
|
)
|
||||||
|
|
||||||
if not report.validation_passed and report.errors:
|
# 事务决策:验证通过且无执行错误则提交,否则回滚
|
||||||
report.status = "partial"
|
if not execution_failed and report.validation_passed:
|
||||||
else:
|
await conn.commit()
|
||||||
report.status = "success"
|
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:
|
except asyncio.TimeoutError:
|
||||||
report.status = "failed"
|
report.status = "failed"
|
||||||
report.errors.append(f"执行超时({SQL_TIMEOUT}秒)")
|
report.errors.append(f"执行超时({timeout}秒)")
|
||||||
report.validation_passed = False
|
report.validation_passed = False
|
||||||
|
try:
|
||||||
|
await conn.rollback()
|
||||||
|
logger.info("工单 %d 超时后事务已回滚", ticket.id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
report.status = "failed"
|
report.status = "failed"
|
||||||
report.errors.append(f"执行异常: {e}")
|
report.errors.append(f"执行异常: {e}")
|
||||||
report.validation_passed = False
|
report.validation_passed = False
|
||||||
|
try:
|
||||||
|
await conn.rollback()
|
||||||
|
logger.info("工单 %d 异常后事务已回滚", ticket.id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -255,7 +354,7 @@ class TicketExecutor:
|
|||||||
async def _execute_postgres(self, ticket: Ticket, instance: Instance) -> ExecutionReport:
|
async def _execute_postgres(self, ticket: Ticket, instance: Instance) -> ExecutionReport:
|
||||||
"""执行 PostgreSQL 工单。
|
"""执行 PostgreSQL 工单。
|
||||||
|
|
||||||
执行流程与 MySQL 类似,使用 asyncpg 驱动。
|
执行流程与 MySQL 类似,使用 asyncpg 驱动,支持显式事务管理。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ticket: 工单对象
|
ticket: 工单对象
|
||||||
@@ -271,70 +370,110 @@ class TicketExecutor:
|
|||||||
sql_content = ticket.sql_content.strip()
|
sql_content = ticket.sql_content.strip()
|
||||||
database = ticket.target_database or instance.database or "postgres"
|
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:
|
try:
|
||||||
# 1. 语法预校验
|
# 1. 语法预校验
|
||||||
self._validate_postgres_syntax(sql_content)
|
self._validate_postgres_syntax(sql_content)
|
||||||
|
|
||||||
# 2. 建立连接
|
# 2. 建立连接(显式事务)
|
||||||
dsn = (
|
dsn = (
|
||||||
f"postgresql://{instance.username}:{instance.password}"
|
f"postgresql://{instance.username}:{instance.password}"
|
||||||
f"@{instance.host}:{instance.port}/{database}"
|
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:
|
try:
|
||||||
# 3. 执行前快照
|
# 开始显式事务
|
||||||
report.before_snapshot = await self._capture_sql_before_snapshot(
|
transaction = conn.transaction()
|
||||||
conn, sql_content, "postgres"
|
await transaction.start()
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 执行 SQL
|
try:
|
||||||
total_affected = 0
|
# 3. 执行前快照和数据备份
|
||||||
output_lines: List[str] = []
|
report.before_snapshot = await self._capture_sql_before_snapshot(
|
||||||
|
conn, sql_content, "postgres"
|
||||||
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)
|
backup_data = await self._backup_mysql_data(conn, sql_content, "postgres")
|
||||||
|
if backup_data:
|
||||||
|
report.before_snapshot += f"\n[数据备份] {backup_data}"
|
||||||
|
|
||||||
# 5. 执行后快照
|
# 4. 在事务中执行 SQL
|
||||||
report.after_snapshot = await self._capture_sql_after_snapshot(
|
total_affected = 0
|
||||||
conn, sql_content, "postgres"
|
output_lines: List[str] = []
|
||||||
)
|
execution_failed = False
|
||||||
|
|
||||||
# 6. 验证
|
statements = self._split_statements(sql_content)
|
||||||
report.validation_passed = self._validate_sql_execution(
|
for stmt in statements:
|
||||||
sql_content, report
|
stmt = stmt.strip()
|
||||||
)
|
if not stmt:
|
||||||
|
continue
|
||||||
|
|
||||||
if not report.validation_passed and report.errors:
|
try:
|
||||||
report.status = "partial"
|
status = await asyncio.wait_for(
|
||||||
else:
|
conn.execute(stmt), timeout=timeout
|
||||||
report.status = "success"
|
)
|
||||||
|
# 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:
|
finally:
|
||||||
await conn.close()
|
await conn.close()
|
||||||
|
|
||||||
@@ -359,6 +498,12 @@ class TicketExecutor:
|
|||||||
- JSON 格式: {"commands": [{"command": "SET key value", "db": 0}]}
|
- JSON 格式: {"commands": [{"command": "SET key value", "db": 0}]}
|
||||||
- 简单文本格式: 每行一个命令
|
- 简单文本格式: 每行一个命令
|
||||||
|
|
||||||
|
增强功能:
|
||||||
|
- 命令注入防护(shell字符检测)
|
||||||
|
- 连接池管理
|
||||||
|
- 可配置超时
|
||||||
|
- 原子性执行(全部成功或记录失败)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ticket: 工单对象
|
ticket: 工单对象
|
||||||
instance: Redis 实例连接信息
|
instance: Redis 实例连接信息
|
||||||
@@ -371,6 +516,10 @@ class TicketExecutor:
|
|||||||
report = ExecutionReport()
|
report = ExecutionReport()
|
||||||
start_time = time.time()
|
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:
|
try:
|
||||||
# 解析命令
|
# 解析命令
|
||||||
commands = self._parse_redis_commands(ticket.sql_content)
|
commands = self._parse_redis_commands(ticket.sql_content)
|
||||||
@@ -383,28 +532,56 @@ class TicketExecutor:
|
|||||||
# 语法预校验
|
# 语法预校验
|
||||||
self._validate_redis_commands(commands)
|
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(
|
conn = aioredis.Redis(
|
||||||
host=instance.host,
|
host=instance.host,
|
||||||
port=instance.port,
|
port=instance.port,
|
||||||
password=instance.password or None,
|
password=instance.password or None,
|
||||||
decode_responses=True,
|
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:
|
try:
|
||||||
# 执行前快照
|
# 执行前快照
|
||||||
report.before_snapshot = await self._capture_redis_before_snapshot(
|
report.before_snapshot = await self._capture_redis_before_snapshot(
|
||||||
conn, commands
|
conn, commands
|
||||||
)
|
)
|
||||||
|
|
||||||
# 执行命令
|
# 执行命令(带重试机制)
|
||||||
output_lines: List[str] = []
|
output_lines: List[str] = []
|
||||||
success_count = 0
|
success_count = 0
|
||||||
|
max_retries = 1
|
||||||
|
|
||||||
for cmd_info in commands:
|
for cmd_info in commands:
|
||||||
command = cmd_info["command"]
|
command = cmd_info["command"]
|
||||||
db = cmd_info.get("db", 0)
|
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:
|
if db != 0:
|
||||||
await conn.select(db)
|
await conn.select(db)
|
||||||
@@ -416,23 +593,36 @@ class TicketExecutor:
|
|||||||
cmd_name = parts[0].upper()
|
cmd_name = parts[0].upper()
|
||||||
args = parts[1:]
|
args = parts[1:]
|
||||||
|
|
||||||
try:
|
# 执行命令(带重试)
|
||||||
result = await asyncio.wait_for(
|
retry_count = 0
|
||||||
conn.execute_command(cmd_name, *args),
|
executed = False
|
||||||
timeout=REDIS_TIMEOUT,
|
last_error = None
|
||||||
)
|
|
||||||
success_count += 1
|
while retry_count <= max_retries and not executed:
|
||||||
output_lines.append(
|
try:
|
||||||
f"[DB{db}] {command} => {self._format_redis_result(result)}"
|
result = await asyncio.wait_for(
|
||||||
)
|
conn.execute_command(cmd_name, *args),
|
||||||
except asyncio.TimeoutError:
|
timeout=timeout,
|
||||||
output_lines.append(
|
)
|
||||||
f"[DB{db}] {command} => 超时({REDIS_TIMEOUT}秒)"
|
success_count += 1
|
||||||
)
|
output_lines.append(
|
||||||
report.errors.append(f"命令超时: {command}")
|
f"[DB{db}] {command} => {self._format_redis_result(result)}"
|
||||||
except Exception as e:
|
)
|
||||||
output_lines.append(f"[DB{db}] {command} => 错误: {e}")
|
executed = True
|
||||||
report.errors.append(f"命令执行失败 '{command}': {e}")
|
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.affected_rows = success_count
|
||||||
report.raw_output = "\n".join(output_lines)
|
report.raw_output = "\n".join(output_lines)
|
||||||
@@ -480,6 +670,12 @@ class TicketExecutor:
|
|||||||
"update": {"$set": {"status": "active"}}
|
"update": {"$set": {"status": "active"}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
增强功能:
|
||||||
|
- 可配置超时
|
||||||
|
- 连接池管理
|
||||||
|
- 操作验证增强
|
||||||
|
- writeConcern 配置(数据持久化保证)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ticket: 工单对象
|
ticket: 工单对象
|
||||||
instance: MongoDB 实例连接信息
|
instance: MongoDB 实例连接信息
|
||||||
@@ -490,6 +686,10 @@ class TicketExecutor:
|
|||||||
report = ExecutionReport()
|
report = ExecutionReport()
|
||||||
start_time = time.time()
|
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:
|
try:
|
||||||
# 解析工单内容
|
# 解析工单内容
|
||||||
operation_spec = self._parse_mongo_operation(ticket.sql_content)
|
operation_spec = self._parse_mongo_operation(ticket.sql_content)
|
||||||
@@ -499,6 +699,11 @@ class TicketExecutor:
|
|||||||
# 语法预校验
|
# 语法预校验
|
||||||
self._validate_mongo_operation(operation_spec)
|
self._validate_mongo_operation(operation_spec)
|
||||||
|
|
||||||
|
# 额外的安全验证:禁止危险操作
|
||||||
|
forbidden_ops = {"drop", "dropIndex", "renameCollection"}
|
||||||
|
if operation in forbidden_ops:
|
||||||
|
raise SyntaxValidationError(f"MongoDB 操作 '{operation}' 被禁止")
|
||||||
|
|
||||||
# 导入 motor(异步 MongoDB 驱动)
|
# 导入 motor(异步 MongoDB 驱动)
|
||||||
try:
|
try:
|
||||||
from motor.motor_asyncio import AsyncIOMotorClient
|
from motor.motor_asyncio import AsyncIOMotorClient
|
||||||
@@ -508,13 +713,32 @@ class TicketExecutor:
|
|||||||
report.validation_passed = False
|
report.validation_passed = False
|
||||||
return report
|
return report
|
||||||
|
|
||||||
# 建立连接
|
# 建立连接(增强配置)
|
||||||
database = ticket.target_database or instance.database or "admin"
|
database = ticket.target_database or instance.database or "admin"
|
||||||
conn_str = (
|
conn_str = (
|
||||||
f"mongodb://{instance.username}:{instance.password}"
|
f"mongodb://{instance.username}:{instance.password}"
|
||||||
f"@{instance.host}:{instance.port}/{database}"
|
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:
|
try:
|
||||||
db = client[database]
|
db = client[database]
|
||||||
@@ -525,27 +749,35 @@ class TicketExecutor:
|
|||||||
collection, operation_spec
|
collection, operation_spec
|
||||||
)
|
)
|
||||||
|
|
||||||
# 执行操作
|
# 执行操作(带超时和错误处理)
|
||||||
filter_doc = operation_spec.get("filter", {})
|
filter_doc = operation_spec.get("filter", {})
|
||||||
update_doc = operation_spec.get("update", {})
|
update_doc = operation_spec.get("update", {})
|
||||||
doc = operation_spec.get("document", {})
|
doc = operation_spec.get("document", {})
|
||||||
options = operation_spec.get("options", {})
|
options = operation_spec.get("options", {})
|
||||||
|
# 强制 majority write concern 保证数据持久化
|
||||||
|
exec_options = {"w": "majority", "j": True}
|
||||||
|
if options:
|
||||||
|
exec_options.update(options)
|
||||||
|
|
||||||
result_obj: Any = None
|
result_obj: Any = None
|
||||||
|
|
||||||
if operation == "insertOne":
|
if operation == "insertOne":
|
||||||
result_obj = await asyncio.wait_for(
|
result_obj = await asyncio.wait_for(
|
||||||
collection.insert_one(doc),
|
collection.with_options(write_concern=exec_options.get("w")).insert_one(doc),
|
||||||
timeout=MONGO_TIMEOUT,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
report.affected_rows = 1 if result_obj.inserted_id else 0
|
report.affected_rows = 1 if result_obj.inserted_id else 0
|
||||||
report.raw_output = f"插入成功,ID: {result_obj.inserted_id}"
|
report.raw_output = f"插入成功,ID: {result_obj.inserted_id}"
|
||||||
|
|
||||||
elif operation == "insertMany":
|
elif operation == "insertMany":
|
||||||
docs = operation_spec.get("documents", [])
|
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(
|
result_obj = await asyncio.wait_for(
|
||||||
collection.insert_many(docs),
|
collection.insert_many(docs),
|
||||||
timeout=MONGO_TIMEOUT,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
report.affected_rows = len(result_obj.inserted_ids)
|
report.affected_rows = len(result_obj.inserted_ids)
|
||||||
report.raw_output = f"批量插入成功,共 {len(result_obj.inserted_ids)} 条"
|
report.raw_output = f"批量插入成功,共 {len(result_obj.inserted_ids)} 条"
|
||||||
@@ -553,7 +785,7 @@ class TicketExecutor:
|
|||||||
elif operation == "updateOne":
|
elif operation == "updateOne":
|
||||||
result_obj = await asyncio.wait_for(
|
result_obj = await asyncio.wait_for(
|
||||||
collection.update_one(filter_doc, update_doc, **options),
|
collection.update_one(filter_doc, update_doc, **options),
|
||||||
timeout=MONGO_TIMEOUT,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
report.affected_rows = result_obj.modified_count
|
report.affected_rows = result_obj.modified_count
|
||||||
report.raw_output = (
|
report.raw_output = (
|
||||||
@@ -564,7 +796,7 @@ class TicketExecutor:
|
|||||||
elif operation == "updateMany":
|
elif operation == "updateMany":
|
||||||
result_obj = await asyncio.wait_for(
|
result_obj = await asyncio.wait_for(
|
||||||
collection.update_many(filter_doc, update_doc, **options),
|
collection.update_many(filter_doc, update_doc, **options),
|
||||||
timeout=MONGO_TIMEOUT,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
report.affected_rows = result_obj.modified_count
|
report.affected_rows = result_obj.modified_count
|
||||||
report.raw_output = (
|
report.raw_output = (
|
||||||
@@ -575,15 +807,21 @@ class TicketExecutor:
|
|||||||
elif operation == "deleteOne":
|
elif operation == "deleteOne":
|
||||||
result_obj = await asyncio.wait_for(
|
result_obj = await asyncio.wait_for(
|
||||||
collection.delete_one(filter_doc),
|
collection.delete_one(filter_doc),
|
||||||
timeout=MONGO_TIMEOUT,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
report.affected_rows = result_obj.deleted_count
|
report.affected_rows = result_obj.deleted_count
|
||||||
report.raw_output = f"删除 {result_obj.deleted_count} 条"
|
report.raw_output = f"删除 {result_obj.deleted_count} 条"
|
||||||
|
|
||||||
elif operation == "deleteMany":
|
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(
|
result_obj = await asyncio.wait_for(
|
||||||
collection.delete_many(filter_doc),
|
collection.delete_many(filter_doc),
|
||||||
timeout=MONGO_TIMEOUT,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
report.affected_rows = result_obj.deleted_count
|
report.affected_rows = result_obj.deleted_count
|
||||||
report.raw_output = f"批量删除 {result_obj.deleted_count} 条"
|
report.raw_output = f"批量删除 {result_obj.deleted_count} 条"
|
||||||
@@ -591,7 +829,7 @@ class TicketExecutor:
|
|||||||
elif operation == "replaceOne":
|
elif operation == "replaceOne":
|
||||||
result_obj = await asyncio.wait_for(
|
result_obj = await asyncio.wait_for(
|
||||||
collection.replace_one(filter_doc, doc, **options),
|
collection.replace_one(filter_doc, doc, **options),
|
||||||
timeout=MONGO_TIMEOUT,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
report.affected_rows = result_obj.modified_count
|
report.affected_rows = result_obj.modified_count
|
||||||
report.raw_output = (
|
report.raw_output = (
|
||||||
@@ -611,17 +849,25 @@ class TicketExecutor:
|
|||||||
|
|
||||||
if report.validation_passed:
|
if report.validation_passed:
|
||||||
report.status = "success"
|
report.status = "success"
|
||||||
|
logger.info("工单 %d MongoDB %s 执行成功", ticket.id, operation)
|
||||||
else:
|
else:
|
||||||
report.status = "partial"
|
report.status = "partial"
|
||||||
|
logger.warning("工单 %d MongoDB %s 验证未通过", ticket.id, operation)
|
||||||
|
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
report.status = "failed"
|
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
|
report.validation_passed = False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
report.status = "failed"
|
report.status = "failed"
|
||||||
report.errors.append(f"执行异常: {e}")
|
report.errors.append(f"执行异常: {e}")
|
||||||
report.validation_passed = False
|
report.validation_passed = False
|
||||||
|
logger.exception("工单 %d MongoDB 执行异常", ticket.id)
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
||||||
@@ -637,6 +883,64 @@ class TicketExecutor:
|
|||||||
report.duration_ms = int((time.time() - start_time) * 1000)
|
report.duration_ms = int((time.time() - start_time) * 1000)
|
||||||
return report
|
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(
|
async def _capture_sql_before_snapshot(
|
||||||
|
|||||||
+1125
-165
File diff suppressed because it is too large.
Load diff
@@ -13,26 +13,25 @@ class TestSQLAuditEngine:
|
|||||||
report = self.engine.audit("DROP TABLE users;")
|
report = self.engine.audit("DROP TABLE users;")
|
||||||
assert not report.passed or report.has_warnings
|
assert not report.passed or report.has_warnings
|
||||||
rules = [f.rule for f in report.findings]
|
rules = [f.rule for f in report.findings]
|
||||||
assert "DDL_IF_EXISTS" in rules
|
assert "DROP_TABLE_DANGER" in rules
|
||||||
assert "DROP_TABLE" in rules
|
|
||||||
|
|
||||||
def test_drop_table_production_blocked(self):
|
def test_drop_table_production_blocked(self):
|
||||||
engine = SQLAuditEngine(env="production")
|
engine = SQLAuditEngine(env="production")
|
||||||
report = engine.audit("DROP TABLE users;")
|
report = engine.audit("DROP TABLE users;")
|
||||||
assert not report.passed
|
assert report.has_warnings or not report.passed
|
||||||
errors = [f for f in report.findings if f.level == Severity.ERROR]
|
warnings = [f for f in report.findings if f.rule == "DROP_TABLE_DANGER"]
|
||||||
assert any(f.rule == "DROP_TABLE_PRODUCTION" for f in errors)
|
assert len(warnings) > 0
|
||||||
|
|
||||||
def test_truncate_warning(self):
|
def test_truncate_warning(self):
|
||||||
report = self.engine.audit("TRUNCATE TABLE logs;")
|
report = self.engine.audit("TRUNCATE TABLE logs;")
|
||||||
rules = [f.rule for f in report.findings]
|
rules = [f.rule for f in report.findings]
|
||||||
assert "TRUNCATE" in rules
|
assert "TRUNCATE_DANGER" in rules
|
||||||
|
|
||||||
def test_truncate_production_error(self):
|
def test_truncate_production_error(self):
|
||||||
engine = SQLAuditEngine(env="production")
|
engine = SQLAuditEngine(env="production")
|
||||||
report = engine.audit("TRUNCATE TABLE logs;")
|
report = engine.audit("TRUNCATE TABLE logs;")
|
||||||
errors = [f for f in report.findings if f.level == Severity.ERROR]
|
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 ---
|
# --- Missing WHERE ---
|
||||||
|
|
||||||
@@ -92,12 +91,12 @@ class TestSQLAuditEngine:
|
|||||||
sql = f"SELECT * FROM users WHERE id IN ({values});"
|
sql = f"SELECT * FROM users WHERE id IN ({values});"
|
||||||
report = self.engine.audit(sql)
|
report = self.engine.audit(sql)
|
||||||
rules = [f.rule for f in report.findings]
|
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):
|
def test_small_in_ok(self):
|
||||||
report = self.engine.audit("SELECT * FROM users WHERE id IN (1, 2, 3);")
|
report = self.engine.audit("SELECT * FROM users WHERE id IN (1, 2, 3);")
|
||||||
rules = [f.rule for f in report.findings]
|
rules = [f.rule for f in report.findings]
|
||||||
assert "LARGE_IN" not in rules
|
assert "LARGE_IN_CLAUSE" not in rules
|
||||||
|
|
||||||
# --- Production extra rules ---
|
# --- Production extra rules ---
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class TestCreateTicket:
|
|||||||
|
|
||||||
async def test_create_ticket_instance_not_found(self, client: AsyncClient, developer_token):
|
async def test_create_ticket_instance_not_found(self, client: AsyncClient, developer_token):
|
||||||
resp = await _create_ticket(client, developer_token, instance_id=9999)
|
resp = await _create_ticket(client, developer_token, instance_id=9999)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestGetTicket:
|
class TestGetTicket:
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export interface LoginResponse {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
role: string;
|
role: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
data_source_permissions?: string;
|
||||||
|
custom_permissions?: string;
|
||||||
|
group_ids?: number[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,6 +21,9 @@ export interface MeResponse {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
role: string;
|
role: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
data_source_permissions?: string;
|
||||||
|
custom_permissions?: string;
|
||||||
|
group_ids?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
} from '@codemirror/language';
|
} from '@codemirror/language';
|
||||||
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||||
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
||||||
import { rectangularSelection } from '@codemirror/rectangular-selection';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -296,7 +295,6 @@ const SqlEditor: React.FC<SqlEditorProps> = ({
|
|||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
closeBrackets(),
|
closeBrackets(),
|
||||||
highlightSelectionMatches(),
|
highlightSelectionMatches(),
|
||||||
rectangularSelection(),
|
|
||||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||||
appTheme,
|
appTheme,
|
||||||
oneDark,
|
oneDark,
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
table?: string
|
table?: string
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (!isLoggedIn) return false;
|
if (!isLoggedIn) return false;
|
||||||
if (isAdmin) return true; // 管理员拥有所有数据源权限
|
if (user.role === 'admin') return true; // 管理员拥有所有数据源权限
|
||||||
|
|
||||||
const cache = permissionCacheRef.current;
|
const cache = permissionCacheRef.current;
|
||||||
const cacheKey = `${instanceId}:${action}:${database || '*'}:${table || '*'}`;
|
const cacheKey = `${instanceId}:${action}:${database || '*'}:${table || '*'}`;
|
||||||
@@ -368,7 +368,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
cache.dataSource.set(cacheKey, hasPerm);
|
cache.dataSource.set(cacheKey, hasPerm);
|
||||||
cache.timestamp = Date.now();
|
cache.timestamp = Date.now();
|
||||||
return hasPerm;
|
return hasPerm;
|
||||||
}, [user.dataSourcePermissions, user.role, isAdmin, isLoggedIn, isCacheValid]);
|
}, [user.dataSourcePermissions, user.role, isLoggedIn, isCacheValid]);
|
||||||
|
|
||||||
/** 批量检查数据源权限 */
|
/** 批量检查数据源权限 */
|
||||||
const checkDataSourcePermissions = useCallback((
|
const checkDataSourcePermissions = useCallback((
|
||||||
@@ -387,34 +387,31 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
/** 从服务器刷新用户权限 */
|
/** 从服务器刷新用户权限 */
|
||||||
const refreshPermissions = useCallback(async (): Promise<void> => {
|
const refreshPermissions = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const res = await authApi.getCurrentUser();
|
const u = await authApi.getMe();
|
||||||
if (res.user) {
|
const role = u.role as UserRole;
|
||||||
const u = res.user;
|
const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions
|
||||||
const role = u.role as UserRole;
|
? JSON.parse(u.data_source_permissions)
|
||||||
const dataSourcePermissions: DataSourcePermission[] = u.data_source_permissions
|
: [];
|
||||||
? JSON.parse(u.data_source_permissions)
|
const customPermissions: Record<string, boolean> = u.custom_permissions
|
||||||
: [];
|
? JSON.parse(u.custom_permissions)
|
||||||
const customPermissions: Record<string, boolean> = u.custom_permissions
|
: {};
|
||||||
? JSON.parse(u.custom_permissions)
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const newUser: AuthUser = {
|
const newUser: AuthUser = {
|
||||||
...user,
|
...user,
|
||||||
id: u.id,
|
id: u.id,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
email: u.email || '',
|
email: u.email || '',
|
||||||
role,
|
role,
|
||||||
roleName: roleNameMap[role] || role,
|
roleName: roleNameMap[role] || role,
|
||||||
groupIds: u.group_ids || [],
|
groupIds: u.group_ids || [],
|
||||||
dataSourcePermissions,
|
dataSourcePermissions,
|
||||||
customPermissions,
|
customPermissions,
|
||||||
permissionsUpdatedAt: Date.now(),
|
permissionsUpdatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
|
||||||
setUser(newUser);
|
setUser(newUser);
|
||||||
clearPermissionCache();
|
clearPermissionCache();
|
||||||
emitPermissionChange(newUser);
|
emitPermissionChange(newUser);
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('刷新权限失败:', error);
|
console.error('刷新权限失败:', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Search, Download, Filter, X, ChevronDown, ChevronRight,
|
Search, Download, Filter, ChevronDown, ChevronRight,
|
||||||
FileText, User, Globe, CheckCircle2, XCircle, AlertTriangle,
|
FileText, User, Globe, CheckCircle2, XCircle, AlertTriangle,
|
||||||
BarChart3, Clock, RotateCcw,
|
BarChart3, Clock, RotateCcw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -131,7 +131,7 @@ function exportLogsToCSV(logs: AuditLog[]) {
|
|||||||
|
|
||||||
export default function AuditLogsPage() {
|
export default function AuditLogsPage() {
|
||||||
const [logs, setLogs] = useState<AuditLog[]>([]);
|
const [logs, setLogs] = useState<AuditLog[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
|
|||||||
import request from '../api/request';
|
import request from '../api/request';
|
||||||
import {
|
import {
|
||||||
Search, Filter, ChevronDown, ChevronUp, AlertTriangle,
|
Search, Filter, ChevronDown, ChevronUp, AlertTriangle,
|
||||||
Info, CheckCircle, XCircle, Database, Table, FileSearch,
|
Info, XCircle, Database, Table, FileSearch,
|
||||||
Shield, Loader2, Save, RotateCcw, Leaf, Server,
|
Shield, Loader2, Leaf, Server,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -136,7 +136,7 @@ export default function AuditRulesPage() {
|
|||||||
const res = await request.get('/audit-rules', {
|
const res = await request.get('/audit-rules', {
|
||||||
params: { db_type: dbType },
|
params: { db_type: dbType },
|
||||||
});
|
});
|
||||||
const data = (res as { data?: unknown[] }).data ?? (res as unknown[]);
|
const data = (res as { data?: unknown[] }).data ?? (res as unknown as unknown[]);
|
||||||
setRules(Array.isArray(data) ? data as AuditRule[] : []);
|
setRules(Array.isArray(data) ? data as AuditRule[] : []);
|
||||||
} catch {
|
} catch {
|
||||||
setRules([]);
|
setRules([]);
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
|||||||
import {
|
import {
|
||||||
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
||||||
Database, ChevronRight, X, AlertCircle, Info, Bell,
|
Database, ChevronRight, X, AlertCircle, Info, Bell,
|
||||||
Activity, TrendingUp, Zap, Server, Plus, Search,
|
Activity, TrendingUp, Zap, Server, Search,
|
||||||
FileText, ArrowUpRight, ArrowDownRight, Minus,
|
FileText, ArrowUpRight, ArrowDownRight, Minus,
|
||||||
Filter, Eye, Loader2, BarChart3,
|
Filter, Eye, BarChart3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
|
import { dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets, announcements } from '@/lib/mock';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
BookOpen, Database, Table2, Search, Download, Columns3, Key, Code2, RefreshCw,
|
BookOpen, Database, Table2, Search, Download, Columns3, Key, Code2, RefreshCw,
|
||||||
ChevronRight, ChevronDown, Hash, AlignLeft, Gauge, FolderOpen, ArrowRight,
|
ChevronRight, Hash, AlignLeft, Gauge, FolderOpen, ArrowRight,
|
||||||
FileJson, TableProperties, Info,
|
FileJson, TableProperties, Info,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -173,7 +173,6 @@ export default function DataDictionaryPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<TabKey>('columns');
|
const [activeTab, setActiveTab] = useState<TabKey>('columns');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [expandedDbs, setExpandedDbs] = useState<Set<string>>(new Set());
|
const [expandedDbs, setExpandedDbs] = useState<Set<string>>(new Set());
|
||||||
const [dbTreeExpanded, setDbTreeExpanded] = useState(true);
|
|
||||||
|
|
||||||
// Load instances
|
// Load instances
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X,
|
Plus, Search, Edit2, Trash2, Server, Users, ChevronRight, X,
|
||||||
XCircle, Shield, Grid3x3, BarChart3, Clock, CheckCircle2,
|
Shield, Grid3x3, Clock, CheckCircle2,
|
||||||
Database, HardDrive, Cpu, Activity, Filter, RotateCcw, ChevronDown,
|
Database, HardDrive, Activity,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
import { instanceGroupApi, type InstanceGroup } from '@/api/instanceGroup';
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
|||||||
import {
|
import {
|
||||||
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
|
Plus, X, AlertTriangle, CheckCircle, Play, Loader2,
|
||||||
ChevronDown, Clock, FileText, Sparkles, Star,
|
ChevronDown, Clock, FileText, Sparkles, Star,
|
||||||
StarOff, Download, Search, RotateCcw, ArrowUpDown,
|
StarOff, Download, Search, ArrowUpDown,
|
||||||
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, PanelLeft,
|
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useActionPermission } from '@/hooks/useAuth';
|
import { useActionPermission } from '@/hooks/useAuth';
|
||||||
@@ -12,7 +12,7 @@ import EmptyState from '@/components/EmptyState';
|
|||||||
import { queryApi } from '@/api/query';
|
import { queryApi } from '@/api/query';
|
||||||
import { instanceApi } from '@/api/instance';
|
import { instanceApi } from '@/api/instance';
|
||||||
import SqlEditor from '@/components/SqlEditor';
|
import SqlEditor from '@/components/SqlEditor';
|
||||||
import { queryTemplates, getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
|
||||||
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
|
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
|
|||||||
import {
|
import {
|
||||||
Search, Clock, Database, AlertTriangle, ChevronDown, ChevronRight,
|
Search, Clock, Database, AlertTriangle, ChevronDown, ChevronRight,
|
||||||
BarChart3, Play, Loader2, RefreshCw, Download, Lightbulb, X,
|
BarChart3, Play, Loader2, RefreshCw, Download, Lightbulb, X,
|
||||||
GitCompare, XCircle, FileText, Skull, Zap, CheckCircle2,
|
GitCompare, XCircle, Skull, Zap, CheckCircle2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ export default function TicketCreatePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoadingDatabases(true);
|
setLoadingDatabases(true);
|
||||||
instanceApi.getDatabases?.(Number(instanceId))
|
instanceApi.listDatabases(Number(instanceId))
|
||||||
.then((res: unknown) => {
|
.then((res: unknown) => {
|
||||||
const data = (res as { databases?: string[] })?.databases
|
const data = (res as { databases?: string[] })?.databases
|
||||||
|| (res as string[])
|
|| (res as string[])
|
||||||
@@ -240,39 +240,6 @@ export default function TicketCreatePage() {
|
|||||||
}
|
}
|
||||||
}, [sqlContent, instanceId, database]);
|
}, [sqlContent, instanceId, database]);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
/* Pre-submit audit check */
|
|
||||||
/* ---------------------------------------------------------------- */
|
|
||||||
|
|
||||||
const handlePreSubmitAudit = useCallback(async () => {
|
|
||||||
if (!title.trim() || !sqlContent.trim() || !instanceId || !database || !reason.trim()) {
|
|
||||||
setError('请填写所有必填项');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// If already audited and SQL hasn't changed, go directly to step 3
|
|
||||||
if (auditResult) {
|
|
||||||
setStep(3);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Auto-audit before submission
|
|
||||||
setAuditing(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
const result = await ticketApi.audit({
|
|
||||||
sql_content: sqlContent,
|
|
||||||
instance_id: Number(instanceId),
|
|
||||||
database,
|
|
||||||
}) as unknown as AuditResult;
|
|
||||||
setAuditResult(result);
|
|
||||||
setStep(3);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const err = e as { response?: { data?: { detail?: string } } };
|
|
||||||
setError(err?.response?.data?.detail || '预审核失败,请检查 SQL 语法');
|
|
||||||
} finally {
|
|
||||||
setAuditing(false);
|
|
||||||
}
|
|
||||||
}, [title, sqlContent, instanceId, database, reason, auditResult]);
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Submit handler */
|
/* Submit handler */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -198,15 +198,13 @@ export default function TicketDetailPage() {
|
|||||||
/* Permission checks */
|
/* Permission checks */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
|
|
||||||
const { isAdminOrDba, isApplicant, canApprove, canExecute, canRollback, canCancel } = useMemo(() => {
|
const { canApprove, canExecute, canRollback, canCancel } = useMemo(() => {
|
||||||
if (!ticket) {
|
if (!ticket) {
|
||||||
return { isAdminOrDba: false, isApplicant: false, canApprove: false, canExecute: false, canRollback: false, canCancel: false };
|
return { canApprove: false, canExecute: false, canRollback: false, canCancel: false };
|
||||||
}
|
}
|
||||||
const adminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba';
|
const adminOrDba = auth.user.role === 'admin' || auth.user.role === 'dba';
|
||||||
const applicant = ticket.applicant_id === auth.user.id;
|
const applicant = ticket.applicant_id === auth.user.id;
|
||||||
return {
|
return {
|
||||||
isAdminOrDba: adminOrDba,
|
|
||||||
isApplicant: applicant,
|
|
||||||
canApprove: adminOrDba && (ticket.status === 'pending' || ticket.status === 'reviewing'),
|
canApprove: adminOrDba && (ticket.status === 'pending' || ticket.status === 'reviewing'),
|
||||||
canExecute: adminOrDba && ticket.status === 'approved',
|
canExecute: adminOrDba && ticket.status === 'approved',
|
||||||
canRollback: adminOrDba && (ticket.status === 'success' || ticket.status === 'failed'),
|
canRollback: adminOrDba && (ticket.status === 'success' || ticket.status === 'failed'),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw,
|
Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw,
|
||||||
ChevronRight, FileCode, History, Undo2, XCircle as XIcon,
|
FileCode, History, Undo2, XCircle as XIcon,
|
||||||
Clock, AlertTriangle, AlertCircle, Info, ChevronLeft,
|
Clock, AlertTriangle, AlertCircle, Info, ChevronLeft,
|
||||||
Download, Filter, X, Loader2, BarChart3, Users, Calendar,
|
Filter, X, Loader2, Users, Calendar,
|
||||||
CheckSquare, Square, ArrowUpDown, FileDown, Ban,
|
CheckSquare, Square, ArrowUpDown, FileDown, Ban,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -540,6 +540,7 @@ export default function TicketsPage() {
|
|||||||
? <CheckSquare className="w-4 h-4 text-blue-400" />
|
? <CheckSquare className="w-4 h-4 text-blue-400" />
|
||||||
: <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
: <Square className="w-4 h-4 text-[var(--dg-text-muted)]" />
|
||||||
}
|
}
|
||||||
|
</button>
|
||||||
</th>
|
</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold">工单号</th>
|
<th className="text-left px-4 py-3 font-semibold">工单号</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold">标题</th>
|
<th className="text-left px-4 py-3 font-semibold">标题</th>
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
|
|||||||
import {
|
import {
|
||||||
Plus, Search, Edit2, Trash2, XCircle, Eye, Download, Upload,
|
Plus, Search, Edit2, Trash2, XCircle, Eye, Download, Upload,
|
||||||
CheckSquare, Square, X, RotateCcw, KeyRound, ChevronDown,
|
CheckSquare, Square, X, RotateCcw, KeyRound, ChevronDown,
|
||||||
UserCheck, UserX, Filter, MoreHorizontal, Lock,
|
UserCheck, UserX, Filter,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { roles, users as mockUsers } from '@/lib/mock';
|
import { users as mockUsers } from '@/lib/mock';
|
||||||
import { userApi } from '@/api/user';
|
import { userApi } from '@/api/user';
|
||||||
|
|
||||||
/* ───────── Types ───────── */
|
/* ───────── Types ───────── */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Plus, Edit2, Eye, ToggleLeft, ToggleRight, GitBranch, Users, CheckCircle,
|
Plus, Edit2, Eye, ToggleLeft, ToggleRight, GitBranch, Users, CheckCircle,
|
||||||
ArrowRight, Shield, Trash2, X, ChevronDown, ChevronUp,
|
ArrowRight, Shield, Trash2, X, ChevronDown, ChevronUp,
|
||||||
Save, Play, Copy, History, TestTube, AlertTriangle,
|
Save, Play, Copy, History, TestTube, AlertTriangle,
|
||||||
Loader2, Check, XCircle, MessageSquare,
|
Loader2, Check, XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { workflows as initialWorkflows, dbTypeColorMap } from '@/lib/mock';
|
import { workflows as initialWorkflows, dbTypeColorMap } from '@/lib/mock';
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# 优化包合并分析报告
|
||||||
|
|
||||||
|
## 📋 总览
|
||||||
|
|
||||||
|
| 文件 | 行数变化 | 冲突模型引用 | 安全合并? | 策略 |
|
||||||
|
|------|---------|------------|----------|------|
|
||||||
|
| models/user.py | 23→299 (+276) | ⚠️ **核心冲突** | ❌ | 手动合并 |
|
||||||
|
| models/workflow.py | 55→362 (+307) | ✅ 无 | ✅ | 直接覆盖 |
|
||||||
|
| services/workflow_engine.py | 359→1319 (+960) | ✅ 无 | ✅ | 直接覆盖 |
|
||||||
|
| services/query_service.py | 709→1253 (+544) | ✅ 无 | ✅ | 直接覆盖 |
|
||||||
|
| services/audit_engine.py | 234→790 (+556) | ✅ 无 | ✅ | 直接覆盖 |
|
||||||
|
| services/ticket_executor.py | 1384→1688 (+304) | ✅ 无 | ✅ | 直接覆盖 |
|
||||||
|
| api/v1/endpoints/ticket.py | 747→1029 (+282) | ⚠️ AuditLog引用 | ✅ | 直接覆盖 |
|
||||||
|
| api/v1/endpoints/query.py | 226→426 (+200) | ⚠️ AuditLog引用 | ✅ | 直接覆盖 |
|
||||||
|
| api/v1/endpoints/user.py | 116→116 (0) | ⚠️ AuditLogService | ✅ | 直接覆盖 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔴 冲突分析
|
||||||
|
|
||||||
|
### 冲突1:`user_groups` 表名冲突
|
||||||
|
|
||||||
|
| 维度 | 我们的版本 | 优化包版本 |
|
||||||
|
|------|-----------|-----------|
|
||||||
|
| **位置** | `models/instance_group.py` | `models/user.py` |
|
||||||
|
| **类型** | `Table()` 关联表 | `class UserGroup(Base)` ORM模型 |
|
||||||
|
| **表名** | `user_groups` | `user_groups` |
|
||||||
|
| **列** | user_id, group_id, permissions | id, name, description, group_permissions, ... |
|
||||||
|
| **用途** | User ↔ InstanceGroup 多对多关联 | 独立的用户组权限管理实体 |
|
||||||
|
|
||||||
|
**冲突本质**:两个完全不同的数据库表结构映射到同一个表名。优化包的 `UserGroup` 是一个完整的权限组系统,我们的 `user_groups` 只是一个简单的关联表。
|
||||||
|
|
||||||
|
### 冲突2:AuditLog 双重定义
|
||||||
|
|
||||||
|
| 维度 | 我们的版本 (`models/audit_log.py`) | 优化包版本 (`models/user.py`) |
|
||||||
|
|------|----------------------------------|-------------------------------|
|
||||||
|
| **表名** | `audit_logs` | `audit_logs` |
|
||||||
|
| **字段风格** | 运维审计(target_type, query_content, db_type) | 通用审计(resource_type, before_data, after_data) |
|
||||||
|
| **关系** | 无 relationship | `user = relationship("User", ...)` |
|
||||||
|
|
||||||
|
**关键发现**:优化包的 `ticket.py` 和 `query.py` 都 `from app.models.audit_log import AuditLog`(即使用的是我们的版本),而不是从 `models.user` 导入!
|
||||||
|
|
||||||
|
### 冲突3:新增模型类(无冲突但需数据库迁移)
|
||||||
|
|
||||||
|
优化包 `models/user.py` 新增了:
|
||||||
|
- `UserGroup` — 用户组模型(表名与我们的关联表冲突)
|
||||||
|
- `UserGroupMember` — 用户组成员关联表
|
||||||
|
- `AuditLog` — 审计日志(与 `models/audit_log.py` 重复定义)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 逐文件分析
|
||||||
|
|
||||||
|
### 1. models/user.py — ⚠️ 需手动合并
|
||||||
|
|
||||||
|
**差异概要**:
|
||||||
|
- User 模型新增了 4 个 JSON 权限字段(data_source_permissions, custom_permissions, column_permissions, permissions_updated_at)
|
||||||
|
- User 模型新增了 5 个 relationship(tickets, approvals, audit_logs, query_histories, user_groups)
|
||||||
|
- User 模型新增了 5 个方法(has_permission, has_data_source_permission, get_allowed_columns, to_dict, __repr__)
|
||||||
|
- 新增 3 个类:UserGroup, UserGroupMember, AuditLog
|
||||||
|
|
||||||
|
**冲突点**:
|
||||||
|
- `secondary="user_groups"` — 与 instance_group.py 的 Table 定义冲突
|
||||||
|
- `user_groups = relationship("UserGroup", ...)` — 引用同文件 UserGroup
|
||||||
|
- `audit_logs = relationship("AuditLog", ...)` — 引用同文件 AuditLog
|
||||||
|
- `to_dict()` 中 `self.user_groups` — 引用 UserGroup relationship
|
||||||
|
|
||||||
|
**合并策略**:
|
||||||
|
```
|
||||||
|
✅ 直接采纳:User 模型的 JSON 权限字段(4个新字段)
|
||||||
|
✅ 直接采纳:User 的便捷方法(has_permission, to_dict 等)
|
||||||
|
⚠️ 需修改:User.groups 的 secondary 保持 "user_groups"(不改,与 instance_group.py 兼容)
|
||||||
|
❌ 不采纳:User.user_groups relationship(引用冲突的 UserGroup)
|
||||||
|
❌ 不采纳:User.tickets/approvals/audit_logs/query_histories(跨模型耦合,按需添加)
|
||||||
|
❌ 不采纳:UserGroup 类(表名冲突)
|
||||||
|
❌ 不采纳:UserGroupMember 类(依赖 UserGroup)
|
||||||
|
❌ 不采纳:AuditLog 类(已在 audit_log.py 独立定义)
|
||||||
|
⚠️ to_dict() 中的 user_groups 引用需要改为 groups 或移除
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. models/workflow.py — ✅ 可安全覆盖
|
||||||
|
|
||||||
|
**差异概要**:
|
||||||
|
- WorkflowDefinition 新增 category, updated_at, created_by, flow_config, trigger_conditions 字段
|
||||||
|
- 新增 WorkflowVersion 类(版本控制)
|
||||||
|
- WorkflowStep 大幅增强(description, approver_assignment, entry_conditions, transition_conditions, field_permissions)
|
||||||
|
- TicketApproval 大幅增强(workflow_id, version_id, step_type, actual_approver_id, transfer 信息, 超时标记)
|
||||||
|
- 新增 WorkflowExecutionLog 类(执行日志)
|
||||||
|
|
||||||
|
**新增依赖**:`json`, `UniqueConstraint`, `JSON` — 标准库/SQLAlchemy
|
||||||
|
|
||||||
|
**安全评估**:无冲突模型引用,纯功能增强。✅
|
||||||
|
|
||||||
|
### 3. services/workflow_engine.py — ✅ 可安全覆盖
|
||||||
|
|
||||||
|
**差异概要**:
|
||||||
|
- 从简单引擎升级为支持条件审批、超时策略、中断恢复的完整引擎
|
||||||
|
- 新增 WorkflowStatus/ApprovalAction/TimeoutStrategy 枚举
|
||||||
|
- 新增 ConditionResult/ApproverAssignment 数据类
|
||||||
|
- 引擎核心方法大幅扩展
|
||||||
|
|
||||||
|
**import 检查**:
|
||||||
|
- `from app.models.workflow import (WorkflowDefinition, WorkflowStep, WorkflowVersion, TicketApproval, WorkflowExecutionLog)` — 全部来自 workflow.py
|
||||||
|
- `from app.models.user import User` — 只用 User 基础字段
|
||||||
|
- 无冲突模型引用
|
||||||
|
|
||||||
|
### 4. services/query_service.py — ✅ 可安全覆盖
|
||||||
|
|
||||||
|
**差异概要**:查询服务增强,支持更多数据库类型和查询优化。
|
||||||
|
|
||||||
|
**import 检查**:
|
||||||
|
- `from app.models.instance import Instance` — ✅
|
||||||
|
- `from app.schemas.query import UnifiedQueryResult` — ✅
|
||||||
|
- `from app.services.db_connector import DBConnector` — ✅
|
||||||
|
- 无冲突模型引用
|
||||||
|
|
||||||
|
### 5. services/audit_engine.py — ✅ 可安全覆盖
|
||||||
|
|
||||||
|
**差异概要**:审计引擎增强,新增枚举和条件判断。
|
||||||
|
|
||||||
|
**import 检查**:
|
||||||
|
- 无外部模型导入,仅标准库 + typing
|
||||||
|
- 无冲突模型引用
|
||||||
|
|
||||||
|
### 6. services/ticket_executor.py — ✅ 可安全覆盖
|
||||||
|
|
||||||
|
**差异概要**:工单执行器增强,支持更多 SQL 解析和执行策略。
|
||||||
|
|
||||||
|
**import 检查**:
|
||||||
|
- `from app.models.instance import Instance` — ✅
|
||||||
|
- `from app.models.ticket import Ticket` — ✅
|
||||||
|
- `from app.services.sql_analyzer import SQLAnalyzer, SQLType` — ✅
|
||||||
|
- 无冲突模型引用
|
||||||
|
|
||||||
|
### 7. api/v1/endpoints/ticket.py — ✅ 可安全覆盖
|
||||||
|
|
||||||
|
**AuditLog 使用情况**:
|
||||||
|
- `from app.models.audit_log import AuditLog` — ✅ 使用我们的独立 AuditLog
|
||||||
|
- `AuditLog.target_type`, `AuditLog.target_id`, `AuditLog.created_at` — ✅ 我们模型有这些字段
|
||||||
|
- `AuditLogService.log(target_type=..., detail=...)` — ✅ audit_log_service.py 两版完全相同
|
||||||
|
|
||||||
|
**注意**:ticket.py 使用的 AuditLog 字段(target_type, target_id, detail, db_type, status)全部存在于我们的 `models/audit_log.py`。
|
||||||
|
|
||||||
|
### 8. api/v1/endpoints/query.py — ✅ 可安全覆盖(需注意字段)
|
||||||
|
|
||||||
|
**AuditLog 使用情况**:
|
||||||
|
- `from app.models.audit_log import AuditLog` — ✅ 使用我们的独立 AuditLog
|
||||||
|
- 直接创建 `AuditLog(...)` 实例,使用字段:
|
||||||
|
- ✅ 我们模型有:user_id, username, action, target_type, target_id, instance_name, db_type, database_name, query_content, execution_time_ms, rows_affected, result_status, detail
|
||||||
|
- ⚠️ **`query_type`** — 我们模型没有这个字段!
|
||||||
|
- ⚠️ **`status`** — 我们模型没有 `status`,只有 `result_status`
|
||||||
|
|
||||||
|
**需要的处理**:在我们的 `models/audit_log.py` 中新增 `query_type` 字段:
|
||||||
|
```python
|
||||||
|
query_type = Column(String(20), nullable=True) # 新增:查询类型
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. api/v1/endpoints/user.py — ✅ 可安全覆盖
|
||||||
|
|
||||||
|
**差异**:两版完全相同(0 差异),优化包版本多了 AuditLogService 引用但代码一致。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ 推荐执行顺序
|
||||||
|
|
||||||
|
### 第一阶段:无冲突文件直接覆盖(7个文件)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. models/workflow.py
|
||||||
|
# 2. services/workflow_engine.py
|
||||||
|
# 3. services/query_service.py
|
||||||
|
# 4. services/audit_engine.py
|
||||||
|
# 5. services/ticket_executor.py
|
||||||
|
# 6. api/v1/endpoints/ticket.py
|
||||||
|
# 7. api/v1/endpoints/user.py (两版相同,跳过)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第二阶段:小修复
|
||||||
|
|
||||||
|
在 `models/audit_log.py` 新增 `query_type` 字段:
|
||||||
|
```python
|
||||||
|
query_type = Column(String(20), nullable=True) # 查询类型(SELECT/INSERT/UPDATE/DELETE等)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第三阶段:手动合并 models/user.py
|
||||||
|
|
||||||
|
**保留我们现有结构**,只合并以下安全内容:
|
||||||
|
|
||||||
|
1. **新增 User 字段**(JSON 权限相关):
|
||||||
|
```python
|
||||||
|
data_source_permissions = Column(JSON, nullable=True, default=list)
|
||||||
|
custom_permissions = Column(JSON, nullable=True, default=dict)
|
||||||
|
column_permissions = Column(JSON, nullable=True, default=dict)
|
||||||
|
permissions_updated_at = Column(DateTime, nullable=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **新增 User 方法**:
|
||||||
|
```python
|
||||||
|
def has_permission(self, action: str) -> bool: ...
|
||||||
|
def has_data_source_permission(self, ...) -> bool: ...
|
||||||
|
def get_allowed_columns(self, ...) -> list: ...
|
||||||
|
def to_dict(self, include_sensitive=False) -> dict: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **不合并的内容**:
|
||||||
|
- `UserGroup` 类 → 如果需要用户组功能,必须改名或用不同表名
|
||||||
|
- `UserGroupMember` 类 → 同上
|
||||||
|
- `AuditLog` 类 → 已有独立文件,不重复定义
|
||||||
|
- `User.user_groups` relationship → 依赖冲突的 UserGroup
|
||||||
|
- `User.audit_logs` relationship → 需要评估是否添加(跨模型耦合)
|
||||||
|
- `User.tickets`/`approvals`/`query_histories` → 按需添加
|
||||||
|
|
||||||
|
4. **to_dict 方法调整**:
|
||||||
|
- 将 `self.user_groups` 改为 `self.groups`(匹配现有 InstanceGroup 关系)
|
||||||
|
|
||||||
|
### 第四阶段:数据库迁移
|
||||||
|
|
||||||
|
新增/修改的数据库表:
|
||||||
|
- `workflow_versions` — 新增表
|
||||||
|
- `workflow_execution_logs` — 新增表
|
||||||
|
- `workflow_definitions` — 新增 category, updated_at, created_by, flow_config, trigger_conditions 字段
|
||||||
|
- `workflow_steps` — 新增多个字段
|
||||||
|
- `ticket_approvals` — 新增多个字段
|
||||||
|
- `audit_logs` — 新增 query_type 字段
|
||||||
|
- `users` — 新增 4 个 JSON 权限字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 风险提示
|
||||||
|
|
||||||
|
1. **UserGroup/user_groups 冲突**:如果未来需要优化包的用户组功能,需要:
|
||||||
|
- 将优化包的 `UserGroup` 改表名为 `user_permission_groups`
|
||||||
|
- 或将我们的关联表 `user_groups` 改名为 `user_instance_groups`
|
||||||
|
- 同时更新所有外键和 relationship 引用
|
||||||
|
|
||||||
|
2. **AuditLog 双定义**:SQLAlchemy 的 metadata 中如果同时注册两个同名 `__tablename__` 的类会报错。只要不同时 import 就没问题。确保不 import `models/user.py` 中的 AuditLog 即可。
|
||||||
|
|
||||||
|
3. **workflow.py 的 relationship 指向 "User"**:通过字符串引用,SQLAlchemy 在运行时解析。只要所有模型在 metadata 中注册时 User 类存在即可。
|
||||||
Reference in new issue
Block a user