294 lines
9.0 KiB
Python
294 lines
9.0 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""调度任务 CRUD API
|
||
|
||
提供 5 个端点:
|
||
- GET /api/schedules — 列表(按 site_id 过滤)
|
||
- POST /api/schedules — 创建
|
||
- PUT /api/schedules/{id} — 更新
|
||
- DELETE /api/schedules/{id} — 删除
|
||
- PATCH /api/schedules/{id}/toggle — 启用/禁用
|
||
|
||
所有端点需要 JWT 认证,site_id 从 JWT 提取。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
|
||
from app.auth.dependencies import CurrentUser, get_current_user
|
||
from app.database import get_connection
|
||
from app.schemas.schedules import (
|
||
CreateScheduleRequest,
|
||
ScheduleResponse,
|
||
UpdateScheduleRequest,
|
||
)
|
||
from app.services.scheduler import calculate_next_run
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/schedules", tags=["调度管理"])
|
||
|
||
|
||
|
||
def _row_to_response(row) -> ScheduleResponse:
|
||
"""将数据库行转换为 ScheduleResponse。"""
|
||
task_config = row[4] if isinstance(row[4], dict) else json.loads(row[4])
|
||
schedule_config = row[5] if isinstance(row[5], dict) else json.loads(row[5])
|
||
return ScheduleResponse(
|
||
id=str(row[0]),
|
||
site_id=row[1],
|
||
name=row[2],
|
||
task_codes=row[3] or [],
|
||
task_config=task_config,
|
||
schedule_config=schedule_config,
|
||
enabled=row[6],
|
||
last_run_at=row[7],
|
||
next_run_at=row[8],
|
||
run_count=row[9],
|
||
last_status=row[10],
|
||
created_at=row[11],
|
||
updated_at=row[12],
|
||
)
|
||
|
||
|
||
# 查询列列表,复用于多个端点
|
||
_SELECT_COLS = """
|
||
id, site_id, name, task_codes, task_config, schedule_config,
|
||
enabled, last_run_at, next_run_at, run_count, last_status,
|
||
created_at, updated_at
|
||
"""
|
||
|
||
|
||
# ── GET /api/schedules — 列表 ────────────────────────────────
|
||
|
||
@router.get("", response_model=list[ScheduleResponse])
|
||
async def list_schedules(
|
||
user: CurrentUser = Depends(get_current_user),
|
||
) -> list[ScheduleResponse]:
|
||
"""获取当前门店的所有调度任务。"""
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
f"SELECT {_SELECT_COLS} FROM scheduled_tasks WHERE site_id = %s ORDER BY created_at DESC",
|
||
(user.site_id,),
|
||
)
|
||
rows = cur.fetchall()
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
return [_row_to_response(row) for row in rows]
|
||
|
||
|
||
# ── POST /api/schedules — 创建 ──────────────────────────────
|
||
|
||
@router.post("", response_model=ScheduleResponse, status_code=status.HTTP_201_CREATED)
|
||
async def create_schedule(
|
||
body: CreateScheduleRequest,
|
||
user: CurrentUser = Depends(get_current_user),
|
||
) -> ScheduleResponse:
|
||
"""创建调度任务,自动计算 next_run_at。"""
|
||
now = datetime.now(timezone.utc)
|
||
next_run = calculate_next_run(body.schedule_config, now)
|
||
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
f"""
|
||
INSERT INTO scheduled_tasks
|
||
(site_id, name, task_codes, task_config, schedule_config, enabled, next_run_at)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||
RETURNING {_SELECT_COLS}
|
||
""",
|
||
(
|
||
user.site_id,
|
||
body.name,
|
||
body.task_codes,
|
||
json.dumps(body.task_config),
|
||
body.schedule_config.model_dump_json(),
|
||
body.schedule_config.enabled,
|
||
next_run,
|
||
),
|
||
)
|
||
row = cur.fetchone()
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
raise
|
||
finally:
|
||
conn.close()
|
||
|
||
return _row_to_response(row)
|
||
|
||
|
||
# ── PUT /api/schedules/{id} — 更新 ──────────────────────────
|
||
|
||
@router.put("/{schedule_id}", response_model=ScheduleResponse)
|
||
async def update_schedule(
|
||
schedule_id: str,
|
||
body: UpdateScheduleRequest,
|
||
user: CurrentUser = Depends(get_current_user),
|
||
) -> ScheduleResponse:
|
||
"""更新调度任务,仅更新请求中提供的字段。"""
|
||
# 构建动态 SET 子句
|
||
set_parts: list[str] = []
|
||
params: list = []
|
||
|
||
if body.name is not None:
|
||
set_parts.append("name = %s")
|
||
params.append(body.name)
|
||
if body.task_codes is not None:
|
||
set_parts.append("task_codes = %s")
|
||
params.append(body.task_codes)
|
||
if body.task_config is not None:
|
||
set_parts.append("task_config = %s")
|
||
params.append(json.dumps(body.task_config))
|
||
if body.schedule_config is not None:
|
||
set_parts.append("schedule_config = %s")
|
||
params.append(body.schedule_config.model_dump_json())
|
||
# 更新调度配置时重新计算 next_run_at
|
||
now = datetime.now(timezone.utc)
|
||
next_run = calculate_next_run(body.schedule_config, now)
|
||
set_parts.append("next_run_at = %s")
|
||
params.append(next_run)
|
||
|
||
if not set_parts:
|
||
raise HTTPException(
|
||
status_code=422,
|
||
detail="至少需要提供一个更新字段",
|
||
)
|
||
|
||
set_parts.append("updated_at = NOW()")
|
||
set_clause = ", ".join(set_parts)
|
||
params.extend([schedule_id, user.site_id])
|
||
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
f"""
|
||
UPDATE scheduled_tasks
|
||
SET {set_clause}
|
||
WHERE id = %s AND site_id = %s
|
||
RETURNING {_SELECT_COLS}
|
||
""",
|
||
params,
|
||
)
|
||
row = cur.fetchone()
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
raise
|
||
finally:
|
||
conn.close()
|
||
|
||
if row is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="调度任务不存在",
|
||
)
|
||
|
||
return _row_to_response(row)
|
||
|
||
|
||
# ── DELETE /api/schedules/{id} — 删除 ────────────────────────
|
||
|
||
@router.delete("/{schedule_id}")
|
||
async def delete_schedule(
|
||
schedule_id: str,
|
||
user: CurrentUser = Depends(get_current_user),
|
||
) -> dict:
|
||
"""删除调度任务。"""
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"DELETE FROM scheduled_tasks WHERE id = %s AND site_id = %s",
|
||
(schedule_id, user.site_id),
|
||
)
|
||
deleted = cur.rowcount
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
raise
|
||
finally:
|
||
conn.close()
|
||
|
||
if deleted == 0:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="调度任务不存在",
|
||
)
|
||
|
||
return {"message": "调度任务已删除"}
|
||
|
||
|
||
# ── PATCH /api/schedules/{id}/toggle — 启用/禁用 ─────────────
|
||
|
||
@router.patch("/{schedule_id}/toggle", response_model=ScheduleResponse)
|
||
async def toggle_schedule(
|
||
schedule_id: str,
|
||
user: CurrentUser = Depends(get_current_user),
|
||
) -> ScheduleResponse:
|
||
"""切换调度任务的启用/禁用状态。
|
||
|
||
禁用时 next_run_at 置 NULL;启用时重新计算 next_run_at。
|
||
"""
|
||
conn = get_connection()
|
||
try:
|
||
# 先查询当前状态和调度配置
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"SELECT enabled, schedule_config FROM scheduled_tasks WHERE id = %s AND site_id = %s",
|
||
(schedule_id, user.site_id),
|
||
)
|
||
row = cur.fetchone()
|
||
|
||
if row is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail="调度任务不存在",
|
||
)
|
||
|
||
current_enabled = row[0]
|
||
new_enabled = not current_enabled
|
||
|
||
if new_enabled:
|
||
# 启用:重新计算 next_run_at
|
||
schedule_config_raw = row[1] if isinstance(row[1], dict) else json.loads(row[1])
|
||
from app.schemas.schedules import ScheduleConfigSchema
|
||
schedule_cfg = ScheduleConfigSchema(**schedule_config_raw)
|
||
now = datetime.now(timezone.utc)
|
||
next_run = calculate_next_run(schedule_cfg, now)
|
||
else:
|
||
# 禁用:next_run_at 置 NULL
|
||
next_run = None
|
||
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
f"""
|
||
UPDATE scheduled_tasks
|
||
SET enabled = %s, next_run_at = %s, updated_at = NOW()
|
||
WHERE id = %s AND site_id = %s
|
||
RETURNING {_SELECT_COLS}
|
||
""",
|
||
(new_enabled, next_run, schedule_id, user.site_id),
|
||
)
|
||
updated_row = cur.fetchone()
|
||
conn.commit()
|
||
except HTTPException:
|
||
raise
|
||
except Exception:
|
||
conn.rollback()
|
||
raise
|
||
finally:
|
||
conn.close()
|
||
|
||
return _row_to_response(updated_row)
|