Files
opsxsql/backend/app/api/v1/endpoints/ticket.py
T
logan dfdac3ad37 feat: P0前端页面完善 — InstanceDetail/RedisDetail/TicketCreate/TicketDetail
- 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测试通过
2026-05-22 23:10:14 +08:00

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)