"""工单管理 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: 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")), ): """审核工单。 支持多级审批流: - 通过: 按顺序审批,所有步骤通过后状态变为 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: # 多级审批: 找到当前待审批步骤 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 "审批通过" # 检查是否还有后续待审批步骤 has_next = any( pa.action == "pending" for pa in pending_approvals if pa.id != current_step.id ) 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}