- InstanceDetailPage: 概览/表管理/SQL查询/用户权限/慢查询/进程列表 6个Tab - RedisDetailPage: 键管理/监控信息/命令行/性能分析 4个Tab + 新增键弹框 - TicketCreatePage: 工单创建向导(选择实例/填写SQL/审核规则检查) - TicketDetailPage: 工单详情+审批/拒绝/执行/回滚操作 - App.tsx 路由注册: instances/:id, redis/:id, tickets/create, tickets/:id - API方法名对齐: listDatabases/listTables/listUsers - 前端构建零错误, 后端202测试通过
454 lines
15 KiB
Python
454 lines
15 KiB
Python
from typing import Optional, List, Dict, Any
|
|
from datetime import datetime, timezone
|
|
|
|
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.services.audit_engine import SQLAuditEngine
|
|
from app.services.audit_log_service import AuditLogService
|
|
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 = await db.execute(select(Instance).where(Instance.id == data.instance_id))
|
|
if not inst.scalar_one_or_none():
|
|
raise HTTPException(status_code=400, detail="数据源不存在")
|
|
|
|
# Auto-audit SQL before creating ticket
|
|
engine = SQLAuditEngine(env="production" if data.db_type == "mysql" else "development")
|
|
report = engine.audit(data.sql_content)
|
|
|
|
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="pending",
|
|
audit_result=json.dumps(report.to_dict(), ensure_ascii=False) if report.findings else None,
|
|
)
|
|
db.add(ticket)
|
|
await db.flush() # flush to get ticket.id for audit log
|
|
|
|
# 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审核评分: {report.score}, 通过: {report.passed}",
|
|
)
|
|
|
|
await db.commit()
|
|
await db.refresh(ticket)
|
|
result = await _enrich_ticket(ticket, db)
|
|
result["audit_report"] = 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"
|
|
engine = SQLAuditEngine(env=env)
|
|
report = engine.audit(data.sql_content)
|
|
|
|
return {
|
|
"score": report.score,
|
|
"passed": report.passed,
|
|
"has_warnings": report.has_warnings,
|
|
"findings": [
|
|
{"rule": f.rule, "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.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":
|
|
ticket.status = "approved"
|
|
else:
|
|
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")),
|
|
):
|
|
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="只能执行已审批的工单")
|
|
|
|
ticket.status = "executing"
|
|
await db.commit()
|
|
|
|
try:
|
|
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="数据源不存在")
|
|
|
|
from app.services.db_connector import DBConnector
|
|
instance.password = decrypt_password(instance.password)
|
|
exec_result = await DBConnector.execute_dml(instance, ticket.target_database, ticket.sql_content)
|
|
|
|
ticket.status = "success"
|
|
ticket.execute_result = exec_result
|
|
ticket.execute_time = datetime.now(timezone.utc)
|
|
except Exception as e:
|
|
ticket.status = "failed"
|
|
ticket.execute_result = str(e)
|
|
ticket.execute_time = datetime.now(timezone.utc)
|
|
|
|
await db.commit()
|
|
await db.refresh(ticket)
|
|
return await _enrich_ticket(ticket, db)
|