Files
opsxsql/backend/app/api/v1/endpoints/scheduled_task.py
T
logan 08f0f88cf1 feat: 工单列表重设计+权限组实例过滤+通知修复
后端:
- Ticket model 新增 ticket_no 字段(格式: YYYYMMDDHHmmss+ms+seq, 如20260522160543841)
- create_ticket 自动生成工单编号(flush后生成)
- _enrich_ticket 新增 instance_name, current_approver_name 字段
- current_approver: 从 ticket_approvals 查询第一个 pending 状态的审批步骤获取审核人
- TicketResponse schema 新增 ticket_no, instance_name, current_approver_name
- 新增 /instances/accessible endpoint(按用户权限组过滤实例, 无admin bypass)
- instance_group response_model 改为 InstanceGroupDetailResponse(含readonly/write实例)
- instance_group_members 脏数据清理, 重建 group_id=2 正确数据
- notification 模板修复: \n→\n 换行, 钉钉 \n→\n\n 双换行
- test_notification endpoint 改为真正调用 NotificationSender.send()
- auditor 角色添加到 audit_rule/ticket 只读端点
- seed_data() 创建4个默认用户(admin/zhangsan/laoliu/lurenjia)
- SQLite ALTER TABLE 添加 ticket_no 列, 迁移现有6条工单数据

前端:
- TicketsPage 完全重设计: 工单编号/实例名称/提交人/当前审核人/状态/操作
- 状态映射简化: pending/reviewing/approved→审核中, success→完成, failed→失败
- 执行详情弹框: 解析 execute_result JSON 展示状态/影响行数/耗时/验证/原始输出/错误信息
- 状态流程可视化(提交→审批→通过→执行→完成)
- 快速筛选: 全部/待审批/执行中/完成/失败 (合并 pending+reviewing+approved 统计)
- TicketCreatePage: 去掉数据库类型选择器, 用 accessible(write) 过滤变更实例
- QueriesPage: 用 accessible(readonly) 过滤查询实例
- instance.ts 新增 accessible 方法
- 分页优化: 动态计算页码范围(总页>5时中间滚动)
2026-05-24 22:55:51 +08:00

288 lines
9.8 KiB
Python

"""定时任务 API — 基于 SQL 的自动化任务管理。"""
import time
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func as sa_func
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.instance import Instance
from app.models.scheduled_task import ScheduledTask, ScheduledTaskLog
from app.schemas.scheduled_task import (
ScheduledTaskCreate, ScheduledTaskUpdate,
ScheduledTaskResponse, ScheduledTaskLogResponse, TaskRunResponse,
)
from app.services.db_connector import DBConnector
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/scheduled-tasks", tags=["自动化任务"])
def _serialize(task: ScheduledTask, instance_name: str = None, instance_db_type: str = None) -> dict:
return {
"id": task.id,
"name": task.name,
"description": task.description,
"task_sql_type": task.task_sql_type,
"instance_id": task.instance_id,
"instance_name": instance_name,
"instance_db_type": instance_db_type,
"target_db": task.target_db,
"target_table": task.target_table,
"sql_content": task.sql_content,
"cron_expression": task.cron_expression,
"is_enabled": task.is_enabled,
"last_run_at": task.last_run_at,
"last_run_status": task.last_run_status,
"last_run_message": task.last_run_message,
"next_run_at": task.next_run_at,
"run_count": task.run_count or 0,
"created_by": task.created_by,
"created_at": task.created_at,
}
async def _get_instance(db: AsyncSession, instance_id: int) -> Instance:
result = await db.execute(select(Instance).where(Instance.id == instance_id))
inst = result.scalar_one_or_none()
if not inst:
raise HTTPException(404, "数据源不存在")
return inst
# ── CRUD ──────────────────────────────────────────────────────
@router.get("", response_model=list[ScheduledTaskResponse])
async def list_tasks(
task_sql_type: Optional[str] = None,
is_enabled: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""列出所有定时任务。"""
q = select(ScheduledTask).order_by(ScheduledTask.created_at.desc())
if task_sql_type:
q = q.where(ScheduledTask.task_sql_type == task_sql_type)
if is_enabled is not None:
q = q.where(ScheduledTask.is_enabled == is_enabled)
result = await db.execute(q)
tasks = result.scalars().all()
# batch load instance info
inst_ids = {t.instance_id for t in tasks}
inst_map = {}
if inst_ids:
ir = await db.execute(select(Instance).where(Instance.id.in_(inst_ids)))
for inst in ir.scalars().all():
inst_map[inst.id] = inst
return [_serialize(t, inst_map.get(t.instance_id).name if inst_map.get(t.instance_id) else None,
inst_map.get(t.instance_id).db_type if inst_map.get(t.instance_id) else None)
for t in tasks]
@router.post("", response_model=ScheduledTaskResponse, status_code=201)
async def create_task(
body: ScheduledTaskCreate,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_role("admin")),
):
"""创建定时任务。"""
# validate instance exists
inst = await _get_instance(db, body.instance_id)
task = ScheduledTask(
name=body.name,
description=body.description,
task_sql_type=body.task_sql_type.value,
instance_id=body.instance_id,
target_db=body.target_db,
target_table=body.target_table,
sql_content=body.sql_content,
cron_expression=body.cron_expression,
is_enabled=body.is_enabled,
created_by=user.id,
)
db.add(task)
await db.flush()
await db.refresh(task)
return _serialize(task, inst.name, inst.db_type)
@router.get("/{task_id}", response_model=ScheduledTaskResponse)
async def get_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""获取任务详情。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
inst = await _get_instance(db, task.instance_id)
return _serialize(task, inst.name, inst.db_type)
@router.put("/{task_id}", response_model=ScheduledTaskResponse)
async def update_task(
task_id: int,
body: ScheduledTaskUpdate,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""更新任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
update_data = body.model_dump(exclude_unset=True)
if "task_sql_type" in update_data and update_data["task_sql_type"] is not None:
update_data["task_sql_type"] = update_data["task_sql_type"].value
for k, v in update_data.items():
setattr(task, k, v)
await db.flush()
await db.refresh(task)
inst = await _get_instance(db, task.instance_id)
return _serialize(task, inst.name, inst.db_type)
@router.delete("/{task_id}")
async def delete_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""删除任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
await db.delete(task)
return {"detail": "已删除"}
@router.patch("/{task_id}/toggle")
async def toggle_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""启用/禁用任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
task.is_enabled = not task.is_enabled
await db.flush()
return {"is_enabled": task.is_enabled}
# ── 执行 ──────────────────────────────────────────────────────
@router.post("/{task_id}/run", response_model=TaskRunResponse)
async def run_task(
task_id: int,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""手动执行一次任务。"""
result = await db.execute(select(ScheduledTask).where(ScheduledTask.id == task_id))
task = result.scalar_one_or_none()
if not task:
raise HTTPException(404, "任务不存在")
inst = await _get_instance(db, task.instance_id)
target_db = task.target_db or inst.database or ""
log = ScheduledTaskLog(
task_id=task.id,
status="running",
sql_content=task.sql_content,
)
db.add(log)
await db.flush()
start = time.time()
try:
msg = await DBConnector.execute_dml(inst, target_db, task.sql_content)
elapsed = int((time.time() - start) * 1000)
# parse affected rows from message like "执行成功,影响 3 行"
affected = 0
import re
m = re.search(r'(\d+)', msg)
if m:
affected = int(m.group(1))
log.status = "success"
log.affected_rows = affected
log.duration_ms = elapsed
log.finished_at = __import__("datetime").datetime.now()
task.last_run_at = __import__("datetime").datetime.now()
task.last_run_status = "success"
task.last_run_message = msg
task.run_count = (task.run_count or 0) + 1
await db.flush()
return TaskRunResponse(success=True, message=msg, affected_rows=affected, duration_ms=elapsed)
except Exception as e:
elapsed = int((time.time() - start) * 1000)
error_msg = str(e)
log.status = "failed"
log.error_message = error_msg
log.duration_ms = elapsed
log.finished_at = __import__("datetime").datetime.now()
task.last_run_at = __import__("datetime").datetime.now()
task.last_run_status = "failed"
task.last_run_message = error_msg
task.run_count = (task.run_count or 0) + 1
await db.flush()
return TaskRunResponse(success=False, message=error_msg, duration_ms=elapsed)
# ── 执行日志 ──────────────────────────────────────────────────
@router.get("/{task_id}/logs", response_model=list[ScheduledTaskLogResponse])
async def get_task_logs(
task_id: int,
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_role("admin")),
):
"""获取任务执行日志。"""
q = (
select(ScheduledTaskLog)
.where(ScheduledTaskLog.task_id == task_id)
.order_by(ScheduledTaskLog.started_at.desc())
.limit(limit)
)
result = await db.execute(q)
logs = result.scalars().all()
return [
{
"id": l.id,
"task_id": l.task_id,
"status": l.status,
"sql_content": l.sql_content,
"affected_rows": l.affected_rows or 0,
"duration_ms": l.duration_ms,
"error_message": l.error_message,
"started_at": l.started_at,
"finished_at": l.finished_at,
}
for l in logs
]