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, )