feat(scheduler): 定时任务后端持久化 + Pipeline trigger 编译修复

S4/S8 定时任务后端:
- 新增 scheduled_tasks 表 (migration v7)
- 新增 scheduled_task CRUD 模块 (handlers/service/types)
- 注册 /api/scheduler/tasks 路由 (GET/POST/PATCH/DELETE)
- 新增 start_user_task_scheduler() 30秒轮询循环
- 支持 cron/interval/once 三种调度类型
- once 类型执行后自动禁用

修复:
- pipeline_commands.rs: 修复 pipeline.trigger 字段不存在的编译错误
  (Pipeline 结构体无 trigger 字段,改用 metadata.tags/description)
This commit is contained in:
iven
2026-03-30 19:46:45 +08:00
parent c2aff09811
commit a0bbd4ba82
10 changed files with 457 additions and 5 deletions

View File

@@ -0,0 +1,79 @@
//! 定时任务 HTTP 处理器
use axum::{
extract::{State, Path, Extension},
http::StatusCode,
Json,
};
use crate::state::AppState;
use crate::error::SaasResult;
use crate::auth::types::AuthContext;
use super::{types::*, service};
/// POST /api/scheduler/tasks — 创建定时任务
pub async fn create_task(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<CreateScheduledTaskRequest>,
) -> SaasResult<(StatusCode, Json<ScheduledTaskResponse>)> {
// 验证
if req.name.is_empty() {
return Err(crate::error::SaasError::InvalidInput("任务名称不能为空".into()));
}
if req.schedule.is_empty() {
return Err(crate::error::SaasError::InvalidInput("调度表达式不能为空".into()));
}
if !["cron", "interval", "once"].contains(&req.schedule_type.as_str()) {
return Err(crate::error::SaasError::InvalidInput(
format!("无效的 schedule_type: {},可选: cron, interval, once", req.schedule_type)
));
}
if !["agent", "hand", "workflow"].contains(&req.target.target_type.as_str()) {
return Err(crate::error::SaasError::InvalidInput(
format!("无效的 target_type: {},可选: agent, hand, workflow", req.target.target_type)
));
}
let resp = service::create_task(&state.db, &ctx.account_id, &req).await?;
Ok((StatusCode::CREATED, Json(resp)))
}
/// GET /api/scheduler/tasks — 列出定时任务
pub async fn list_tasks(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<Vec<ScheduledTaskResponse>>> {
let tasks = service::list_tasks(&state.db, &ctx.account_id).await?;
Ok(Json(tasks))
}
/// GET /api/scheduler/tasks/:id — 获取单个定时任务
pub async fn get_task(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
) -> SaasResult<Json<ScheduledTaskResponse>> {
let task = service::get_task(&state.db, &ctx.account_id, &id).await?;
Ok(Json(task))
}
/// PATCH /api/scheduler/tasks/:id — 更新定时任务
pub async fn update_task(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
Json(req): Json<UpdateScheduledTaskRequest>,
) -> SaasResult<Json<ScheduledTaskResponse>> {
let task = service::update_task(&state.db, &ctx.account_id, &id, &req).await?;
Ok(Json(task))
}
/// DELETE /api/scheduler/tasks/:id — 删除定时任务
pub async fn delete_task(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
) -> SaasResult<StatusCode> {
service::delete_task(&state.db, &ctx.account_id, &id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -0,0 +1,15 @@
//! 用户定时任务管理模块
pub mod types;
pub mod service;
pub mod handlers;
use axum::routing::{get, post, patch, delete};
use crate::state::AppState;
/// 定时任务路由 (需要认证)
pub fn routes() -> axum::Router<AppState> {
axum::Router::new()
.route("/api/scheduler/tasks", get(handlers::list_tasks).post(handlers::create_task))
.route("/api/scheduler/tasks/:id", get(handlers::get_task).patch(handlers::update_task).delete(handlers::delete_task))
}

View File

@@ -0,0 +1,195 @@
//! 定时任务数据库服务层
use sqlx::{PgPool, FromRow};
use crate::error::SaasResult;
use super::types::*;
/// 数据库行结构
#[derive(Debug, FromRow)]
struct ScheduledTaskRow {
id: String,
account_id: String,
name: String,
description: Option<String>,
schedule: String,
schedule_type: String,
target_type: String,
target_id: String,
enabled: bool,
last_run_at: Option<String>,
next_run_at: Option<String>,
run_count: i32,
last_error: Option<String>,
input_payload: Option<serde_json::Value>,
created_at: String,
}
impl ScheduledTaskRow {
fn to_response(&self) -> ScheduledTaskResponse {
ScheduledTaskResponse {
id: self.id.clone(),
name: self.name.clone(),
schedule: self.schedule.clone(),
schedule_type: self.schedule_type.clone(),
target: TaskTarget {
target_type: self.target_type.clone(),
id: self.target_id.clone(),
},
enabled: self.enabled,
description: self.description.clone(),
last_run: self.last_run_at.clone(),
next_run: self.next_run_at.clone(),
run_count: self.run_count,
last_error: self.last_error.clone(),
created_at: self.created_at.clone(),
}
}
}
/// 创建定时任务
pub async fn create_task(
db: &PgPool,
account_id: &str,
req: &CreateScheduledTaskRequest,
) -> SaasResult<ScheduledTaskResponse> {
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
let input_json = req.input.as_ref().map(|v| v.to_string());
sqlx::query(
"INSERT INTO scheduled_tasks (id, account_id, name, description, schedule, schedule_type, target_type, target_id, enabled, input_payload, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)"
)
.bind(&id)
.bind(account_id)
.bind(&req.name)
.bind(&req.description)
.bind(&req.schedule)
.bind(&req.schedule_type)
.bind(&req.target.target_type)
.bind(&req.target.id)
.bind(req.enabled.unwrap_or(true))
.bind(&input_json)
.bind(&now)
.execute(db)
.await?;
Ok(ScheduledTaskResponse {
id,
name: req.name.clone(),
schedule: req.schedule.clone(),
schedule_type: req.schedule_type.clone(),
target: req.target.clone(),
enabled: req.enabled.unwrap_or(true),
description: req.description.clone(),
last_run: None,
next_run: None,
run_count: 0,
last_error: None,
created_at: now,
})
}
/// 列出用户的定时任务
pub async fn list_tasks(
db: &PgPool,
account_id: &str,
) -> SaasResult<Vec<ScheduledTaskResponse>> {
let rows: Vec<ScheduledTaskRow> = sqlx::query_as(
"SELECT id, account_id, name, description, schedule, schedule_type,
target_type, target_id, enabled, last_run_at, next_run_at,
run_count, last_error, input_payload, created_at
FROM scheduled_tasks WHERE account_id = $1 ORDER BY created_at DESC"
)
.bind(account_id)
.fetch_all(db)
.await?;
Ok(rows.iter().map(|r| r.to_response()).collect())
}
/// 获取单个定时任务
pub async fn get_task(
db: &PgPool,
account_id: &str,
task_id: &str,
) -> SaasResult<ScheduledTaskResponse> {
let row: Option<ScheduledTaskRow> = sqlx::query_as(
"SELECT id, account_id, name, description, schedule, schedule_type,
target_type, target_id, enabled, last_run_at, next_run_at,
run_count, last_error, input_payload, created_at
FROM scheduled_tasks WHERE id = $1 AND account_id = $2"
)
.bind(task_id)
.bind(account_id)
.fetch_optional(db)
.await?;
Ok(row
.ok_or_else(|| crate::error::SaasError::NotFound("定时任务不存在".into()))?
.to_response())
}
/// 更新定时任务
pub async fn update_task(
db: &PgPool,
account_id: &str,
task_id: &str,
req: &UpdateScheduledTaskRequest,
) -> SaasResult<ScheduledTaskResponse> {
let existing = get_task(db, account_id, task_id).await?;
let name = req.name.as_deref().unwrap_or(&existing.name);
let schedule = req.schedule.as_deref().unwrap_or(&existing.schedule);
let schedule_type = req.schedule_type.as_deref().unwrap_or(&existing.schedule_type);
let enabled = req.enabled.unwrap_or(existing.enabled);
let description = req.description.as_deref().or(existing.description.as_deref());
let now = chrono::Utc::now().to_rfc3339();
let (target_type, target_id) = if let Some(ref target) = req.target {
(target.target_type.as_str(), target.id.as_str())
} else {
(existing.target.target_type.as_str(), existing.target.id.as_str())
};
sqlx::query(
"UPDATE scheduled_tasks SET name = $1, schedule = $2, schedule_type = $3,
target_type = $4, target_id = $5, enabled = $6, description = $7,
updated_at = $8
WHERE id = $9 AND account_id = $10"
)
.bind(name)
.bind(schedule)
.bind(schedule_type)
.bind(target_type)
.bind(target_id)
.bind(enabled)
.bind(description)
.bind(&now)
.bind(task_id)
.bind(account_id)
.execute(db)
.await?;
get_task(db, account_id, task_id).await
}
/// 删除定时任务
pub async fn delete_task(
db: &PgPool,
account_id: &str,
task_id: &str,
) -> SaasResult<()> {
let result = sqlx::query(
"DELETE FROM scheduled_tasks WHERE id = $1 AND account_id = $2"
)
.bind(task_id)
.bind(account_id)
.execute(db)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::SaasError::NotFound("定时任务不存在".into()));
}
Ok(())
}

View File

@@ -0,0 +1,63 @@
//! 定时任务类型定义
use serde::{Deserialize, Serialize};
/// 创建定时任务请求
#[derive(Debug, Deserialize)]
pub struct CreateScheduledTaskRequest {
pub name: String,
pub schedule: String,
/// "cron" | "interval" | "once"
#[serde(default = "default_schedule_type")]
pub schedule_type: String,
pub target: TaskTarget,
pub description: Option<String>,
#[serde(default = "default_enabled")]
pub enabled: Option<bool>,
pub input: Option<serde_json::Value>,
}
fn default_schedule_type() -> String {
"cron".to_string()
}
fn default_enabled() -> Option<bool> {
Some(true)
}
/// 任务目标
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TaskTarget {
#[serde(rename = "type")]
pub target_type: String,
pub id: String,
}
/// 更新定时任务请求
#[derive(Debug, Deserialize)]
pub struct UpdateScheduledTaskRequest {
pub name: Option<String>,
pub schedule: Option<String>,
pub schedule_type: Option<String>,
pub target: Option<TaskTarget>,
pub description: Option<String>,
pub enabled: Option<bool>,
pub input: Option<serde_json::Value>,
}
/// 定时任务响应
#[derive(Debug, Serialize)]
pub struct ScheduledTaskResponse {
pub id: String,
pub name: String,
pub schedule: String,
pub schedule_type: String,
pub target: TaskTarget,
pub enabled: bool,
pub description: Option<String>,
pub last_run: Option<String>,
pub next_run: Option<String>,
pub run_count: i32,
pub last_error: Option<String>,
pub created_at: String,
}