审批流修复: - 提交阶段(step_type=submit)自动approved,不再创建pending记录 - 审批权限添加auditor角色(require_role admin/dba/auditor) - 审批完成判断排除execute步骤(step_type!=execute才计入has_next) - review_ticket中加载workflow_steps用于step_type判断 - Ticket字段名修正: creator_id -> applicant_id 通知修复: - send()中自动补充sql_preview字段(sql_content前100字符) - 飞书/钉钉模板format改为format_map+安全fallback(缺失字段不报KeyError) - DB中auditor用户role从developer修正为auditor 测试: 1182 passed, 0 failed 审批流全链路验证通过: 提交->DBA审核->审计员审核->admin执行->success
1543 lines
53 KiB
Python
1543 lines
53 KiB
Python
"""工单管理 API — 支持多数据库的变更工单生命周期管理。
|
|
|
|
核心功能:
|
|
- 工单创建、审核、执行、取消、回滚
|
|
- 多级审批流支持
|
|
- 工单状态机管理(严格的状态转换)
|
|
- 自动 SQL 审核(创建时触发)
|
|
- 执行前二次确认
|
|
- 执行后通知触发
|
|
- 完整操作审计
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
from enum import Enum
|
|
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from sqlalchemy import select, func, or_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import get_db
|
|
from app.core.security import get_current_user, require_role
|
|
from app.models.user import User
|
|
from app.models.ticket import Ticket
|
|
from app.models.instance import Instance
|
|
from app.models.workflow import WorkflowDefinition, WorkflowStep, TicketApproval
|
|
from app.models.audit_log import AuditLog
|
|
from app.schemas.ticket import TicketCreate, TicketReview, TicketResponse
|
|
from app.core.crypto import decrypt_password
|
|
from app.core.permissions import check_instance_access, check_instance_permission
|
|
from app.services.audit_engine import SQLAuditEngine
|
|
from app.services.audit_engine_v2 import SQLAuditEngineV2
|
|
from app.services.audit_log_service import AuditLogService
|
|
from app.services.query_service import MAX_QUERY_LENGTH
|
|
import json
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _generate_ticket_no(ticket_id: int) -> str:
|
|
"""生成工单编号,格式: YYYYMMDDHHmmss + 微秒前3位 + 序号补齐。
|
|
|
|
示例: 20260522160543841
|
|
|
|
Args:
|
|
ticket_id: 工单自增ID,用作末尾序号
|
|
|
|
Returns:
|
|
工单编号字符串
|
|
"""
|
|
now = datetime.now()
|
|
base = now.strftime("%Y%m%d%H%M%S")
|
|
ms = f"{now.microsecond:06d}"[:3] # 取前3位毫秒
|
|
seq = str(ticket_id).zfill(3)[-3:] # ID 补齐到3位
|
|
return f"{base}{ms}{seq}"
|
|
|
|
|
|
def _classify_sql_type(sql: str) -> str:
|
|
"""根据单条 SQL 语句判断类型。"""
|
|
s = sql.strip().upper()
|
|
if s.startswith(("SELECT", "SHOW", "EXPLAIN", "DESC", "DESCRIBE")):
|
|
return "DQL"
|
|
if s.startswith(("INSERT", "UPDATE", "DELETE", "REPLACE")):
|
|
return "DML"
|
|
if s.startswith(("CREATE", "ALTER", "DROP", "RENAME", "TRUNCATE")):
|
|
return "DDL"
|
|
if s.startswith(("GRANT", "REVOKE")):
|
|
return "DCL"
|
|
if s.startswith(("SET", "GET", "HSET", "HMSET", "LPUSH", "RPUSH", "SADD", "ZADD", "DEL", "FLUSHDB", "FLUSHALL")):
|
|
return "SET"
|
|
return "OTHER"
|
|
|
|
|
|
async def _update_sql_items_from_report(db, ticket_id: int, report) -> None:
|
|
"""根据执行报告更新 SQL items 状态。"""
|
|
from app.models.ticket_sql_item import TicketSqlItem
|
|
|
|
result = await db.execute(
|
|
select(TicketSqlItem)
|
|
.where(TicketSqlItem.ticket_id == ticket_id)
|
|
.order_by(TicketSqlItem.seq)
|
|
)
|
|
items = result.scalars().all()
|
|
if not items:
|
|
return
|
|
|
|
if report.status == "success":
|
|
for item in items:
|
|
item.status = "success"
|
|
item.finished_at = datetime.now(timezone.utc)
|
|
else:
|
|
# 尝试从 raw_output 解析逐条结果
|
|
exec_errors = getattr(report, "errors", [])
|
|
error_stmt = ""
|
|
if exec_errors:
|
|
err_msg = str(exec_errors[0]) if exec_errors else ""
|
|
# 找到出错的语句
|
|
for item in items:
|
|
if item.status == "pending":
|
|
if not error_stmt:
|
|
item.status = "failed"
|
|
item.error_message = err_msg
|
|
item.finished_at = datetime.now(timezone.utc)
|
|
error_stmt = item.sql_text[:50]
|
|
else:
|
|
item.status = "skipped"
|
|
|
|
await db.flush()
|
|
|
|
|
|
async def _mark_sql_items_failed(db, ticket_id: int, error_msg: str) -> None:
|
|
"""标记所有 pending 的 SQL items 为 failed。"""
|
|
from app.models.ticket_sql_item import TicketSqlItem
|
|
|
|
result = await db.execute(
|
|
select(TicketSqlItem)
|
|
.where(
|
|
TicketSqlItem.ticket_id == ticket_id,
|
|
TicketSqlItem.status == "pending",
|
|
)
|
|
)
|
|
items = result.scalars().all()
|
|
for item in items:
|
|
item.status = "failed"
|
|
item.error_message = error_msg[:500]
|
|
item.finished_at = datetime.now(timezone.utc)
|
|
await db.flush()
|
|
|
|
|
|
async def _check_maintenance_window(db) -> Optional[str]:
|
|
"""检查当前是否在维护窗口内。
|
|
|
|
Returns:
|
|
None = 允许执行(无窗口限制或在窗口内)
|
|
str = 窗口描述(表示不在窗口内,不允许执行)
|
|
"""
|
|
from app.models.system_setting import SystemSetting
|
|
result = await db.execute(
|
|
select(SystemSetting).where(SystemSetting.key == "maintenance_window")
|
|
)
|
|
setting = result.scalar_one_or_none()
|
|
if not setting or not setting.value:
|
|
return None # 无配置,不限制
|
|
|
|
import json as _json
|
|
try:
|
|
window = _json.loads(setting.value) if isinstance(setting.value, str) else setting.value
|
|
except Exception:
|
|
return None
|
|
|
|
enabled = window.get("enabled", False)
|
|
if not enabled:
|
|
return None
|
|
|
|
# 格式: {"enabled": true, "start": "22:00", "end": "06:00", "days": ["mon","tue","wed","thu","fri"]}
|
|
start_time = window.get("start", "22:00")
|
|
end_time = window.get("end", "06:00")
|
|
days = window.get("days", []) # 空=每天
|
|
|
|
now = datetime.now()
|
|
current_day = now.strftime("%a").lower()[:3]
|
|
current_time = now.strftime("%H:%M")
|
|
|
|
# 检查星期
|
|
if days and current_day not in [d.lower()[:3] for d in days]:
|
|
return f"{start_time}-{end_time} ({'/'.join(days)})"
|
|
|
|
# 检查时间(支持跨午夜)
|
|
if start_time <= end_time:
|
|
if not (start_time <= current_time <= end_time):
|
|
return f"{start_time}-{end_time}"
|
|
else:
|
|
# 跨午夜: 22:00-06:00 意味着 22:00~24:00 或 00:00~06:00
|
|
if not (current_time >= start_time or current_time <= end_time):
|
|
return f"{start_time}-{end_time}(次日)"
|
|
|
|
return None # 在窗口内,允许执行
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from app.services.ticket_executor import ExecutionReport
|
|
|
|
router = APIRouter(prefix="/tickets", tags=["工单管理"])
|
|
|
|
|
|
# ===== 工单状态机 =====
|
|
|
|
class TicketStatus(str, Enum):
|
|
"""工单状态枚举。"""
|
|
PENDING = "pending" # 待审批
|
|
REVIEWING = "reviewing" # 审批中(多级审批)
|
|
APPROVED = "approved" # 已通过
|
|
SCHEDULED = "scheduled" # 定时执行中
|
|
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.SCHEDULED.value, TicketStatus.REJECTED.value],
|
|
TicketStatus.SCHEDULED.value: [TicketStatus.EXECUTING.value, TicketStatus.APPROVED.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]:
|
|
"""丰富工单数据,添加申请人/审批人/实例名称/当前审核人等信息。"""
|
|
data = {
|
|
"id": ticket.id,
|
|
"ticket_no": ticket.ticket_no,
|
|
"title": ticket.title,
|
|
"db_type": ticket.db_type,
|
|
"instance_id": ticket.instance_id,
|
|
"target_database": ticket.target_database,
|
|
"sql_content": ticket.sql_content,
|
|
"rollback_sql": ticket.rollback_sql,
|
|
"status": ticket.status,
|
|
"priority": ticket.priority,
|
|
"risk_level": getattr(ticket, "risk_level", "low"),
|
|
"workflow_id": getattr(ticket, "workflow_id", None),
|
|
"applicant_id": ticket.applicant_id,
|
|
"approver_id": ticket.approver_id,
|
|
"reason": ticket.reason,
|
|
"audit_result": ticket.audit_result,
|
|
"execute_result": ticket.execute_result,
|
|
"create_time": ticket.create_time,
|
|
"review_time": ticket.review_time,
|
|
"execute_time": ticket.execute_time,
|
|
}
|
|
|
|
# Resolve applicant name
|
|
if ticket.applicant_id:
|
|
r = await db.execute(select(User).where(User.id == ticket.applicant_id))
|
|
applicant = r.scalar_one_or_none()
|
|
data["applicant_name"] = applicant.display_name if applicant else "未知"
|
|
|
|
# Resolve approver name (最终审批人)
|
|
if ticket.approver_id:
|
|
r = await db.execute(select(User).where(User.id == ticket.approver_id))
|
|
approver = r.scalar_one_or_none()
|
|
data["approver_name"] = approver.display_name if approver else "未知"
|
|
|
|
# Resolve instance name
|
|
if ticket.instance_id:
|
|
r = await db.execute(select(Instance).where(Instance.id == ticket.instance_id))
|
|
inst = r.scalar_one_or_none()
|
|
data["instance_name"] = inst.name if inst else "未知"
|
|
|
|
# Resolve current approver (审批/执行阶段的当前待审核人)
|
|
data["current_approver_name"] = None
|
|
if ticket.status in ("reviewing", "approved", "executing"):
|
|
pending_approval = await db.execute(
|
|
select(TicketApproval)
|
|
.where(
|
|
TicketApproval.ticket_id == ticket.id,
|
|
TicketApproval.action == "pending",
|
|
)
|
|
.order_by(TicketApproval.step_number)
|
|
.limit(1)
|
|
)
|
|
pending = pending_approval.scalar_one_or_none()
|
|
if pending:
|
|
if pending.approver_id:
|
|
r = await db.execute(select(User).where(User.id == pending.approver_id))
|
|
approver_user = r.scalar_one_or_none()
|
|
data["current_approver_name"] = approver_user.display_name if approver_user else None
|
|
elif pending.approver_role:
|
|
data["current_approver_name"] = pending.approver_role
|
|
|
|
return data
|
|
|
|
|
|
async def _get_instance(db: AsyncSession, instance_id: int) -> Instance:
|
|
"""获取数据源实例,不存在则抛出 404。
|
|
|
|
Args:
|
|
db: 数据库会话
|
|
instance_id: 实例ID
|
|
|
|
Returns:
|
|
Instance 对象
|
|
|
|
Raises:
|
|
HTTPException: 实例不存在
|
|
"""
|
|
result = await db.execute(select(Instance).where(Instance.id == instance_id))
|
|
instance = result.scalar_one_or_none()
|
|
if not instance:
|
|
raise HTTPException(status_code=404, detail="数据源不存在")
|
|
return instance
|
|
|
|
|
|
async def _get_ticket(db: AsyncSession, ticket_id: int) -> Ticket:
|
|
"""获取工单,不存在则抛出 404。
|
|
|
|
Args:
|
|
db: 数据库会话
|
|
ticket_id: 工单ID
|
|
|
|
Returns:
|
|
Ticket 对象
|
|
|
|
Raises:
|
|
HTTPException: 工单不存在
|
|
"""
|
|
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
|
|
ticket = result.scalar_one_or_none()
|
|
if not ticket:
|
|
raise HTTPException(status_code=404, detail="工单不存在")
|
|
return ticket
|
|
|
|
|
|
def _validate_ticket_content(data: TicketCreate) -> None:
|
|
"""验证工单内容安全性。
|
|
|
|
Args:
|
|
data: 工单创建数据
|
|
|
|
Raises:
|
|
HTTPException: 验证失败
|
|
"""
|
|
# SQL 内容长度检查
|
|
if len(data.sql_content) > MAX_QUERY_LENGTH:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"SQL 内容过长({len(data.sql_content)} 字符),最大允许 {MAX_QUERY_LENGTH} 字符",
|
|
)
|
|
|
|
# 检测 null 字节
|
|
if "\x00" in data.sql_content:
|
|
raise HTTPException(status_code=400, detail="SQL 内容包含非法字符(null 字节)")
|
|
|
|
# 检测零宽字符(可能的注入攻击)
|
|
zero_width_chars = ["\u200b", "\u200c", "\u200d", "\ufeff"]
|
|
for char in zero_width_chars:
|
|
if char in data.sql_content:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="SQL 内容包含非法的零宽 Unicode 字符",
|
|
)
|
|
|
|
# 回滚 SQL 长度检查
|
|
if data.rollback_sql and len(data.rollback_sql) > MAX_QUERY_LENGTH:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"回滚 SQL 过长({len(data.rollback_sql)} 字符),最大允许 {MAX_QUERY_LENGTH} 字符",
|
|
)
|
|
|
|
|
|
# ===== API 端点 =====
|
|
|
|
|
|
@router.get("")
|
|
async def list_tickets(
|
|
page: int = Query(1, ge=1, description="页码"),
|
|
page_size: int = Query(10, ge=1, le=100, description="每页数量"),
|
|
status: Optional[str] = None,
|
|
db_type: Optional[str] = None,
|
|
keyword: Optional[str] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""获取工单列表。
|
|
|
|
- admin/dba 可查看所有工单
|
|
- developer 只能查看自己的工单
|
|
"""
|
|
stmt = select(Ticket)
|
|
|
|
# 权限过滤: developer 只能看到自己的工单
|
|
if current_user.role == "developer":
|
|
stmt = stmt.where(Ticket.applicant_id == current_user.id)
|
|
|
|
# 状态过滤
|
|
if status:
|
|
# 验证状态值合法性
|
|
valid_statuses = [s.value for s in TicketStatus]
|
|
if status not in valid_statuses:
|
|
raise HTTPException(status_code=400, detail=f"无效的状态值: {status}")
|
|
stmt = stmt.where(Ticket.status == status)
|
|
|
|
if db_type:
|
|
stmt = stmt.where(Ticket.db_type == db_type)
|
|
|
|
if keyword:
|
|
# 限制关键词长度防止 DoS
|
|
keyword = keyword[:100]
|
|
stmt = stmt.where(
|
|
or_(
|
|
Ticket.title.contains(keyword),
|
|
Ticket.sql_content.contains(keyword),
|
|
)
|
|
)
|
|
|
|
# 总数查询
|
|
total_stmt = select(func.count()).select_from(stmt.subquery())
|
|
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)
|
|
)
|
|
result = await db.execute(stmt)
|
|
tickets = result.scalars().all()
|
|
|
|
items = []
|
|
for t in tickets:
|
|
items.append(await _enrich_ticket(t, db))
|
|
|
|
return {
|
|
"total": total,
|
|
"page": page,
|
|
"page_size": page_size,
|
|
"items": items,
|
|
}
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
async def create_ticket(
|
|
data: TicketCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""创建工单。
|
|
|
|
流程:
|
|
1. 验证数据源存在和权限
|
|
2. 验证工单内容安全性
|
|
3. 自动 SQL 审核
|
|
4. 创建工单记录
|
|
5. 如有多级审批流,创建审批步骤
|
|
"""
|
|
# 1. 验证数据源
|
|
instance = await _get_instance(db, data.instance_id)
|
|
|
|
# 2. 权限检查
|
|
try:
|
|
await check_instance_access(current_user, instance, db)
|
|
except HTTPException:
|
|
raise HTTPException(status_code=403, detail="您没有权限对该数据源提交变更工单")
|
|
|
|
await check_instance_permission(current_user, instance, "ticket:create", db)
|
|
|
|
# 3. 验证工单内容安全性
|
|
_validate_ticket_content(data)
|
|
|
|
# 3.5 EXPLAIN 预检(大表检测合并到 risk_evaluator,不再单独查表大小)
|
|
table_sizes = {}
|
|
explain_warnings = []
|
|
try:
|
|
from app.services.explain_preview import run_explain_preview
|
|
import asyncio as _asyncio
|
|
explain_result = await _asyncio.wait_for(
|
|
run_explain_preview(data.sql_content, instance, data.target_database),
|
|
timeout=8.0,
|
|
)
|
|
explain_warnings = explain_result.warnings
|
|
table_sizes = explain_result.table_sizes or {}
|
|
except Exception as e:
|
|
import asyncio as _asyncio
|
|
logger.warning("EXPLAIN 预检失败(非致命): %s", e)
|
|
|
|
# 4. 自动审核 SQL(带超时保护)
|
|
env = "production" if instance.env == "production" else "development"
|
|
v2_engine = SQLAuditEngineV2(db_session=db, env=env)
|
|
try:
|
|
v2_report = await _asyncio.wait_for(
|
|
v2_engine.audit(data.sql_content, db_type=data.db_type, instance_id=data.instance_id),
|
|
timeout=10.0,
|
|
)
|
|
except _asyncio.TimeoutError:
|
|
logger.warning("SQL 审核超时(10s),跳过审核: ticket title=%s", data.title)
|
|
v2_report = None
|
|
except Exception as e:
|
|
logger.error("SQL 审核失败: %s", e)
|
|
v2_report = None
|
|
|
|
# 5. 计算风险等级
|
|
from app.services.risk_evaluator import evaluate_risk_level
|
|
audit_findings_raw = v2_report.to_dict().get("findings", []) if v2_report else []
|
|
import sqlparse as _sqlparse
|
|
stmt_list = [s.strip() for s in _sqlparse.split(data.sql_content) if s.strip()]
|
|
risk_level = evaluate_risk_level(
|
|
sql_content=data.sql_content,
|
|
db_type=data.db_type,
|
|
sql_type=data.sql_type or "",
|
|
audit_findings=audit_findings_raw,
|
|
statement_count=len(stmt_list),
|
|
table_sizes=table_sizes,
|
|
)
|
|
|
|
# EXPLAIN 结果可能升级风险等级
|
|
if explain_warnings and risk_level == "low":
|
|
risk_level = "medium"
|
|
|
|
# 6. 匹配审批流 — 优先按 instance.workflow_id,其次按 risk_level 匹配默认流
|
|
workflow = None
|
|
if instance.workflow_id:
|
|
wf_result = await db.execute(
|
|
select(WorkflowDefinition).where(
|
|
WorkflowDefinition.id == instance.workflow_id,
|
|
WorkflowDefinition.is_active == True,
|
|
)
|
|
)
|
|
workflow = wf_result.scalar_one_or_none()
|
|
if not workflow:
|
|
default_wf_result = await db.execute(
|
|
select(WorkflowDefinition).where(
|
|
WorkflowDefinition.is_default == True,
|
|
WorkflowDefinition.is_active == True,
|
|
)
|
|
)
|
|
workflow = default_wf_result.scalar_one_or_none()
|
|
|
|
initial_status = TicketStatus.REVIEWING.value if workflow else TicketStatus.PENDING.value
|
|
|
|
# 7. 创建工单
|
|
ticket = Ticket(
|
|
title=data.title,
|
|
db_type=data.db_type,
|
|
instance_id=data.instance_id,
|
|
target_database=data.target_database,
|
|
sql_content=data.sql_content,
|
|
rollback_sql=data.rollback_sql,
|
|
reason=data.reason,
|
|
priority=data.priority,
|
|
risk_level=risk_level,
|
|
workflow_id=workflow.id if workflow else None,
|
|
exec_strategy=data.exec_strategy or "stop_on_error",
|
|
applicant_id=current_user.id,
|
|
status=initial_status,
|
|
audit_result=json.dumps(v2_report.to_dict(), ensure_ascii=False) if v2_report and v2_report.findings else None,
|
|
scheduled_at=data.scheduled_at,
|
|
)
|
|
db.add(ticket)
|
|
await db.flush() # flush to get ticket.id
|
|
|
|
# 生成工单编号
|
|
ticket.ticket_no = _generate_ticket_no(ticket.id)
|
|
await db.flush()
|
|
|
|
# 7. 创建审批步骤(多级审批流)
|
|
if workflow:
|
|
wf_steps_result = await db.execute(
|
|
select(WorkflowStep)
|
|
.where(WorkflowStep.workflow_id == workflow.id)
|
|
.order_by(WorkflowStep.step_number)
|
|
)
|
|
wf_steps = wf_steps_result.scalars().all()
|
|
for step in wf_steps:
|
|
if step.step_type == "submit":
|
|
# 提交阶段自动通过(提交者自己就是审批人)
|
|
approval = TicketApproval(
|
|
ticket_id=ticket.id,
|
|
step_number=step.step_number,
|
|
step_name=step.name,
|
|
approver_role=step.approver_role,
|
|
approver_id=ticket.applicant_id,
|
|
actual_approver_id=ticket.applicant_id,
|
|
action="approved",
|
|
comment="提交工单",
|
|
)
|
|
elif step.step_type == "execute":
|
|
# 执行阶段不创建审批记录,等所有审核通过后直接执行
|
|
approval = TicketApproval(
|
|
ticket_id=ticket.id,
|
|
step_number=step.step_number,
|
|
step_name=step.name,
|
|
approver_role=step.approver_role,
|
|
action="pending",
|
|
)
|
|
else:
|
|
approval = TicketApproval(
|
|
ticket_id=ticket.id,
|
|
step_number=step.step_number,
|
|
step_name=step.name,
|
|
approver_role=step.approver_role,
|
|
action="pending",
|
|
)
|
|
db.add(approval)
|
|
await db.flush()
|
|
|
|
# 8. 创建 SQL 逐条执行记录
|
|
from app.models.ticket_sql_item import TicketSqlItem
|
|
for i, stmt in enumerate(stmt_list, start=1):
|
|
stmt_type = _classify_sql_type(stmt)
|
|
sql_item = TicketSqlItem(
|
|
ticket_id=ticket.id,
|
|
seq=i,
|
|
sql_text=stmt,
|
|
sql_type=stmt_type,
|
|
status="pending",
|
|
)
|
|
db.add(sql_item)
|
|
await db.flush()
|
|
|
|
# 9. 记录审计日志
|
|
audit_detail = ""
|
|
if v2_report:
|
|
audit_detail = f"SQL审核评分: {v2_report.score}, 通过: {v2_report.passed}, 规则数: {len(v2_report.findings)}"
|
|
await AuditLogService.log(
|
|
db, user_id=current_user.id, action="CREATE_TICKET",
|
|
target_type="ticket", target_id=ticket.id,
|
|
detail=audit_detail or "工单创建",
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(ticket)
|
|
|
|
# 9. 发送创建通知
|
|
try:
|
|
await _send_ticket_notification(db, ticket, "ticket_created")
|
|
except Exception as notify_err:
|
|
logger.error("创建通知发送失败: %s", notify_err)
|
|
|
|
result = await _enrich_ticket(ticket, db)
|
|
if v2_report:
|
|
result["audit_report"] = v2_report.to_dict()
|
|
return result
|
|
|
|
|
|
@router.get("/statistics")
|
|
async def ticket_statistics(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""获取工单统计信息。"""
|
|
stmt = select(Ticket.status, func.count(Ticket.id)).group_by(Ticket.status)
|
|
result = await db.execute(stmt)
|
|
stats = {row[0]: row[1] for row in result.all()}
|
|
|
|
total = sum(stats.values())
|
|
return {
|
|
"total": total,
|
|
"pending": stats.get("pending", 0),
|
|
"reviewing": stats.get("reviewing", 0),
|
|
"approved": stats.get("approved", 0),
|
|
"rejected": stats.get("rejected", 0),
|
|
"executing": stats.get("executing", 0),
|
|
"success": stats.get("success", 0),
|
|
"failed": stats.get("failed", 0),
|
|
"rollback": stats.get("rollback", 0),
|
|
}
|
|
|
|
|
|
@router.post("/audit")
|
|
async def audit_sql(
|
|
data: AuditRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""SQL 审核接口(独立调用)。"""
|
|
# 验证实例存在
|
|
instance = await _get_instance(db, data.instance_id)
|
|
|
|
env = "production" if instance.env == "production" else "development"
|
|
db_type = instance.db_type or "mysql"
|
|
|
|
# 使用 V2 引擎进行审核
|
|
v2_engine = SQLAuditEngineV2(db_session=db, env=env)
|
|
try:
|
|
report = await v2_engine.audit(data.sql_content, db_type=db_type)
|
|
except Exception as e:
|
|
logger.error("SQL 审核异常: %s", e)
|
|
raise HTTPException(status_code=500, detail=f"审核引擎异常: {e}")
|
|
|
|
return {
|
|
"score": report.score,
|
|
"passed": report.passed,
|
|
"has_warnings": report.has_warnings,
|
|
"risk_level": getattr(report, 'risk_level', 'unknown'),
|
|
"summary": getattr(report, 'summary', ''),
|
|
"findings": [
|
|
{
|
|
"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
|
|
],
|
|
}
|
|
|
|
|
|
@router.get("/{ticket_id}")
|
|
async def get_ticket(
|
|
ticket_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""获取工单详情。"""
|
|
ticket = await _get_ticket(db, ticket_id)
|
|
|
|
# 权限检查: developer 只能查看自己的工单
|
|
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="无权查看该工单")
|
|
|
|
return await _enrich_ticket(ticket, db)
|
|
|
|
|
|
@router.get("/{ticket_id}/approvals")
|
|
async def get_ticket_approvals(
|
|
ticket_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""获取工单的审批进度(多级审批流)。"""
|
|
ticket = await _get_ticket(db, ticket_id)
|
|
|
|
# 权限检查
|
|
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="无权查看该工单")
|
|
|
|
approvals_result = await db.execute(
|
|
select(TicketApproval)
|
|
.where(TicketApproval.ticket_id == ticket_id)
|
|
.order_by(TicketApproval.step_number)
|
|
)
|
|
approvals = approvals_result.scalars().all()
|
|
|
|
return [
|
|
{
|
|
"id": a.id,
|
|
"step_number": a.step_number,
|
|
"step_name": a.step_name,
|
|
"approver_role": a.approver_role,
|
|
"action": a.action,
|
|
"approver_id": a.approver_id,
|
|
"comment": a.comment,
|
|
"created_at": a.created_at.isoformat() if a.created_at else None,
|
|
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
|
|
}
|
|
for a in approvals
|
|
]
|
|
|
|
|
|
@router.post("/{ticket_id}/review")
|
|
async def review_ticket(
|
|
ticket_id: int,
|
|
data: TicketReview,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin", "dba", "auditor")),
|
|
):
|
|
"""审核工单。
|
|
|
|
支持多级审批流:
|
|
- 通过: 按顺序审批,所有步骤通过后状态变为 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":
|
|
# 检查多级审批流
|
|
pending_approvals_result = await db.execute(
|
|
select(TicketApproval)
|
|
.where(TicketApproval.ticket_id == ticket_id)
|
|
.order_by(TicketApproval.step_number)
|
|
)
|
|
pending_approvals = pending_approvals_result.scalars().all()
|
|
|
|
if pending_approvals:
|
|
# 加载审批流步骤定义(用于判断 step_type)
|
|
wf_steps = []
|
|
if ticket.workflow_id:
|
|
ws_result = await db.execute(
|
|
select(WorkflowStep)
|
|
.where(WorkflowStep.workflow_id == ticket.workflow_id)
|
|
.order_by(WorkflowStep.step_number)
|
|
)
|
|
wf_steps = ws_result.scalars().all()
|
|
|
|
# 多级审批: 找到当前待审批步骤
|
|
current_step = None
|
|
for pa in pending_approvals:
|
|
if pa.action == "pending":
|
|
current_step = pa
|
|
break
|
|
|
|
if current_step:
|
|
# 审批当前步骤
|
|
current_step.action = "approved"
|
|
current_step.approver_id = current_user.id
|
|
current_step.comment = data.comment or "审批通过"
|
|
|
|
# 检查是否还有后续待审批步骤(执行阶段不算,它只需要状态为approved即可执行)
|
|
has_next = False
|
|
for pa in pending_approvals:
|
|
if pa.id != current_step.id and pa.action == "pending":
|
|
# 查对应 workflow step 的 step_type
|
|
ws = next((s for s in wf_steps if s.step_number == pa.step_number), None)
|
|
if ws and ws.step_type != "execute":
|
|
has_next = True
|
|
break
|
|
if has_next:
|
|
new_status = TicketStatus.REVIEWING.value
|
|
else:
|
|
new_status = TicketStatus.APPROVED.value
|
|
else:
|
|
# 所有步骤已处理
|
|
new_status = TicketStatus.APPROVED.value
|
|
else:
|
|
# 无审批流 - 单步审批
|
|
new_status = TicketStatus.APPROVED.value
|
|
else:
|
|
# 拒绝: 标记所有待审批步骤为拒绝
|
|
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 = "rejected"
|
|
pa.approver_id = current_user.id
|
|
pa.comment = data.comment or "审批拒绝"
|
|
|
|
new_status = TicketStatus.REJECTED.value
|
|
|
|
# 验证状态转换合法性
|
|
if not _validate_status_transition(ticket.status, new_status):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"非法的状态转换: {ticket.status} -> {new_status}"
|
|
)
|
|
|
|
ticket.status = new_status
|
|
ticket.approver_id = current_user.id
|
|
ticket.review_time = datetime.now(timezone.utc)
|
|
ticket.audit_result = data.comment
|
|
|
|
await AuditLogService.log(
|
|
db, user_id=current_user.id, action="REVIEW_TICKET",
|
|
target_type="ticket", target_id=ticket.id,
|
|
detail=f"审核动作: {data.action}, 备注: {data.comment}, 新状态: {new_status}",
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(ticket)
|
|
|
|
# 发送审批通知(仅审批通过时通知,拒绝不通知)
|
|
if data.action == "approve":
|
|
try:
|
|
await _send_ticket_notification(db, ticket, "ticket_approved", {
|
|
"approver": current_user.display_name,
|
|
"comment": data.comment or "审批通过",
|
|
})
|
|
except Exception as notify_err:
|
|
logger.error("审批通知发送失败: %s", notify_err)
|
|
|
|
return await _enrich_ticket(ticket, db)
|
|
|
|
|
|
@router.get("/{ticket_id}/timeline")
|
|
async def get_ticket_timeline(
|
|
ticket_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""获取工单时间线。"""
|
|
ticket = await _get_ticket(db, ticket_id)
|
|
|
|
if current_user.role == "developer" and ticket.applicant_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="无权查看该工单")
|
|
|
|
# 获取审计日志
|
|
log_stmt = (
|
|
select(AuditLog)
|
|
.where(AuditLog.target_type == "ticket", AuditLog.target_id == ticket_id)
|
|
.order_by(AuditLog.created_at.asc())
|
|
)
|
|
log_result = await db.execute(log_stmt)
|
|
logs = log_result.scalars().all()
|
|
|
|
timeline = []
|
|
|
|
# 创建事件
|
|
applicant_name = "未知"
|
|
if ticket.applicant_id:
|
|
r = await db.execute(select(User).where(User.id == ticket.applicant_id))
|
|
applicant = r.scalar_one_or_none()
|
|
if applicant:
|
|
applicant_name = applicant.display_name
|
|
|
|
timeline.append({
|
|
"action": "CREATE_TICKET",
|
|
"label": "创建工单",
|
|
"user": applicant_name,
|
|
"time": ticket.create_time.isoformat() if ticket.create_time else None,
|
|
"detail": "提交 SQL 变更工单",
|
|
})
|
|
|
|
# 审计日志事件
|
|
for log in logs:
|
|
if log.action == "CREATE_TICKET":
|
|
continue
|
|
r = await db.execute(select(User).where(User.id == log.user_id))
|
|
u = r.scalar_one_or_none()
|
|
uname = u.display_name if u else "未知"
|
|
|
|
label_map = {
|
|
"REVIEW_TICKET": "审核工单",
|
|
"EXECUTE_TICKET": "执行工单",
|
|
"CANCEL_TICKET": "取消工单",
|
|
"ROLLBACK_TICKET": "回滚工单",
|
|
}
|
|
timeline.append({
|
|
"action": log.action,
|
|
"label": label_map.get(log.action, log.action),
|
|
"user": uname,
|
|
"time": log.created_at.isoformat() if log.created_at else None,
|
|
"detail": log.detail,
|
|
})
|
|
|
|
# 基于状态的合成事件
|
|
if ticket.review_time:
|
|
has_review = any(t["action"] == "REVIEW_TICKET" for t in timeline)
|
|
if not has_review:
|
|
approver_name = "未知"
|
|
if ticket.approver_id:
|
|
r = await db.execute(select(User).where(User.id == ticket.approver_id))
|
|
a = r.scalar_one_or_none()
|
|
if a:
|
|
approver_name = a.display_name
|
|
timeline.append({
|
|
"action": "REVIEW_TICKET",
|
|
"label": "审核工单",
|
|
"user": approver_name,
|
|
"time": ticket.review_time.isoformat() if ticket.review_time else None,
|
|
"detail": "工单状态变更为 " + str(ticket.status),
|
|
})
|
|
|
|
if ticket.execute_time:
|
|
has_exec = any(t["action"] == "EXECUTE_TICKET" for t in timeline)
|
|
if not has_exec:
|
|
timeline.append({
|
|
"action": "EXECUTE_TICKET",
|
|
"label": "执行工单",
|
|
"user": "系统",
|
|
"time": ticket.execute_time.isoformat() if ticket.execute_time else None,
|
|
"detail": ticket.execute_result or "执行完成",
|
|
})
|
|
|
|
return {"ticket_id": ticket_id, "timeline": timeline}
|
|
|
|
|
|
@router.get("/{ticket_id}/sql-items")
|
|
async def get_ticket_sql_items(
|
|
ticket_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""获取工单 SQL 逐条执行进度。"""
|
|
from app.models.ticket_sql_item import TicketSqlItem
|
|
|
|
result = await db.execute(
|
|
select(TicketSqlItem)
|
|
.where(TicketSqlItem.ticket_id == ticket_id)
|
|
.order_by(TicketSqlItem.seq)
|
|
)
|
|
items = result.scalars().all()
|
|
|
|
return {
|
|
"ticket_id": ticket_id,
|
|
"total": len(items),
|
|
"items": [
|
|
{
|
|
"id": item.id,
|
|
"seq": item.seq,
|
|
"sql_text": item.sql_text,
|
|
"sql_type": item.sql_type,
|
|
"status": item.status,
|
|
"affected_rows": item.affected_rows,
|
|
"duration_ms": item.duration_ms,
|
|
"error_message": item.error_message,
|
|
"started_at": item.started_at.isoformat() if item.started_at else None,
|
|
"finished_at": item.finished_at.isoformat() if item.finished_at else None,
|
|
}
|
|
for item in items
|
|
],
|
|
"progress": {
|
|
"total": len(items),
|
|
"success": sum(1 for i in items if i.status == "success"),
|
|
"failed": sum(1 for i in items if i.status == "failed"),
|
|
"running": sum(1 for i in items if i.status == "running"),
|
|
"pending": sum(1 for i in items if i.status == "pending"),
|
|
"skipped": sum(1 for i in items if i.status == "skipped"),
|
|
},
|
|
}
|
|
|
|
|
|
@router.post("/{ticket_id}/cancel")
|
|
async def cancel_ticket(
|
|
ticket_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""取消工单。
|
|
|
|
只有申请人或管理员可以取消 pending/reviewing 状态的工单。
|
|
"""
|
|
ticket = await _get_ticket(db, ticket_id)
|
|
|
|
# 权限检查
|
|
if ticket.applicant_id != current_user.id and current_user.role != "admin":
|
|
raise HTTPException(status_code=403, detail="只有申请人或管理员可以取消工单")
|
|
|
|
# 状态检查
|
|
if ticket.status not in (TicketStatus.PENDING.value, TicketStatus.REVIEWING.value):
|
|
raise HTTPException(status_code=400, detail="只能取消待审批或审批中的工单")
|
|
|
|
# 状态转换
|
|
new_status = TicketStatus.REJECTED.value
|
|
if not _validate_status_transition(ticket.status, new_status):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"非法的状态转换: {ticket.status} -> {new_status}"
|
|
)
|
|
|
|
ticket.status = new_status
|
|
ticket.audit_result = "工单被取消"
|
|
|
|
# 更新审批步骤状态
|
|
pending_approvals_result = await db.execute(
|
|
select(TicketApproval)
|
|
.where(TicketApproval.ticket_id == ticket_id, TicketApproval.action == "pending")
|
|
)
|
|
for pa in pending_approvals_result.scalars().all():
|
|
pa.action = "cancelled"
|
|
pa.comment = "工单被取消"
|
|
|
|
await AuditLogService.log(
|
|
db, user_id=current_user.id, action="CANCEL_TICKET",
|
|
target_type="ticket", target_id=ticket.id,
|
|
detail="取消工单",
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(ticket)
|
|
return await _enrich_ticket(ticket, db)
|
|
|
|
|
|
@router.post("/{ticket_id}/rollback")
|
|
async def rollback_ticket(
|
|
ticket_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin", "dba")),
|
|
):
|
|
"""回滚工单。
|
|
|
|
只能回滚 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="只能回滚已执行(成功或失败)的工单")
|
|
|
|
# 状态转换
|
|
new_status = TicketStatus.ROLLBACK.value
|
|
if not _validate_status_transition(ticket.status, new_status):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"非法的状态转换: {ticket.status} -> {new_status}"
|
|
)
|
|
|
|
rollback_sql = ticket.rollback_sql or "未提供回滚 SQL"
|
|
|
|
await AuditLogService.log(
|
|
db, user_id=current_user.id, action="ROLLBACK_TICKET",
|
|
target_type="ticket", target_id=ticket.id,
|
|
detail=f"请求回滚工单,回滚SQL: {rollback_sql[:200]}",
|
|
)
|
|
|
|
ticket.status = new_status
|
|
await db.commit()
|
|
await db.refresh(ticket)
|
|
|
|
return {
|
|
"message": "回滚请求已记录,请手动执行以下回滚 SQL",
|
|
"ticket_id": ticket_id,
|
|
"rollback_sql": rollback_sql,
|
|
"status": ticket.status,
|
|
}
|
|
|
|
|
|
@router.post("/{ticket_id}/execute")
|
|
async def execute_ticket(
|
|
ticket_id: int,
|
|
confirm: Optional[TicketExecuteConfirm] = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin", "dba")),
|
|
):
|
|
"""执行工单。
|
|
|
|
使用多数据库工单执行引擎,按 db_type 自动分发到对应执行器。
|
|
|
|
执行流程:
|
|
1. 验证工单状态为 approved
|
|
2. 二次确认(可选)
|
|
3. 更新状态为 executing
|
|
4. 执行变更
|
|
5. 更新结果和状态
|
|
6. 记录审计日志
|
|
7. 发送通知
|
|
|
|
Args:
|
|
ticket_id: 工单 ID
|
|
confirm: 二次确认参数
|
|
db: 数据库会话
|
|
current_user: 当前用户(需要 admin 或 dba 角色)
|
|
|
|
Returns:
|
|
执行后的工单详情,包含执行报告
|
|
"""
|
|
# 1. 查询工单
|
|
ticket = await _get_ticket(db, ticket_id)
|
|
|
|
# 2. 状态检查
|
|
if ticket.status != TicketStatus.APPROVED.value:
|
|
raise HTTPException(status_code=400, detail="只能执行已审批的工单")
|
|
|
|
# 3. 二次确认检查
|
|
if confirm and not confirm.confirmed:
|
|
raise HTTPException(status_code=400, detail="未确认执行,操作已取消")
|
|
|
|
# 3.1 高危操作强制二次确认
|
|
risk_level = getattr(ticket, "risk_level", "low")
|
|
if risk_level in ("high", "critical"):
|
|
if not confirm or not confirm.confirmed:
|
|
from app.services.risk_evaluator import RISK_LEVEL_CONFIG
|
|
risk_cfg = RISK_LEVEL_CONFIG.get(risk_level, {})
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=f"该工单风险等级为「{risk_cfg.get('label', risk_level)}」,需要二次确认后才能执行。请传入 confirmed=true",
|
|
)
|
|
|
|
# 4. 查询数据源实例
|
|
instance = await _get_instance(db, ticket.instance_id)
|
|
|
|
# 5. 权限检查
|
|
await check_instance_permission(current_user, instance, "ticket:execute", db)
|
|
|
|
# 5.1 维护窗口检查 — 非定时工单检查是否在维护窗口内
|
|
if not ticket.scheduled_at:
|
|
maintenance_window = await _check_maintenance_window(db)
|
|
if maintenance_window:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail=f"当前不在维护窗口内,允许执行时间: {maintenance_window}",
|
|
)
|
|
|
|
# 6. 定时执行:如果 scheduled_at 在未来,改为 scheduled 状态
|
|
if ticket.scheduled_at:
|
|
now = datetime.now(timezone.utc)
|
|
scheduled_aware = ticket.scheduled_at.replace(tzinfo=timezone.utc) if ticket.scheduled_at.tzinfo is None else ticket.scheduled_at
|
|
if scheduled_aware > now:
|
|
if not _validate_status_transition(ticket.status, TicketStatus.SCHEDULED.value):
|
|
raise HTTPException(status_code=400, detail=f"非法的状态转换: {ticket.status} -> scheduled")
|
|
ticket.status = TicketStatus.SCHEDULED.value
|
|
await db.commit()
|
|
await db.refresh(ticket)
|
|
|
|
# 启动后台定时执行
|
|
from app.services.ticket_scheduler import schedule_ticket_execution
|
|
await schedule_ticket_execution(ticket.id, scheduled_aware)
|
|
|
|
ticket_data = await _enrich_ticket(ticket, db)
|
|
return ticket_data
|
|
|
|
# 7. 状态转换: approved -> executing
|
|
if not _validate_status_transition(ticket.status, TicketStatus.EXECUTING.value):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"非法的状态转换: {ticket.status} -> {TicketStatus.EXECUTING.value}"
|
|
)
|
|
|
|
ticket.status = TicketStatus.EXECUTING.value
|
|
await db.commit()
|
|
await db.refresh(ticket)
|
|
|
|
# 8. 使用多数据库执行引擎
|
|
from app.services.ticket_executor import TicketExecutor
|
|
executor = TicketExecutor()
|
|
|
|
try:
|
|
# 解密密码
|
|
instance.password = decrypt_password(instance.password)
|
|
|
|
# 执行工单
|
|
report = await executor.execute(ticket, instance)
|
|
|
|
# 8. 更新工单状态和结果
|
|
if report.status == "success":
|
|
new_status = TicketStatus.SUCCESS.value
|
|
else:
|
|
new_status = TicketStatus.FAILED.value
|
|
|
|
# 验证状态转换
|
|
if _validate_status_transition(TicketStatus.EXECUTING.value, new_status):
|
|
ticket.status = new_status
|
|
else:
|
|
ticket.status = TicketStatus.FAILED.value
|
|
|
|
ticket.execute_result = report.to_json()
|
|
ticket.execute_time = datetime.now(timezone.utc)
|
|
|
|
# 8.1 更新 SQL 逐条执行记录
|
|
await _update_sql_items_from_report(db, ticket.id, report)
|
|
|
|
# 9. 记录执行日志
|
|
log_detail = (
|
|
f"执行结果: {report.status}, 影响行数: {report.affected_rows}, "
|
|
f"耗时: {report.duration_ms}ms, 验证通过: {report.validation_passed}"
|
|
)
|
|
if report.errors:
|
|
log_detail += f", 错误: {'; '.join(report.errors[:3])}"
|
|
if confirm and confirm.comment:
|
|
log_detail += f", 执行备注: {confirm.comment}"
|
|
|
|
await AuditLogService.log(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="EXECUTE_TICKET",
|
|
target_type="ticket",
|
|
target_id=ticket.id,
|
|
detail=log_detail,
|
|
)
|
|
|
|
# 10. 发送通知
|
|
try:
|
|
await _send_execution_notification(db, ticket, report)
|
|
except Exception as notify_err:
|
|
logger.error("执行通知发送失败: %s", notify_err)
|
|
# 通知失败不应影响执行结果
|
|
|
|
except Exception as e:
|
|
logger.exception("工单 %d 执行异常", ticket_id)
|
|
ticket.status = TicketStatus.FAILED.value
|
|
ticket.execute_result = json.dumps({
|
|
"status": "failed",
|
|
"errors": [f"{type(e).__name__}: {e}"],
|
|
}, ensure_ascii=False)
|
|
ticket.execute_time = datetime.now(timezone.utc)
|
|
|
|
# 标记所有 pending SQL items 为 failed
|
|
await _mark_sql_items_failed(db, ticket.id, str(e))
|
|
|
|
await AuditLogService.log(
|
|
db,
|
|
user_id=current_user.id,
|
|
action="EXECUTE_TICKET",
|
|
target_type="ticket",
|
|
target_id=ticket.id,
|
|
detail=f"执行异常: {type(e).__name__}: {e}",
|
|
)
|
|
|
|
# 11. 提交事务并返回
|
|
await db.commit()
|
|
await db.refresh(ticket)
|
|
|
|
# 构建响应
|
|
ticket_data = await _enrich_ticket(ticket, db)
|
|
|
|
# 尝试解析执行报告
|
|
try:
|
|
exec_report = json.loads(ticket.execute_result) if ticket.execute_result else {}
|
|
if isinstance(exec_report, dict):
|
|
from app.schemas.execution import ExecutionReportResponse
|
|
ticket_data["execution_report"] = ExecutionReportResponse(
|
|
status=exec_report.get("status", "unknown"),
|
|
affected_rows=exec_report.get("affected_rows", 0),
|
|
duration_ms=exec_report.get("duration_ms", 0),
|
|
validation_passed=exec_report.get("validation_passed", False),
|
|
warnings=exec_report.get("warnings", []),
|
|
errors=exec_report.get("errors", []),
|
|
raw_output=exec_report.get("raw_output"),
|
|
before_snapshot_summary=exec_report.get("before_snapshot"),
|
|
after_snapshot_summary=exec_report.get("after_snapshot"),
|
|
)
|
|
except Exception:
|
|
ticket_data["execution_report"] = None
|
|
|
|
return ticket_data
|
|
|
|
|
|
async def _get_user_name(db: AsyncSession, user_id: Optional[int]) -> str:
|
|
"""获取用户显示名称。"""
|
|
if not user_id:
|
|
return "系统"
|
|
result = await db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
return user.display_name if user else "未知"
|
|
|
|
|
|
async def _get_instance_name(db: AsyncSession, instance_id: Optional[int]) -> str:
|
|
"""获取实例名称。"""
|
|
if not instance_id:
|
|
return "未知实例"
|
|
result = await db.execute(select(Instance).where(Instance.id == instance_id))
|
|
inst = result.scalar_one_or_none()
|
|
return inst.name if inst else "未知实例"
|
|
|
|
|
|
async def _get_enabled_channels(db: AsyncSession) -> list:
|
|
"""获取已启用的通知渠道。"""
|
|
from app.models.notification import NotificationChannel
|
|
result = await db.execute(
|
|
select(NotificationChannel).where(NotificationChannel.is_enabled == True)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
async def _send_ticket_notification(
|
|
db: AsyncSession, ticket: Ticket, event_type: str,
|
|
extra_data: Optional[Dict[str, Any]] = None,
|
|
) -> None:
|
|
"""通用工单通知发送。
|
|
|
|
Args:
|
|
db: 数据库会话
|
|
ticket: 工单对象
|
|
event_type: 事件类型 (ticket_created / ticket_approved / ticket_rejected / ticket_executed / ticket_failed)
|
|
extra_data: 额外模板数据 (如 approver, operator, error 等)
|
|
"""
|
|
from app.services.notification_sender import NotificationSender
|
|
|
|
channels = await _get_enabled_channels(db)
|
|
if not channels:
|
|
return
|
|
|
|
# 获取关联信息
|
|
applicant_name = await _get_user_name(db, ticket.applicant_id)
|
|
instance_name = await _get_instance_name(db, ticket.instance_id)
|
|
sql_preview = ticket.sql_content[:100] + "..." if len(ticket.sql_content) > 100 else ticket.sql_content
|
|
|
|
# Redis database 格式化: "0" → "db0"
|
|
target_db = ticket.target_database or ""
|
|
if ticket.db_type == "redis" and target_db.isdigit():
|
|
target_db = f"db{target_db}"
|
|
|
|
notification_data = {
|
|
"title": ticket.title,
|
|
"ticket_id": ticket.id,
|
|
"ticket_no": ticket.ticket_no or str(ticket.id),
|
|
"db_type": ticket.db_type,
|
|
"target_database": target_db,
|
|
"instance_name": instance_name,
|
|
"sql_preview": sql_preview,
|
|
"applicant": applicant_name,
|
|
"approver": "未知",
|
|
"operator": "系统",
|
|
"comment": "",
|
|
"result": "",
|
|
"duration": 0,
|
|
"affected_rows": 0,
|
|
"error": "",
|
|
}
|
|
|
|
# 合并额外数据
|
|
if extra_data:
|
|
notification_data.update(extra_data)
|
|
|
|
sender = NotificationSender()
|
|
await sender.notify_ticket_event(
|
|
ticket_id=ticket.id,
|
|
event_type=event_type,
|
|
channels=channels,
|
|
ticket_data=notification_data,
|
|
)
|
|
|
|
|
|
async def _send_execution_notification(
|
|
db: AsyncSession, ticket: Ticket, report: "ExecutionReport"
|
|
) -> None:
|
|
"""发送工单执行完成通知(兼容旧调用)。"""
|
|
from app.services.notification_sender import NotificationSender
|
|
|
|
channels = await _get_enabled_channels(db)
|
|
if not channels:
|
|
return
|
|
|
|
event_type = "ticket_executed" if report.status == "success" else "ticket_failed"
|
|
operator_name = await _get_user_name(db, ticket.approver_id)
|
|
instance_name = await _get_instance_name(db, ticket.instance_id)
|
|
applicant_name = await _get_user_name(db, ticket.applicant_id)
|
|
sql_preview = ticket.sql_content[:100] + "..." if len(ticket.sql_content) > 100 else ticket.sql_content
|
|
|
|
# Redis database 格式化: "0" → "db0"
|
|
target_db = ticket.target_database or ""
|
|
if ticket.db_type == "redis" and target_db.isdigit():
|
|
target_db = f"db{target_db}"
|
|
|
|
notification_data = {
|
|
"title": ticket.title,
|
|
"ticket_id": ticket.id,
|
|
"ticket_no": ticket.ticket_no or str(ticket.id),
|
|
"db_type": ticket.db_type,
|
|
"target_database": target_db,
|
|
"instance_name": instance_name,
|
|
"sql_preview": sql_preview,
|
|
"applicant": applicant_name,
|
|
"operator": operator_name,
|
|
"result": report.status,
|
|
"duration": report.duration_ms,
|
|
"affected_rows": report.affected_rows,
|
|
"error": "; ".join(report.errors[:3]) if report.errors else "",
|
|
"approver": operator_name,
|
|
"comment": "",
|
|
}
|
|
|
|
sender = NotificationSender()
|
|
await sender.notify_ticket_event(
|
|
ticket_id=ticket.id,
|
|
event_type=event_type,
|
|
channels=channels,
|
|
ticket_data=notification_data,
|
|
)
|
|
|
|
|
|
# ===== 定时任务管理 =====
|
|
|
|
@router.get("/scheduled/list")
|
|
async def list_scheduled_tickets(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin", "dba")),
|
|
):
|
|
"""列出所有定时执行中的工单。"""
|
|
result = await db.execute(
|
|
select(Ticket)
|
|
.where(Ticket.status == TicketStatus.SCHEDULED.value)
|
|
.order_by(Ticket.scheduled_at.asc())
|
|
)
|
|
tickets = result.scalars().all()
|
|
data = []
|
|
for t in tickets:
|
|
inst_result = await db.execute(select(Instance).where(Instance.id == t.instance_id))
|
|
inst = inst_result.scalar_one_or_none()
|
|
data.append({
|
|
"id": t.id,
|
|
"ticket_no": t.ticket_no,
|
|
"title": t.title,
|
|
"db_type": t.db_type,
|
|
"instance_name": inst.name if inst else "未知",
|
|
"instance_db_type": inst.db_type if inst else "",
|
|
"target_database": t.target_database,
|
|
"sql_content": t.sql_content[:200] + "..." if len(t.sql_content) > 200 else t.sql_content,
|
|
"status": t.status,
|
|
"scheduled_at": t.scheduled_at.isoformat() if t.scheduled_at else None,
|
|
"priority": t.priority,
|
|
"create_time": t.create_time.isoformat() if t.create_time else None,
|
|
})
|
|
return {"items": data, "total": len(data)}
|
|
|
|
|
|
@router.post("/{ticket_id}/cancel-schedule")
|
|
async def cancel_scheduled_ticket(
|
|
ticket_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_role("admin", "dba")),
|
|
):
|
|
"""取消定时执行,将工单状态改回 approved。"""
|
|
ticket = await _get_ticket(db, ticket_id)
|
|
if ticket.status != TicketStatus.SCHEDULED.value:
|
|
raise HTTPException(status_code=400, detail="只能取消定时执行中的工单")
|
|
|
|
if not _validate_status_transition(ticket.status, TicketStatus.APPROVED.value):
|
|
raise HTTPException(status_code=400, detail="非法的状态转换")
|
|
|
|
# 取消后台任务
|
|
from app.services.ticket_scheduler import cancel_ticket_execution
|
|
cancel_ticket_execution(ticket.id)
|
|
|
|
ticket.status = TicketStatus.APPROVED.value
|
|
await db.commit()
|
|
await db.refresh(ticket)
|
|
|
|
await AuditLogService.log(
|
|
db, user_id=current_user.id, action="CANCEL_SCHEDULED_TICKET",
|
|
target_type="ticket", target_id=ticket.id,
|
|
detail=f"取消定时执行: {ticket.scheduled_at}",
|
|
)
|
|
|
|
return {"message": "已取消定时执行", "ticket_id": ticket_id, "status": ticket.status}
|