schedule_routes.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. """定时任务路由(CRUD + 手动触发 + 执行记录)。
  2. 写操作的纪律:**先落 DB,再同步调度器 job**。反过来的话,DB 写失败会留下
  3. 一个「内存里有 job、DB 里没有」的幽灵任务,重启就消失——排查起来很费劲。
  4. """
  5. from __future__ import annotations
  6. import asyncio
  7. from fastapi import APIRouter, HTTPException
  8. from api.models import ScheduleCreateRequest, ScheduleUpdateRequest
  9. from db import schedule_store
  10. from obs import get_logger
  11. from scheduler import scheduler as sched
  12. logger = get_logger(__name__)
  13. router = APIRouter(tags=["定时任务"])
  14. def _validate_cron(expr: str) -> str:
  15. """校验 cron 表达式,非法直接 400(不要让脏数据进 DB)。"""
  16. value = (expr or "").strip()
  17. try:
  18. sched._parse_cron(value)
  19. except Exception as exc:
  20. raise HTTPException(
  21. status_code=400,
  22. detail={
  23. "code": "invalid_cron",
  24. "message": f"cron 表达式非法(需 5 段:分 时 日 月 周):{value!r} — {exc}",
  25. },
  26. ) from exc
  27. return value
  28. def _require_schedule(schedule_id: str):
  29. schedule = schedule_store.get_schedule(schedule_id)
  30. if schedule is None:
  31. raise HTTPException(
  32. status_code=404, detail={"code": "schedule_not_found", "message": "定时任务不存在"}
  33. )
  34. return schedule
  35. @router.get("/schedules")
  36. async def list_schedules(user_name: str | None = None):
  37. """列出定时任务,附带最近一次执行状态。"""
  38. items = await asyncio.to_thread(schedule_store.list_schedules, user_name, False, 200)
  39. for item in items:
  40. latest = await asyncio.to_thread(schedule_store.latest_run, item["id"])
  41. item["latest_run"] = latest
  42. return {"items": items, "count": len(items), "scheduler_running": sched.is_running()}
  43. @router.post("/schedules")
  44. async def create_schedule(payload: ScheduleCreateRequest):
  45. """新建定时任务。"""
  46. cron = _validate_cron(payload.cron)
  47. if not (payload.prompt or "").strip():
  48. raise HTTPException(
  49. status_code=400, detail={"code": "empty_prompt", "message": "prompt 不能为空"}
  50. )
  51. user = (payload.user_name or "default").strip() or "default"
  52. # 1) 先落 DB(事实源)
  53. schedule = await asyncio.to_thread(
  54. schedule_store.create_schedule,
  55. user, payload.title, cron, payload.prompt, payload.mode, payload.enabled,
  56. )
  57. # 2) 再投影到调度器
  58. if schedule.get("enabled"):
  59. try:
  60. await asyncio.to_thread(sched.add_job, schedule)
  61. except Exception as exc:
  62. # DB 已写入但 job 装载失败:任务仍在,只是这轮没生效。
  63. # 不回滚 DB——用户重试或重启服务后会自动补上。
  64. logger.error("任务已入库但装载失败 id=%s:%s", schedule["id"], exc)
  65. schedule["load_error"] = str(exc)
  66. return schedule
  67. @router.get("/schedules/{schedule_id}")
  68. async def get_schedule(schedule_id: str):
  69. """取任务详情与执行记录。"""
  70. schedule = await asyncio.to_thread(_require_schedule, schedule_id)
  71. runs = await asyncio.to_thread(schedule_store.list_runs, schedule_id, 20)
  72. return {**schedule, "runs": runs}
  73. @router.put("/schedules/{schedule_id}")
  74. async def update_schedule(schedule_id: str, payload: ScheduleUpdateRequest):
  75. """更新任务。传了 cron 就先校验;传了 enabled 就同步 job。"""
  76. await asyncio.to_thread(_require_schedule, schedule_id)
  77. fields: dict = {}
  78. if payload.cron is not None:
  79. fields["cron"] = _validate_cron(payload.cron)
  80. if payload.title is not None:
  81. fields["title"] = payload.title
  82. if payload.prompt is not None:
  83. fields["prompt"] = payload.prompt
  84. if payload.mode is not None:
  85. fields["mode"] = payload.mode
  86. if payload.enabled is not None:
  87. fields["enabled"] = payload.enabled
  88. if not fields:
  89. raise HTTPException(
  90. status_code=400, detail={"code": "no_fields", "message": "没有需要更新的字段"}
  91. )
  92. schedule = await asyncio.to_thread(schedule_store.update_schedule, schedule_id, **fields)
  93. if schedule is None:
  94. raise HTTPException(
  95. status_code=404, detail={"code": "schedule_not_found", "message": "定时任务不存在"}
  96. )
  97. await asyncio.to_thread(sched.sync_job, schedule)
  98. return schedule
  99. @router.delete("/schedules/{schedule_id}")
  100. async def delete_schedule(schedule_id: str):
  101. """删除任务及其执行记录。"""
  102. await asyncio.to_thread(_require_schedule, schedule_id)
  103. await asyncio.to_thread(sched.remove_job, schedule_id)
  104. deleted = await asyncio.to_thread(schedule_store.delete_schedule, schedule_id)
  105. return {"deleted": deleted, "schedule_id": schedule_id}
  106. @router.post("/schedules/{schedule_id}/pause")
  107. async def pause_schedule(schedule_id: str):
  108. """暂停任务(保留定义,摘掉 job)。"""
  109. await asyncio.to_thread(_require_schedule, schedule_id)
  110. schedule = await asyncio.to_thread(schedule_store.set_enabled, schedule_id, False)
  111. await asyncio.to_thread(sched.remove_job, schedule_id)
  112. return {"schedule_id": schedule_id, "enabled": False}
  113. @router.post("/schedules/{schedule_id}/resume")
  114. async def resume_schedule(schedule_id: str):
  115. """恢复任务。"""
  116. await asyncio.to_thread(_require_schedule, schedule_id)
  117. schedule = await asyncio.to_thread(schedule_store.set_enabled, schedule_id, True)
  118. if schedule is not None:
  119. # cron 可能在这期间被改坏,装载失败要如实报出来
  120. try:
  121. await asyncio.to_thread(sched.add_job, schedule)
  122. except Exception as exc:
  123. raise HTTPException(
  124. status_code=400,
  125. detail={"code": "invalid_cron", "message": f"任务已启用但装载失败:{exc}"},
  126. ) from exc
  127. return {"schedule_id": schedule_id, "enabled": True}
  128. @router.post("/schedules/{schedule_id}/run")
  129. async def run_now(schedule_id: str):
  130. """立即执行一次(不影响后续 cron 计划)。
  131. 同步等待执行完成——手动触发是用户主动点的,他想立刻看到结果。
  132. 不放进后台是因为「点了没反应」比「等一会儿」更糟。
  133. """
  134. from scheduler.runner import run_schedule
  135. await asyncio.to_thread(_require_schedule, schedule_id)
  136. result = await run_schedule(schedule_id, trigger="manual")
  137. if not result.get("ok"):
  138. raise HTTPException(
  139. status_code=500,
  140. detail={"code": "run_failed", "message": result.get("error") or "执行失败"},
  141. )
  142. return result
  143. @router.get("/schedules/{schedule_id}/runs")
  144. async def list_runs(schedule_id: str, limit: int = 20):
  145. """任务的执行记录。"""
  146. await asyncio.to_thread(_require_schedule, schedule_id)
  147. items = await asyncio.to_thread(schedule_store.list_runs, schedule_id, min(limit, 100))
  148. return {"schedule_id": schedule_id, "items": items, "count": len(items)}