# -*- 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)