feat: 定时执行工单 — 新建工单+自动任务管理全面重构

后端:
- Ticket 模型新增 scheduled_at 字段 (DateTime, nullable)
- TicketStatus 新增 SCHEDULED 状态
- 状态转换图: approved→scheduled, scheduled→executing/approved
- execute_ticket: scheduled_at 在未来时进入 scheduled 状态
- ticket_scheduler.py: asyncio 后台定时执行器
- GET /tickets/scheduled/list: 列出定时执行中工单
- POST /tickets/{id}/cancel-schedule: 取消定时执行

前端:
- TicketCreatePage: 新增定时执行 checkbox + datetime-local picker (秒级精度)
- AutoTaskPage: 全面重写为真实定时任务管理页
  - 实时倒计时显示 (Xh Xm 后执行)
  - DB 类型图标 (MySQL/PG/Redis/MongoDB)
  - SQL 预览 + 取消定时执行
  - 每 30 秒自动刷新
This commit is contained in:
logan committed 2026-05-25 00:02:21 +08:00
1 parent 478e663313
commit ffb3d24e2a
6 files changed
+418 -262

No files matched your search

+90 -3
View File
@@ -69,6 +69,7 @@ class TicketStatus(str, Enum):
PENDING = "pending" # 待审批
REVIEWING = "reviewing" # 审批中(多级审批)
APPROVED = "approved" # 已通过
SCHEDULED = "scheduled" # 定时执行中
REJECTED = "rejected" # 已拒绝
EXECUTING = "executing" # 执行中
SUCCESS = "success" # 执行成功
@@ -80,7 +81,8 @@ class TicketStatus(str, Enum):
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.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],
@@ -404,6 +406,7 @@ async def create_ticket(
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
@@ -896,7 +899,25 @@ async def execute_ticket(
# 5. 权限检查
await check_instance_permission(current_user, instance, "ticket:execute", db)
# 6. 状态转换: approved -> executing
# 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,
@@ -907,7 +928,7 @@ async def execute_ticket(
await db.commit()
await db.refresh(ticket)
# 7. 使用多数据库执行引擎
# 8. 使用多数据库执行引擎
from app.services.ticket_executor import TicketExecutor
executor = TicketExecutor()
@@ -1071,3 +1092,69 @@ async def _send_execution_notification(
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}