fix: merge后14个测试修复+前端TS编译修复 - 895/895通过+前端零错误
This commit is contained in:
1 parent
58eb78c987
commit
b39c426317
24 files changed
+3918
-760
No files matched your search
@@ -1,11 +1,21 @@
|
||||
from typing import Optional, List, Dict, Any, TYPE_CHECKING
|
||||
"""工单管理 API — 支持多数据库的变更工单生命周期管理。
|
||||
|
||||
核心功能:
|
||||
- 工单创建、审核、执行、取消、回滚
|
||||
- 多级审批流支持
|
||||
- 工单状态机管理(严格的状态转换)
|
||||
- 自动 SQL 审核(创建时触发)
|
||||
- 执行前二次确认
|
||||
- 执行后通知触发
|
||||
- 完整操作审计
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.services.ticket_executor import ExecutionReport
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
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
|
||||
|
||||
@@ -14,25 +24,103 @@ 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.models.workflow import WorkflowDefinition, WorkflowStep, TicketApproval
|
||||
from app.services.query_service import MAX_QUERY_LENGTH
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AuditRequest(BaseModel):
|
||||
sql_content: str
|
||||
instance_id: int
|
||||
database: str = ""
|
||||
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" # 已通过
|
||||
REJECTED = "rejected" # 已拒绝
|
||||
EXECUTING = "executing" # 执行中
|
||||
SUCCESS = "success" # 执行成功
|
||||
FAILED = "failed" # 执行失败
|
||||
ROLLBACK = "rollback" # 已回滚
|
||||
|
||||
|
||||
# 状态转换图: 当前状态 -> 允许的目标状态
|
||||
TICKET_STATE_TRANSITIONS: Dict[str, List[str]] = {
|
||||
TicketStatus.PENDING.value: [TicketStatus.REVIEWING.value, TicketStatus.APPROVED.value, TicketStatus.REJECTED.value],
|
||||
TicketStatus.REVIEWING.value: [TicketStatus.REVIEWING.value, TicketStatus.APPROVED.value, TicketStatus.REJECTED.value],
|
||||
TicketStatus.APPROVED.value: [TicketStatus.EXECUTING.value, TicketStatus.REJECTED.value],
|
||||
TicketStatus.EXECUTING.value: [TicketStatus.SUCCESS.value, TicketStatus.FAILED.value],
|
||||
TicketStatus.SUCCESS.value: [TicketStatus.ROLLBACK.value],
|
||||
TicketStatus.FAILED.value: [TicketStatus.ROLLBACK.value, TicketStatus.APPROVED.value],
|
||||
TicketStatus.REJECTED.value: [], # 终态,不可再转换
|
||||
TicketStatus.ROLLBACK.value: [], # 终态,不可再转换
|
||||
}
|
||||
|
||||
|
||||
def _validate_status_transition(current_status: str, new_status: str) -> bool:
|
||||
"""验证工单状态转换是否合法。
|
||||
|
||||
Args:
|
||||
current_status: 当前状态
|
||||
new_status: 目标状态
|
||||
|
||||
Returns:
|
||||
转换是否合法
|
||||
"""
|
||||
allowed = TICKET_STATE_TRANSITIONS.get(current_status, [])
|
||||
return new_status in allowed
|
||||
|
||||
|
||||
# ===== 请求模型 =====
|
||||
|
||||
|
||||
class AuditRequest(BaseModel):
|
||||
"""SQL 审核请求。"""
|
||||
sql_content: str = Field(..., min_length=1, max_length=MAX_QUERY_LENGTH)
|
||||
instance_id: int
|
||||
database: str = ""
|
||||
|
||||
@field_validator('sql_content')
|
||||
@classmethod
|
||||
def validate_sql_length(cls, v):
|
||||
if len(v) > MAX_QUERY_LENGTH:
|
||||
raise ValueError(f"SQL 内容不能超过 {MAX_QUERY_LENGTH} 字符")
|
||||
return v
|
||||
|
||||
|
||||
class TicketExecuteConfirm(BaseModel):
|
||||
"""工单执行二次确认请求。"""
|
||||
confirmed: bool = Field(..., description="是否确认执行")
|
||||
comment: str = Field(default="", max_length=500, description="执行备注")
|
||||
|
||||
|
||||
# ===== 辅助函数 =====
|
||||
|
||||
|
||||
async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
|
||||
"""丰富工单数据,添加申请人/审批人名称等信息。
|
||||
|
||||
Args:
|
||||
ticket: 工单对象
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
丰富的工单字典
|
||||
"""
|
||||
data = {
|
||||
"id": ticket.id,
|
||||
"title": ticket.title,
|
||||
@@ -68,27 +156,121 @@ async def _enrich_ticket(ticket: Ticket, db: AsyncSession) -> Dict[str, Any]:
|
||||
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),
|
||||
page_size: int = Query(10, ge=1, le=100),
|
||||
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 只能看到自己的工单
|
||||
# 权限过滤: 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),
|
||||
@@ -96,10 +278,17 @@ async def list_tickets(
|
||||
)
|
||||
)
|
||||
|
||||
# 总数查询
|
||||
total_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total = (await db.execute(total_stmt)).scalar()
|
||||
total_result = await db.execute(total_stmt)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
stmt = stmt.order_by(Ticket.create_time.desc()).offset((page - 1) * page_size).limit(page_size)
|
||||
# 分页查询
|
||||
stmt = (
|
||||
stmt.order_by(Ticket.create_time.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
tickets = result.scalars().all()
|
||||
|
||||
@@ -107,7 +296,12 @@ async def list_tickets(
|
||||
for t in tickets:
|
||||
items.append(await _enrich_ticket(t, db))
|
||||
|
||||
return {"total": total, "page": page, "page_size": page_size, "items": items}
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", status_code=201)
|
||||
@@ -116,26 +310,39 @@ async def create_ticket(
|
||||
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="数据源不存在")
|
||||
"""创建工单。
|
||||
|
||||
# 权限组检查:用户是否有权对该数据源提交工单
|
||||
流程:
|
||||
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="您没有权限对该数据源提交变更工单")
|
||||
|
||||
# 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)
|
||||
# 3. 验证工单内容安全性
|
||||
_validate_ticket_content(data)
|
||||
|
||||
# Check for default workflow to determine initial status
|
||||
# 4. 自动审核 SQL
|
||||
env = "production" if instance.env == "production" else "development"
|
||||
v2_engine = SQLAuditEngineV2(db_session=db, env=env)
|
||||
try:
|
||||
v2_report = await v2_engine.audit(data.sql_content, db_type=data.db_type)
|
||||
except Exception as e:
|
||||
logger.error("SQL 审核失败: %s", e)
|
||||
v2_report = None
|
||||
|
||||
# 5. 检查是否有默认审批流
|
||||
default_wf_result = await db.execute(
|
||||
select(WorkflowDefinition).where(
|
||||
WorkflowDefinition.is_default == True,
|
||||
@@ -144,8 +351,9 @@ async def create_ticket(
|
||||
)
|
||||
default_workflow = default_wf_result.scalar_one_or_none()
|
||||
|
||||
initial_status = "reviewing" if default_workflow else "pending"
|
||||
initial_status = TicketStatus.REVIEWING.value if default_workflow else TicketStatus.PENDING.value
|
||||
|
||||
# 6. 创建工单
|
||||
ticket = Ticket(
|
||||
title=data.title,
|
||||
db_type=data.db_type,
|
||||
@@ -157,12 +365,12 @@ async def create_ticket(
|
||||
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,
|
||||
audit_result=json.dumps(v2_report.to_dict(), ensure_ascii=False) if v2_report and 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
|
||||
# 7. 创建审批步骤(多级审批流)
|
||||
if default_workflow:
|
||||
wf_steps_result = await db.execute(
|
||||
select(WorkflowStep)
|
||||
@@ -181,17 +389,22 @@ async def create_ticket(
|
||||
db.add(approval)
|
||||
await db.flush()
|
||||
|
||||
# Log ticket creation
|
||||
# 8. 记录审计日志
|
||||
audit_detail = ""
|
||||
if v2_report:
|
||||
audit_detail = f"SQL审核评分: {v2_report.score}, 通过: {v2_report.passed}, 规则数: {len(v2_report.findings)}"
|
||||
await AuditLogService.log(
|
||||
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)}",
|
||||
detail=audit_detail or "工单创建",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
result = await _enrich_ticket(ticket, db)
|
||||
result["audit_report"] = v2_report.to_dict()
|
||||
if v2_report:
|
||||
result["audit_report"] = v2_report.to_dict()
|
||||
return result
|
||||
|
||||
|
||||
@@ -200,6 +413,7 @@ 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()}
|
||||
@@ -214,6 +428,7 @@ async def ticket_statistics(
|
||||
"executing": stats.get("executing", 0),
|
||||
"success": stats.get("success", 0),
|
||||
"failed": stats.get("failed", 0),
|
||||
"rollback": stats.get("rollback", 0),
|
||||
}
|
||||
|
||||
|
||||
@@ -223,26 +438,35 @@ async def audit_sql(
|
||||
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="数据源不存在")
|
||||
"""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 引擎进行审核
|
||||
v2_engine = SQLAuditEngineV2(db_session=db, env=env)
|
||||
report = await v2_engine.audit(data.sql_content, db_type=db_type)
|
||||
try:
|
||||
report = await v2_engine.audit(data.sql_content, db_type=db_type)
|
||||
except Exception as e:
|
||||
logger.error("SQL 审核异常: %s", e)
|
||||
raise HTTPException(status_code=500, detail=f"审核引擎异常: {e}")
|
||||
|
||||
return {
|
||||
"score": report.score,
|
||||
"passed": report.passed,
|
||||
"has_warnings": report.has_warnings,
|
||||
"summary": report.summary,
|
||||
"risk_level": getattr(report, 'risk_level', 'unknown'),
|
||||
"summary": getattr(report, 'summary', ''),
|
||||
"findings": [
|
||||
{"rule": f.rule_code, "level": f.level.value, "message": f.message}
|
||||
{
|
||||
"rule": getattr(f, 'rule_code', getattr(f, 'rule', 'unknown')),
|
||||
"level": f.level.value,
|
||||
"message": f.message,
|
||||
"risk_level": getattr(f, 'risk_level', 'medium'),
|
||||
"suggestion": getattr(f, 'suggestion', ''),
|
||||
}
|
||||
for f in report.findings
|
||||
],
|
||||
}
|
||||
@@ -254,11 +478,10 @@ async def get_ticket(
|
||||
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="工单不存在")
|
||||
"""获取工单详情。"""
|
||||
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="无权查看该工单")
|
||||
|
||||
@@ -271,11 +494,12 @@ async def get_ticket_approvals(
|
||||
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="工单不存在")
|
||||
"""获取工单的审批进度(多级审批流)。"""
|
||||
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)
|
||||
@@ -307,16 +531,23 @@ async def review_ticket(
|
||||
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},无法审核")
|
||||
支持多级审批流:
|
||||
- 通过: 按顺序审批,所有步骤通过后状态变为 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":
|
||||
# Check for pending workflow steps (multi-step approval)
|
||||
# 检查多级审批流
|
||||
pending_approvals_result = await db.execute(
|
||||
select(TicketApproval)
|
||||
.where(TicketApproval.ticket_id == ticket_id)
|
||||
@@ -325,7 +556,7 @@ async def review_ticket(
|
||||
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":
|
||||
@@ -333,30 +564,29 @@ async def review_ticket(
|
||||
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"
|
||||
new_status = TicketStatus.REVIEWING.value
|
||||
else:
|
||||
ticket.status = "approved"
|
||||
new_status = TicketStatus.APPROVED.value
|
||||
else:
|
||||
# All steps already processed
|
||||
ticket.status = "approved"
|
||||
# 所有步骤已处理
|
||||
new_status = TicketStatus.APPROVED.value
|
||||
else:
|
||||
# No workflow steps — single-step approval (backward compat)
|
||||
ticket.status = "approved"
|
||||
# 无审批流 - 单步审批
|
||||
new_status = TicketStatus.APPROVED.value
|
||||
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")
|
||||
@@ -366,8 +596,16 @@ async def review_ticket(
|
||||
pa.approver_id = current_user.id
|
||||
pa.comment = data.comment or "审批拒绝"
|
||||
|
||||
ticket.status = "rejected"
|
||||
new_status = TicketStatus.REJECTED.value
|
||||
|
||||
# 验证状态转换合法性
|
||||
if not _validate_status_transition(ticket.status, new_status):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"非法的状态转换: {ticket.status} -> {new_status}"
|
||||
)
|
||||
|
||||
ticket.status = new_status
|
||||
ticket.approver_id = current_user.id
|
||||
ticket.review_time = datetime.now(timezone.utc)
|
||||
ticket.audit_result = data.comment
|
||||
@@ -375,7 +613,7 @@ async def review_ticket(
|
||||
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}",
|
||||
detail=f"审核动作: {data.action}, 备注: {data.comment}, 新状态: {new_status}",
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
@@ -389,16 +627,13 @@ async def get_ticket_timeline(
|
||||
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="工单不存在")
|
||||
"""获取工单时间线。"""
|
||||
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="无权查看该工单")
|
||||
|
||||
# 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)
|
||||
@@ -409,7 +644,7 @@ async def get_ticket_timeline(
|
||||
|
||||
timeline = []
|
||||
|
||||
# Creation event
|
||||
# 创建事件
|
||||
applicant_name = "未知"
|
||||
if ticket.applicant_id:
|
||||
r = await db.execute(select(User).where(User.id == ticket.applicant_id))
|
||||
@@ -425,10 +660,10 @@ async def get_ticket_timeline(
|
||||
"detail": "提交 SQL 变更工单",
|
||||
})
|
||||
|
||||
# From audit logs
|
||||
# 审计日志事件
|
||||
for log in logs:
|
||||
if log.action == "CREATE_TICKET":
|
||||
continue # already handled
|
||||
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 "未知"
|
||||
@@ -447,7 +682,7 @@ async def get_ticket_timeline(
|
||||
"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:
|
||||
@@ -485,21 +720,40 @@ async def cancel_ticket(
|
||||
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
|
||||
只有申请人或管理员可以取消 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 ("pending", "reviewing"):
|
||||
# 状态检查
|
||||
if ticket.status not in (TicketStatus.PENDING.value, TicketStatus.REVIEWING.value):
|
||||
raise HTTPException(status_code=400, detail="只能取消待审批或审批中的工单")
|
||||
|
||||
ticket.status = "rejected"
|
||||
# 状态转换
|
||||
new_status = TicketStatus.REJECTED.value
|
||||
if not _validate_status_transition(ticket.status, new_status):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"非法的状态转换: {ticket.status} -> {new_status}"
|
||||
)
|
||||
|
||||
ticket.status = new_status
|
||||
ticket.audit_result = "工单被取消"
|
||||
|
||||
# 更新审批步骤状态
|
||||
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,
|
||||
@@ -517,24 +771,32 @@ async def rollback_ticket(
|
||||
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"):
|
||||
只能回滚 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="只能回滚已执行(成功或失败)的工单")
|
||||
|
||||
# Simplified rollback — return info message
|
||||
# 状态转换
|
||||
new_status = TicketStatus.ROLLBACK.value
|
||||
if not _validate_status_transition(ticket.status, new_status):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"非法的状态转换: {ticket.status} -> {new_status}"
|
||||
)
|
||||
|
||||
rollback_sql = ticket.rollback_sql or "未提供回滚 SQL"
|
||||
|
||||
await AuditLogService.log(
|
||||
db, user_id=current_user.id, action="ROLLBACK_TICKET",
|
||||
target_type="ticket", target_id=ticket.id,
|
||||
detail="请求回滚工单",
|
||||
detail=f"请求回滚工单,回滚SQL: {rollback_sql[:200]}",
|
||||
)
|
||||
|
||||
ticket.status = "rollback"
|
||||
ticket.status = new_status
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
@@ -549,49 +811,61 @@ async def rollback_ticket(
|
||||
@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 自动分发到对应执行器。
|
||||
支持 MySQL、PostgreSQL、Redis、MongoDB。
|
||||
执行流程包括:语法预校验、执行前快照、执行变更、执行后验证、生成报告。
|
||||
|
||||
执行流程:
|
||||
1. 验证工单状态为 approved
|
||||
2. 二次确认(可选)
|
||||
3. 更新状态为 executing
|
||||
4. 执行变更
|
||||
5. 更新结果和状态
|
||||
6. 记录审计日志
|
||||
7. 发送通知
|
||||
|
||||
Args:
|
||||
ticket_id: 工单 ID
|
||||
confirm: 二次确认参数
|
||||
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="工单不存在")
|
||||
ticket = await _get_ticket(db, ticket_id)
|
||||
|
||||
if ticket.status != "approved":
|
||||
# 2. 状态检查
|
||||
if ticket.status != TicketStatus.APPROVED.value:
|
||||
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. 二次确认检查
|
||||
if confirm and not confirm.confirmed:
|
||||
raise HTTPException(status_code=400, detail="未确认执行,操作已取消")
|
||||
|
||||
# 3. 更新工单状态为执行中
|
||||
ticket.status = "executing"
|
||||
# 4. 查询数据源实例
|
||||
instance = await _get_instance(db, ticket.instance_id)
|
||||
|
||||
# 5. 权限检查
|
||||
await check_instance_permission(current_user, instance, "ticket:execute", db)
|
||||
|
||||
# 6. 状态转换: approved -> executing
|
||||
if not _validate_status_transition(ticket.status, TicketStatus.EXECUTING.value):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"非法的状态转换: {ticket.status} -> {TicketStatus.EXECUTING.value}"
|
||||
)
|
||||
|
||||
ticket.status = TicketStatus.EXECUTING.value
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
# 4. 使用新的多数据库执行引擎
|
||||
# 7. 使用多数据库执行引擎
|
||||
from app.services.ticket_executor import TicketExecutor
|
||||
executor = TicketExecutor()
|
||||
|
||||
@@ -602,22 +876,30 @@ async def execute_ticket(
|
||||
# 执行工单
|
||||
report = await executor.execute(ticket, instance)
|
||||
|
||||
# 5. 更新工单状态和结果
|
||||
# 8. 更新工单状态和结果
|
||||
if report.status == "success":
|
||||
ticket.status = "success"
|
||||
new_status = TicketStatus.SUCCESS.value
|
||||
else:
|
||||
ticket.status = "failed"
|
||||
new_status = TicketStatus.FAILED.value
|
||||
|
||||
# 验证状态转换
|
||||
if _validate_status_transition(TicketStatus.EXECUTING.value, new_status):
|
||||
ticket.status = new_status
|
||||
else:
|
||||
ticket.status = TicketStatus.FAILED.value
|
||||
|
||||
ticket.execute_result = report.to_json()
|
||||
ticket.execute_time = datetime.now(timezone.utc)
|
||||
|
||||
# 6. 记录执行日志
|
||||
# 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,
|
||||
@@ -628,16 +910,20 @@ async def execute_ticket(
|
||||
detail=log_detail,
|
||||
)
|
||||
|
||||
# 7. 发送通知(执行完成后)
|
||||
# 10. 发送通知
|
||||
try:
|
||||
await _send_execution_notification(db, ticket, report)
|
||||
except Exception:
|
||||
except Exception as notify_err:
|
||||
logger.error("执行通知发送失败: %s", notify_err)
|
||||
# 通知失败不应影响执行结果
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
ticket.status = "failed"
|
||||
ticket.execute_result = str(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)
|
||||
|
||||
await AuditLogService.log(
|
||||
@@ -649,7 +935,7 @@ async def execute_ticket(
|
||||
detail=f"执行异常: {type(e).__name__}: {e}",
|
||||
)
|
||||
|
||||
# 8. 提交事务并返回
|
||||
# 11. 提交事务并返回
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
@@ -658,7 +944,6 @@ async def execute_ticket(
|
||||
|
||||
# 尝试解析执行报告
|
||||
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
|
||||
@@ -674,7 +959,6 @@ async def execute_ticket(
|
||||
after_snapshot_summary=exec_report.get("after_snapshot"),
|
||||
)
|
||||
except Exception:
|
||||
# 如果解析失败,不返回结构化报告
|
||||
ticket_data["execution_report"] = None
|
||||
|
||||
return ticket_data
|
||||
@@ -736,7 +1020,7 @@ async def _send_execution_notification(
|
||||
"duration": report.duration_ms,
|
||||
"affected_rows": report.affected_rows,
|
||||
"error": "; ".join(report.errors[:3]) if report.errors else "",
|
||||
"operator": "dba", # 执行人角色
|
||||
"operator": "dba",
|
||||
}
|
||||
|
||||
await sender.notify_ticket_event(
|
||||
|
||||
Reference in new issue
Block a user