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:
1 parent
478e663313
commit
ffb3d24e2a
6 files changed
+418
-262
No files matched your search
@@ -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}
|
||||
@@ -24,4 +24,5 @@ class Ticket(Base):
|
||||
create_time = Column(DateTime, server_default=func.now())
|
||||
review_time = Column(DateTime, nullable=True)
|
||||
execute_time = Column(DateTime, nullable=True)
|
||||
scheduled_at = Column(DateTime, nullable=True, comment="定时执行时间,NULL=立即执行")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
@@ -12,6 +12,7 @@ class TicketCreate(BaseModel):
|
||||
rollback_sql: Optional[str] = None
|
||||
reason: str = Field(..., min_length=5)
|
||||
priority: Literal["high", "medium", "low"] = "medium"
|
||||
scheduled_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class TicketReview(BaseModel):
|
||||
@@ -42,6 +43,7 @@ class TicketResponse(BaseModel):
|
||||
create_time: datetime
|
||||
review_time: Optional[datetime] = None
|
||||
execute_time: Optional[datetime] = None
|
||||
scheduled_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""工单定时执行调度器 — 基于 asyncio 的轻量级定时任务。"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 内存中保存活跃的定时任务句柄
|
||||
_active_tasks: Dict[int, asyncio.Task] = {}
|
||||
|
||||
|
||||
async def schedule_ticket_execution(ticket_id: int, scheduled_at: datetime) -> None:
|
||||
"""注册一个定时执行任务。
|
||||
|
||||
使用 asyncio.create_task 创建后台等待任务,到达 scheduled_at 后自动执行工单。
|
||||
|
||||
Args:
|
||||
ticket_id: 工单 ID
|
||||
scheduled_at: 定时执行时间 (UTC)
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
delay = (scheduled_at - now).total_seconds()
|
||||
|
||||
if delay <= 0:
|
||||
# 已到时间,立即执行
|
||||
logger.info("工单 %d 定时时间已到,立即执行", ticket_id)
|
||||
task = asyncio.create_task(_execute_scheduled_ticket(ticket_id))
|
||||
else:
|
||||
logger.info("工单 %d 定时执行: %s (%.1f 秒后)", ticket_id, scheduled_at, delay)
|
||||
task = asyncio.create_task(_wait_and_execute(ticket_id, delay))
|
||||
|
||||
_active_tasks[ticket_id] = task
|
||||
|
||||
|
||||
async def _wait_and_execute(ticket_id: int, delay: float) -> None:
|
||||
"""等待 delay 秒后执行工单。"""
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
await _execute_scheduled_ticket(ticket_id)
|
||||
except asyncio.CancelledError:
|
||||
logger.info("工单 %d 定时执行已取消", ticket_id)
|
||||
except Exception:
|
||||
logger.exception("工单 %d 定时执行异常", ticket_id)
|
||||
finally:
|
||||
_active_tasks.pop(ticket_id, None)
|
||||
|
||||
|
||||
async def _execute_scheduled_ticket(ticket_id: int) -> None:
|
||||
"""执行定时工单 — 创建独立数据库会话执行。"""
|
||||
from app.core.database import async_session
|
||||
from app.models.ticket import Ticket as TicketModel
|
||||
from app.models.instance import Instance
|
||||
from app.services.ticket_executor import TicketExecutor
|
||||
from app.services.audit_log import AuditLogService
|
||||
from app.core.security import decrypt_password
|
||||
from sqlalchemy import select
|
||||
import json
|
||||
|
||||
logger.info("开始执行定时工单 %d", ticket_id)
|
||||
|
||||
async with async_session() as db:
|
||||
try:
|
||||
# 查询工单
|
||||
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
|
||||
ticket = result.scalar_one_or_none()
|
||||
if not ticket:
|
||||
logger.error("定时工单 %d 不存在", ticket_id)
|
||||
return
|
||||
|
||||
# 状态检查
|
||||
if ticket.status != "scheduled":
|
||||
logger.warning("工单 %d 状态已变更 (%s),跳过执行", ticket_id, ticket.status)
|
||||
return
|
||||
|
||||
# 更新状态为 executing
|
||||
ticket.status = "executing"
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
|
||||
# 查询实例
|
||||
inst_result = await db.execute(select(Instance).where(Instance.id == ticket.instance_id))
|
||||
instance = inst_result.scalar_one_or_none()
|
||||
if not instance:
|
||||
ticket.status = "failed"
|
||||
ticket.execute_result = json.dumps({"status": "failed", "errors": ["数据源实例不存在"]}, ensure_ascii=False)
|
||||
ticket.execute_time = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return
|
||||
|
||||
# 解密密码
|
||||
instance.password = decrypt_password(instance.password)
|
||||
|
||||
# 执行
|
||||
executor = TicketExecutor()
|
||||
report = await executor.execute(ticket, instance)
|
||||
|
||||
# 更新结果
|
||||
ticket.status = "success" if report.status == "success" else "failed"
|
||||
ticket.execute_result = report.to_json()
|
||||
ticket.execute_time = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
|
||||
logger.info("定时工单 %d 执行完成: %s", ticket_id, report.status)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("定时工单 %d 执行异常", ticket_id)
|
||||
try:
|
||||
result = await db.execute(select(TicketModel).where(TicketModel.id == ticket_id))
|
||||
ticket = result.scalar_one_or_none()
|
||||
if ticket:
|
||||
ticket.status = "failed"
|
||||
ticket.execute_result = json.dumps({"status": "failed", "errors": [f"{type(e).__name__}: {e}"]}, ensure_ascii=False)
|
||||
ticket.execute_time = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
logger.exception("更新定时工单 %d 失败状态也失败", ticket_id)
|
||||
finally:
|
||||
_active_tasks.pop(ticket_id, None)
|
||||
|
||||
|
||||
def cancel_ticket_execution(ticket_id: int) -> bool:
|
||||
"""取消定时执行任务。
|
||||
|
||||
Args:
|
||||
ticket_id: 工单 ID
|
||||
|
||||
Returns:
|
||||
是否成功取消
|
||||
"""
|
||||
task = _active_tasks.get(ticket_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
_active_tasks.pop(ticket_id, None)
|
||||
logger.info("已取消工单 %d 的定时执行", ticket_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_active_scheduled_count() -> int:
|
||||
"""返回当前活跃的定时任务数量。"""
|
||||
return len(_active_tasks)
|
||||
|
||||
|
||||
def get_active_scheduled_ids() -> list[int]:
|
||||
"""返回当前活跃的定时工单 ID 列表。"""
|
||||
return list(_active_tasks.keys())
|
||||
Reference in new issue
Block a user