Files
opsxsql/backend/app/api/v1/endpoints/ticket.py
T
logan 4c9427e546 sync: 同步 sql-platform-pro 最新代码 — 权限体系安全修复 v0.8.1
- P0: 实例访问控制全线接入权限组 (829测试)
- P1: permissions字段激活 + 操作级权限检查 (843测试)
- 前端: 删除数据权限页面, 权限组合并为唯一入口
- 新增: CodeMirror编辑器 + 查询模板 + 慢查询分析
- 新增: MongoDB管理页面 + Dashboard增强
- 修复: Pydantic V2迁移 + 前端TS错误修复
2026-05-23 14:41:06 +08:00

748 lines
25 KiB
Python

from typing import Optional, List, Dict, Any, TYPE_CHECKING
from datetime import datetime, timezone
if TYPE_CHECKING:
from app.services.ticket_executor import ExecutionReport
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
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.schemas.ticket import TicketCreate, TicketReview, TicketResponse
from app.core.crypto import decrypt_password
from app.core.permissions import check_instance_access, check_instance_permission
from app.services.audit_engine import SQLAuditEngine
from app.services.audit_engine_v2 import SQLAuditEngineV2
from app.services.audit_log_service import AuditLogService
from app.models.workflow import WorkflowDefinition, WorkflowStep, TicketApproval
import json
class AuditRequest(BaseModel):
sql_content: str
instance_id: int
database: str = ""
router = APIRouter(prefix="/tickets", tags=["工单管理"])
async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
data = {
"id": ticket.id,
"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,
"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 "未知"
return data
@router.get("")
async def list_tickets(
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
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),
):
stmt = select(Ticket)
# developer 只能看到自己的工单
if current_user.role == "developer":
stmt = stmt.where(Ticket.applicant_id == current_user.id)
if status:
stmt = stmt.where(Ticket.status == status)
if db_type:
stmt = stmt.where(Ticket.db_type == db_type)
if keyword:
stmt = stmt.where(
or_(
Ticket.title.contains(keyword),
Ticket.sql_content.contains(keyword),
)
)
total_stmt = select(func.count()).select_from(stmt.subquery())
total = (await db.execute(total_stmt)).scalar()
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),
):
# 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="数据源不存在")
# 权限组检查:用户是否有权对该数据源提交工单
try:
await check_instance_access(current_user, instance, db)
except HTTPException:
raise HTTPException(status_code=403, detail="您没有权限对该数据源提交变更工单")
# P1: 操作级权限检查
await check_instance_permission(current_user, instance, "ticket:create", db)
# Auto-audit SQL before creating ticket (使用 V2 引擎)
v2_engine = SQLAuditEngineV2(db_session=db, env="production" if data.db_type == "mysql" else "development")
v2_report = await v2_engine.audit(data.sql_content, db_type=data.db_type)
# Check for default workflow to determine initial status
default_wf_result = await db.execute(
select(WorkflowDefinition).where(
WorkflowDefinition.is_default == True,
WorkflowDefinition.is_active == True,
)
)
default_workflow = default_wf_result.scalar_one_or_none()
initial_status = "reviewing" if default_workflow else "pending"
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,
applicant_id=current_user.id,
status=initial_status,
audit_result=json.dumps(v2_report.to_dict(), ensure_ascii=False) if v2_report.findings else None,
)
db.add(ticket)
await db.flush() # flush to get ticket.id
# If default workflow exists, create TicketApproval entries for each step
if default_workflow:
wf_steps_result = await db.execute(
select(WorkflowStep)
.where(WorkflowStep.workflow_id == default_workflow.id)
.order_by(WorkflowStep.step_number)
)
wf_steps = wf_steps_result.scalars().all()
for step in wf_steps:
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()
# Log ticket creation
await AuditLogService.log(
db, user_id=current_user.id, action="CREATE_TICKET",
target_type="ticket", target_id=ticket.id,
detail=f"SQL审核评分: {v2_report.score}, 通过: {v2_report.passed}, 规则数: {len(v2_report.findings)}",
)
await db.commit()
await db.refresh(ticket)
result = await _enrich_ticket(ticket, db)
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),
}
@router.post("/audit")
async def audit_sql(
data: AuditRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Verify instance exists
inst_result = await db.execute(select(Instance).where(Instance.id == data.instance_id))
instance = inst_result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=400, detail="数据源不存在")
env = "production" if instance.env == "production" else "development"
db_type = instance.db_type or "mysql"
# 使用 V2 引擎进行审核(支持数据库加载规则配置)
v2_engine = SQLAuditEngineV2(db_session=db, env=env)
report = await v2_engine.audit(data.sql_content, db_type=db_type)
return {
"score": report.score,
"passed": report.passed,
"has_warnings": report.has_warnings,
"summary": report.summary,
"findings": [
{"rule": f.rule_code, "level": f.level.value, "message": f.message}
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),
):
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 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),
):
"""获取工单的审批进度(多级审批流)"""
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="工单不存在")
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")),
):
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},无法审核")
if data.action == "approve":
# Check for pending workflow steps (multi-step approval)
pending_approvals_result = await db.execute(
select(TicketApproval)
.where(TicketApproval.ticket_id == ticket_id)
.order_by(TicketApproval.step_number)
)
pending_approvals = pending_approvals_result.scalars().all()
if pending_approvals:
# Multi-step workflow: find the current pending step
current_step = None
for pa in pending_approvals:
if pa.action == "pending":
current_step = pa
break
if current_step:
# Approve current step
current_step.action = "approved"
current_step.approver_id = current_user.id
current_step.comment = data.comment or "审批通过"
# Check if there's a next pending step
has_next = any(
pa.action == "pending"
for pa in pending_approvals
if pa.id != current_step.id
)
if has_next:
ticket.status = "reviewing"
else:
ticket.status = "approved"
else:
# All steps already processed
ticket.status = "approved"
else:
# No workflow steps — single-step approval (backward compat)
ticket.status = "approved"
else:
# Reject: reject the whole ticket immediately
# Mark all pending approval steps as rejected
pending_approvals_result = await db.execute(
select(TicketApproval)
.where(TicketApproval.ticket_id == ticket_id, TicketApproval.action == "pending")
)
for pa in pending_approvals_result.scalars().all():
pa.action = "rejected"
pa.approver_id = current_user.id
pa.comment = data.comment or "审批拒绝"
ticket.status = "rejected"
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}",
)
await db.commit()
await db.refresh(ticket)
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),
):
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 current_user.role == "developer" and ticket.applicant_id != current_user.id:
raise HTTPException(status_code=403, detail="无权查看该工单")
# Fetch audit logs for this ticket
from app.models.audit_log import AuditLog
log_stmt = (
select(AuditLog)
.where(AuditLog.target_type == "ticket", AuditLog.target_id == ticket_id)
.order_by(AuditLog.created_at.asc())
)
log_result = await db.execute(log_stmt)
logs = log_result.scalars().all()
timeline = []
# Creation event
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 变更工单",
})
# From audit logs
for log in logs:
if log.action == "CREATE_TICKET":
continue # already handled
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,
})
# Status-based synthetic events if no log entries cover them
if ticket.review_time:
has_review = any(t["action"] == "REVIEW_TICKET" for t in timeline)
if not has_review:
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.post("/{ticket_id}/cancel")
async def cancel_ticket(
ticket_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
# Only applicant or admin can cancel
if ticket.applicant_id != current_user.id and current_user.role != "admin":
raise HTTPException(status_code=403, detail="只有申请人或管理员可以取消工单")
if ticket.status not in ("pending", "reviewing"):
raise HTTPException(status_code=400, detail="只能取消待审批或审批中的工单")
ticket.status = "rejected"
ticket.audit_result = "工单被取消"
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")),
):
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"):
raise HTTPException(status_code=400, detail="只能回滚已执行(成功或失败)的工单")
# Simplified rollback — return info message
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="请求回滚工单",
)
ticket.status = "rollback"
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,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""执行工单。
使用多数据库工单执行引擎,按 db_type 自动分发到对应执行器。
支持 MySQL、PostgreSQL、Redis、MongoDB。
执行流程包括:语法预校验、执行前快照、执行变更、执行后验证、生成报告。
Args:
ticket_id: 工单 ID
db: 数据库会话
current_user: 当前用户(需要 admin 或 dba 角色)
Returns:
执行后的工单详情,包含执行报告
Raises:
HTTPException: 工单不存在、状态不正确、数据源不存在等
"""
# 1. 查询工单
result = await db.execute(select(Ticket).where(Ticket.id == ticket_id))
ticket = result.scalar_one_or_none()
if not ticket:
raise HTTPException(status_code=404, detail="工单不存在")
if ticket.status != "approved":
raise HTTPException(status_code=400, detail="只能执行已审批的工单")
# 2. 查询数据源实例
inst_result = await db.execute(
select(Instance).where(Instance.id == ticket.instance_id)
)
instance = inst_result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=400, detail="数据源不存在")
# 3. 更新工单状态为执行中
ticket.status = "executing"
await db.commit()
await db.refresh(ticket)
# 4. 使用新的多数据库执行引擎
from app.services.ticket_executor import TicketExecutor
executor = TicketExecutor()
try:
# 解密密码
instance.password = decrypt_password(instance.password)
# 执行工单
report = await executor.execute(ticket, instance)
# 5. 更新工单状态和结果
if report.status == "success":
ticket.status = "success"
else:
ticket.status = "failed"
ticket.execute_result = report.to_json()
ticket.execute_time = datetime.now(timezone.utc)
# 6. 记录执行日志
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])}"
await AuditLogService.log(
db,
user_id=current_user.id,
action="EXECUTE_TICKET",
target_type="ticket",
target_id=ticket.id,
detail=log_detail,
)
# 7. 发送通知(执行完成后)
try:
await _send_execution_notification(db, ticket, report)
except Exception:
# 通知失败不应影响执行结果
pass
except Exception as e:
ticket.status = "failed"
ticket.execute_result = str(e)
ticket.execute_time = datetime.now(timezone.utc)
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}",
)
# 8. 提交事务并返回
await db.commit()
await db.refresh(ticket)
# 构建响应
ticket_data = await _enrich_ticket(ticket, db)
# 尝试解析执行报告
try:
import json
exec_report = json.loads(ticket.execute_result) if ticket.execute_result else {}
if isinstance(exec_report, dict):
from app.schemas.execution import ExecutionReportResponse
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 _send_execution_notification(
db: AsyncSession, ticket: Ticket, report: "ExecutionReport"
) -> None:
"""发送工单执行完成通知。
查询已启用的通知渠道并发送执行结果通知。
Args:
db: 数据库会话
ticket: 工单对象
report: 执行报告
"""
from app.models.notification import NotificationChannel
from app.services.notification_sender import NotificationSender
result = await db.execute(
select(NotificationChannel).where(NotificationChannel.is_enabled == True)
)
channels = result.scalars().all()
if not channels:
return
sender = NotificationSender()
# 确定事件类型
if report.status == "success":
event_type = "ticket_executed"
else:
event_type = "ticket_failed"
# 构建 SQL 预览(前 100 字符)
sql_preview = ticket.sql_content[:100] + "..." if len(ticket.sql_content) > 100 else ticket.sql_content
# 获取申请人信息
applicant_name = "未知"
if ticket.applicant_id:
result = await db.execute(
select(User).where(User.id == ticket.applicant_id)
)
applicant = result.scalar_one_or_none()
if applicant:
applicant_name = applicant.display_name
notification_data = {
"title": ticket.title,
"ticket_id": ticket.id,
"db_type": ticket.db_type,
"target_database": ticket.target_database,
"sql_preview": sql_preview,
"priority": ticket.priority,
"applicant": applicant_name,
"result": report.status,
"duration": report.duration_ms,
"affected_rows": report.affected_rows,
"error": "; ".join(report.errors[:3]) if report.errors else "",
"operator": "dba", # 执行人角色
}
await sender.notify_ticket_event(
ticket_id=ticket.id,
event_type=event_type,
channels=channels,
ticket_data=notification_data,
)