feat(调度任务): 实现调度任务管理功能

新增调度任务模块,支持任务的增删改查、启停及手动执行
- 后端添加 schedule_job 模型、服务、路由及调度器工具类
- 前端新增调度任务管理页面
- 修改 flow 相关接口将 id 类型从 String 改为 i64
- 添加 tokio-cron-scheduler 依赖实现定时任务调度
- 初始化时加载已启用任务并注册到调度器
This commit is contained in:
2025-09-24 00:21:30 +08:00
parent cadd336dee
commit 8c06849254
29 changed files with 1253 additions and 103 deletions

View File

@ -17,7 +17,7 @@ use crate::flow::engine::DriveError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowSummary {
pub id: String,
pub id: i64,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")] pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] pub remark: Option<String>,
@ -27,7 +27,7 @@ pub struct FlowSummary {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowDoc {
pub id: String,
pub id: i64,
pub yaml: String,
#[serde(skip_serializing_if = "Option::is_none")] pub design_json: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")] pub name: Option<String>,
@ -51,9 +51,15 @@ pub async fn list(db: &Db, page: u64, page_size: u64, keyword: Option<String>) -
let mut selector = db_flow::Entity::find();
if let Some(k) = keyword.filter(|s| !s.is_empty()) {
let like = format!("%{}%", k);
// 名称模糊匹配 + 若关键字可解析为数字则按ID精确匹配
selector = selector.filter(
db_flow::Column::Name.like(like.clone())
.or(db_flow::Column::Id.like(like))
.or(
match k.parse::<i64>() {
Ok(num) => db_flow::Column::Id.eq(num),
Err(_) => db_flow::Column::Name.like(like),
}
)
);
}
let paginator = selector.order_by_desc(db_flow::Column::CreatedAt).paginate(db, page_size);
@ -61,13 +67,13 @@ pub async fn list(db: &Db, page: u64, page_size: u64, keyword: Option<String>) -
let models = paginator.fetch_page(if page > 0 { page - 1 } else { 0 }).await?;
let mut items: Vec<FlowSummary> = Vec::with_capacity(models.len());
for row in models.into_iter() {
let id = row.id.clone();
let id = row.id;
let name = row
.name
.clone()
.or_else(|| row.yaml.as_deref().and_then(extract_name))
.unwrap_or_else(|| {
let prefix: String = id.chars().take(8).collect();
let prefix: String = id.to_string().chars().take(8).collect();
format!("flow_{}", prefix)
});
// 最近修改人从请求日志中查找最近一次对该flow的PUT请求
@ -98,7 +104,7 @@ pub async fn create(db: &Db, req: FlowCreateReq) -> anyhow::Result<FlowDoc> {
let _parsed: FlowDSL = serde_yaml::from_str(yaml).context("invalid flow yaml")?;
info!(target: "udmin", "flow.create: yaml parsed ok");
}
let id = crate::utils::generate_flow_id();
let id: i64 = crate::utils::generate_id();
let name = req
.name
.clone()
@ -110,7 +116,7 @@ pub async fn create(db: &Db, req: FlowCreateReq) -> anyhow::Result<FlowDoc> {
let ret_code = req.code.clone();
let ret_remark = req.remark.clone();
let am = db_flow::ActiveModel {
id: Set(id.clone()),
id: Set(id),
name: Set(name.clone()),
yaml: Set(req.yaml.clone()),
design_json: Set(design_json_str),
@ -122,16 +128,14 @@ pub async fn create(db: &Db, req: FlowCreateReq) -> anyhow::Result<FlowDoc> {
..Default::default()
};
info!(target: "udmin", "flow.create: inserting into db id={}", id);
// Use exec() instead of insert() returning Model to avoid RecordNotInserted on non-AI PK
match db_flow::Entity::insert(am).exec(db).await {
Ok(_) => {
info!(target: "udmin", "flow.create: insert ok id={}", id);
Ok(FlowDoc { id, yaml: req.yaml.unwrap_or_default(), design_json: req.design_json, name: ret_name, code: ret_code, remark: ret_remark })
}
Err(DbErr::RecordNotInserted) => {
// Workaround for MySQL + non-auto-increment PK: verify by reading back
error!(target: "udmin", "flow.create: insert returned RecordNotInserted, verifying by select id={}", id);
match db_flow::Entity::find_by_id(id.clone()).one(db).await {
match db_flow::Entity::find_by_id(id).one(db).await {
Ok(Some(_)) => {
info!(target: "udmin", "flow.create: found inserted row by id={}, treating as success", id);
Ok(FlowDoc { id, yaml: req.yaml.unwrap_or_default(), design_json: req.design_json, name, code: req.code, remark: req.remark })
@ -147,12 +151,11 @@ pub async fn create(db: &Db, req: FlowCreateReq) -> anyhow::Result<FlowDoc> {
}
}
pub async fn get(db: &Db, id: &str) -> anyhow::Result<FlowDoc> {
let row = db_flow::Entity::find_by_id(id.to_string()).one(db).await?;
pub async fn get(db: &Db, id: i64) -> anyhow::Result<FlowDoc> {
let row = db_flow::Entity::find_by_id(id).one(db).await?;
let row = row.ok_or_else(|| anyhow::anyhow!("not found"))?;
let yaml = row.yaml.unwrap_or_default();
let design_json = row.design_json.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
// 名称兜底:数据库 name 为空时,尝试从 YAML 提取
let name = row
.name
.clone()
@ -176,11 +179,11 @@ pub async fn get_by_code(db: &Db, code: &str) -> anyhow::Result<FlowDoc> {
Ok(FlowDoc { id: row.id, yaml, design_json, name, code: row.code, remark: row.remark })
}
pub async fn update(db: &Db, id: &str, req: FlowUpdateReq) -> anyhow::Result<FlowDoc> {
pub async fn update(db: &Db, id: i64, req: FlowUpdateReq) -> anyhow::Result<FlowDoc> {
if let Some(yaml) = &req.yaml {
let _parsed: FlowDSL = serde_yaml::from_str(yaml).context("invalid flow yaml")?;
}
let row = db_flow::Entity::find_by_id(id.to_string()).one(db).await?;
let row = db_flow::Entity::find_by_id(id).one(db).await?;
let Some(row) = row else { return Err(anyhow::anyhow!("not found")); };
let mut am: db_flow::ActiveModel = row.into();
@ -192,45 +195,36 @@ pub async fn update(db: &Db, id: &str, req: FlowUpdateReq) -> anyhow::Result<Flo
am.yaml = Set(Some(yaml.clone()));
} else if let Some(n) = req.name { am.name = Set(Some(n)); }
if let Some(dj) = req.design_json {
let s = serde_json::to_string(&dj)?;
am.design_json = Set(Some(s));
}
if let Some(dj) = req.design_json { let s = serde_json::to_string(&dj)?; am.design_json = Set(Some(s)); }
if let Some(c) = req.code { am.code = Set(Some(c)); }
if let Some(r) = req.remark { am.remark = Set(Some(r)); }
// update timestamp
am.updated_at = Set(Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()));
am.update(db).await?;
// return latest yaml
let got = db_flow::Entity::find_by_id(id.to_string()).one(db).await?.unwrap();
let got = db_flow::Entity::find_by_id(id).one(db).await?.unwrap();
let dj = got.design_json.as_deref().and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
Ok(FlowDoc { id: id.to_string(), yaml: got.yaml.unwrap_or_default(), design_json: dj, name: got.name, code: got.code, remark: got.remark })
Ok(FlowDoc { id, yaml: got.yaml.unwrap_or_default(), design_json: dj, name: got.name, code: got.code, remark: got.remark })
}
pub async fn delete(db: &Db, id: &str) -> anyhow::Result<()> {
let row = db_flow::Entity::find_by_id(id.to_string()).one(db).await?;
pub async fn delete(db: &Db, id: i64) -> anyhow::Result<()> {
let row = db_flow::Entity::find_by_id(id).one(db).await?;
let Some(row) = row else { return Err(anyhow::anyhow!("not found")); };
let am: db_flow::ActiveModel = row.into();
am.delete(db).await?;
Ok(())
}
pub async fn run(db: &Db, id: &str, req: RunReq, operator: Option<(i64, String)>) -> anyhow::Result<RunResult> {
pub async fn run(db: &Db, id: i64, req: RunReq, operator: Option<(i64, String)>) -> anyhow::Result<RunResult> {
let log_handler = DatabaseLogHandler::new(db.clone());
match run_internal(db, id, req, operator, &log_handler, None).await {
Ok((ctx, logs)) => Ok(RunResult { ok: true, ctx, logs }),
Err(e) => {
// 将运行期错误转换为 ok=false并尽量带上部分 ctx/logs
if let Some(de) = e.downcast_ref::<DriveError>().cloned() {
Ok(RunResult { ok: false, ctx: de.ctx, logs: de.logs })
} else {
let mut full = e.to_string();
for cause in e.chain().skip(1) {
full.push_str(" | ");
full.push_str(&cause.to_string());
}
for cause in e.chain().skip(1) { full.push_str(" | "); full.push_str(&cause.to_string()); }
Ok(RunResult { ok: false, ctx: serde_json::json!({}), logs: vec![full] })
}
}
@ -240,18 +234,16 @@ pub async fn run(db: &Db, id: &str, req: RunReq, operator: Option<(i64, String)>
// 新增:流式运行,向外发送节点事件与最终完成事件
pub async fn run_with_stream(
db: Db,
id: &str,
id: i64,
req: RunReq,
operator: Option<(i64, String)>,
event_tx: Sender<StreamEvent>,
) -> anyhow::Result<()> {
// clone 一份用于错误时补发 done
let tx_done = event_tx.clone();
let log_handler = SseLogHandler::new(db.clone(), event_tx.clone());
match run_internal(&db, id, req, operator, &log_handler, Some(event_tx)).await {
Ok((_ctx, _logs)) => Ok(()), // 正常路径log_success 内已发送 done(true,...)
Ok((_ctx, _logs)) => Ok(()),
Err(e) => {
// 错误路径:先在 log_error 中已发送 error 事件;此处补发 done(false,...)
if let Some(de) = e.downcast_ref::<DriveError>().cloned() {
crate::middlewares::sse::emit_done(&tx_done, false, de.ctx, de.logs).await;
} else {
@ -267,23 +259,17 @@ pub async fn run_with_stream(
// 内部统一的运行方法
async fn run_internal(
db: &Db,
id: &str,
id: i64,
req: RunReq,
operator: Option<(i64, String)>,
log_handler: &dyn FlowLogHandler,
event_tx: Option<Sender<StreamEvent>>,
event_tx: Option<Sender<StreamEvent>>,
) -> anyhow::Result<(serde_json::Value, Vec<String>)> {
// 使用传入的 event_tx当启用 SSE 时由路由层提供)
info!(target = "udmin", "flow.run_internal: start id={}", id);
let start = Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap());
// 获取流程编码
let flow_code: Option<String> = match db_flow::Entity::find_by_id(id.to_string()).one(db).await {
Ok(Some(row)) => row.code,
_ => None,
};
// 获取流程文档
let flow_code: Option<String> = match db_flow::Entity::find_by_id(id).one(db).await { Ok(Some(row)) => row.code, _ => None };
let doc = match get(db, id).await {
Ok(d) => d,
Err(e) => {
@ -380,7 +366,6 @@ async fn run_internal(
Err(e) => {
error!(target = "udmin", error = ?e, "flow.run_internal: engine drive failed id={}", id);
let dur = (Utc::now().with_timezone(&FixedOffset::east_opt(0).unwrap()) - start).num_milliseconds() as i64;
// 优先记录详细错误(包含部分 ctx 与累计 logs
if let Some(de) = e.downcast_ref::<DriveError>().cloned() {
log_handler
.log_error_detail(