Files
udmin/backend/src/flow/dsl.rs
ayou 75c6974a35 feat(flow): 新增分组执行与异步模式支持
refactor(executors): 将 Rhai 引擎评估逻辑迁移至 script_rhai 模块
docs: 添加 Flow 架构文档与示例 JSON
feat(i18n): 新增前端多语言支持
perf(axios): 优化 token 刷新与 401 处理逻辑
style: 统一代码格式化与简化条件判断
2025-12-03 20:51:22 +08:00

396 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 模块:流程 DSL 与自由布局 Design JSON 的解析、校验与构建。
//! 主要内容:
//! - FlowDSL/NodeDSL/EdgeDSL较为“表述性”的简化 DSL 结构(用于外部接口/入库)。
//! - DesignSyntax/NodeSyntax/EdgeSyntax与前端自由布局 JSON 对齐的结构(含 source_port_id 等)。
//! - validate_design基础校验节点 ID 唯一、至少包含一个 start 与一个 end、边的引用合法
//! - build_chain_from_design将自由布局 JSON 转换为内部 ChainDef含条件节点 AND 组装等启发式与兼容逻辑)。
//! - chain_from_design_json统一入口支持字符串/对象两种输入,做兼容字段回填后再校验并构建。
//! 说明:尽量保持向后兼容;在条件节点的出边组装上采用启发式(例如:单出边 + 多条件 => 组装为 AND 条件组)。
use serde::{Deserialize, Serialize};
use serde_json::Value;
use anyhow::bail;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowDSL {
#[serde(default)]
/// 流程名称(可选)
pub name: String,
#[serde(default, alias = "executionMode")]
/// 执行模式(兼容前端 executionModesync/async目前仅占位
pub execution_mode: Option<String>,
/// 节点列表(按声明顺序)
pub nodes: Vec<NodeDSL>,
#[serde(default)]
/// 边列表from -> to可选 condition
pub edges: Vec<EdgeDSL>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeDSL {
/// 节点唯一 ID字符串
pub id: String,
#[serde(default)]
/// 节点类型start / end / task / condition开始/结束/任务/条件)
pub kind: String, // 节点类型start / end / task / condition开始/结束/任务/条件)
#[serde(default)]
/// 节点显示名称(可选)
pub name: String,
#[serde(default)]
/// 任务标识(绑定执行器),如 http/db/variable/script_*(可选)
pub task: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EdgeDSL {
/// 起点节点 ID别名source/from
#[serde(alias = "source", alias = "from", rename = "from")]
pub from: String,
/// 终点节点 ID别名target/to
#[serde(alias = "target", alias = "to", rename = "to")]
pub to: String,
#[serde(default)]
/// 条件表达式(字符串):
/// - 若为 JSON 字符串(以 { 或 [ 开头),则按 JSON 条件集合进行求值;
/// - 否则按 Rhai 表达式求值;
/// - 空字符串/None 表示无条件。
pub condition: Option<String>,
}
impl From<FlowDSL> for super::domain::ChainDef {
/// 将简化 DSL 转换为内部 ChainDef
/// - kind 映射start/end/condition/其他->task支持 decision 别名 -> condition。
/// - 直接搬运 edges 的 from/to/condition。
fn from(v: FlowDSL) -> Self {
super::domain::ChainDef {
name: v.name,
nodes: v
.nodes
.into_iter()
.map(|n| super::domain::NodeDef {
id: super::domain::NodeId(n.id),
kind: match n.kind.to_lowercase().as_str() {
"start" => super::domain::NodeKind::Start,
"end" => super::domain::NodeKind::End,
"decision" | "condition" => super::domain::NodeKind::Condition,
_ => super::domain::NodeKind::Task,
},
name: n.name,
task: n.task,
})
.collect(),
links: v
.edges
.into_iter()
.map(|e| super::domain::LinkDef {
from: super::domain::NodeId(e.from),
to: super::domain::NodeId(e.to),
condition: e.condition,
})
.collect(),
groups: std::collections::HashMap::default(),
}
}
}
// ===== 将 design_json前端自由布局 JSON解析为 ChainDef =====
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DesignSyntax {
#[serde(default)]
/// 设计名称(可选)
pub name: String,
#[serde(default)]
/// 节点集合(自由布局)
pub nodes: Vec<NodeSyntax>,
#[serde(default)]
/// 边集合(自由布局)
pub edges: Vec<EdgeSyntax>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeSyntax {
/// 节点 ID
pub id: String,
#[serde(rename = "type", default)]
/// 前端类型start | end | condition | http | db | task | script_*(用于推断具体执行器)
pub kind: String, // 取值: start | end | condition | http | db | task开始/结束/条件/HTTP/数据库/通用任务)
#[serde(default)]
/// 节点附加数据title/conditions/scripts 等
pub data: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EdgeSyntax {
/// 起点(兼容 sourceNodeID/source/from
#[serde(alias = "sourceNodeID", alias = "source", alias = "from")]
pub from: String,
/// 终点(兼容 targetNodeID/target/to
#[serde(alias = "targetNodeID", alias = "target", alias = "to")]
pub to: String,
#[serde(default)]
/// 源端口 ID用于条件节点端口到条件 key 的兼容映射;
/// 特殊值 and/all/group/true 表示将节点内所有 conditions 的 value 组装为 AND 组。
pub source_port_id: Option<String>,
}
/// 设计级别校验:
/// - 节点 ID 唯一且非空;
/// - 至少一个 start 与一个 end
/// - 边的 from/to 必须指向已知节点。
fn validate_design(design: &DesignSyntax) -> anyhow::Result<()> {
use std::collections::HashSet;
let mut ids = HashSet::new();
for n in &design.nodes {
// 节点 ID 不能为空,且在一个流程内必须唯一
if n.id.trim().is_empty() { bail!("node id is required"); }
if !ids.insert(n.id.clone()) { bail!("duplicate node id: {}", n.id); }
}
// 确保至少包含一个开始节点与一个结束节点
let mut start = 0usize;
let mut end = 0usize;
for n in &design.nodes {
match n.kind.as_str() {
"start" => start += 1,
"end" => end += 1,
_ => {}
}
}
anyhow::ensure!(start >= 1, "flow must have at least one start node");
anyhow::ensure!(end >= 1, "flow must have at least one end node");
// 校验边的引用合法性from/to 必须存在且指向已知节点)
for e in &design.edges {
if e.from.is_empty() || e.to.is_empty() { bail!("edge must have both from and to"); }
if !ids.contains(&e.from) { bail!("edge from references unknown node: {}", e.from); }
if !ids.contains(&e.to) { bail!("edge to references unknown node: {}", e.to); }
}
Ok(())
}
/// 将自由布局 DesignSyntax 转换为内部 ChainDef
/// - 节点:推断 kind/name/task含 scripts 与 inline script/expr 的兼容);
/// - 边:
/// * 条件节点:支持 source_port_id 到 data.conditions 的旧版映射;
/// * 当 source_port_id 为空或为 and/all/group/true取 conditions 的 value 组成 AND 组;
/// * 启发式:若条件节点仅一条出边且包含多个 conditions即便 source_port_id 指向具体 key也回退为 AND 组;
/// * 非条件节点:不处理条件。
fn build_chain_from_design(design: &DesignSyntax) -> anyhow::Result<super::domain::ChainDef> {
use super::domain::{ChainDef, NodeDef, NodeId, NodeKind, LinkDef};
let mut nodes: Vec<NodeDef> = Vec::new();
let mut groups: std::collections::HashMap<String, super::domain::GroupDef> = std::collections::HashMap::new();
for n in &design.nodes {
let kind = match n.kind.as_str() {
"start" => NodeKind::Start,
"end" => NodeKind::End,
"condition" => NodeKind::Condition,
"group" => NodeKind::Task, // group 本身不作为可执行节点,稍后跳过入 nodes
_ => NodeKind::Task,
};
if n.kind.as_str() == "group" {
// 解析分组data.blockIDs成员节点、data.parentID上层分组、data.awaitPolicy等待策略
let members: Vec<String> = n
.data
.get("blockIDs")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default();
let parent_id: Option<String> = n
.data
.get("parentID")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let await_policy = match n
.data
.get("awaitPolicy")
.and_then(|v| v.as_str())
.unwrap_or("branch_exit")
{
"none" => super::domain::GroupAwaitPolicy::None,
"node_leave" => super::domain::GroupAwaitPolicy::NodeLeave,
"flow_end" => super::domain::GroupAwaitPolicy::FlowEnd,
_ => super::domain::GroupAwaitPolicy::BranchExit,
};
groups.insert(n.id.clone(), super::domain::GroupDef { id: n.id.clone(), parent_id, members, await_policy });
// group 节点不加入执行节点集合
continue;
}
// 从节点 data.title 读取名称,若不存在则为空字符串
let name = n.data.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
// 将可执行类型映射到任务标识(用于绑定任务实现)
let mut task = match n.kind.as_str() {
"http" => Some("http".to_string()),
"db" => Some("db".to_string()),
"variable" => Some("variable".to_string()),
// 脚本节点:按语言拆分
"script" | "expr" | "script_rhai" => Some("script_rhai".to_string()),
"script_js" | "javascript" | "js" => Some("script_js".to_string()),
"script_python" | "python" | "py" => Some("script_python".to_string()),
_ => None,
};
// 兼容/推断:根据 data.scripts.* 或 inline script/expr 推断脚本类型
if task.is_none() {
if let Some(obj) = n.data.get("scripts").and_then(|v| v.as_object()) {
if obj.get("js").is_some() { task = Some("script_js".to_string()); }
else if obj.get("python").is_some() { task = Some("script_python".to_string()); }
else if obj.get("rhai").is_some() { task = Some("script_rhai".to_string()); }
}
}
if task.is_none() {
if n.data.get("script").is_some() || n.data.get("expr").is_some() {
task = Some("script_rhai".to_string());
}
}
nodes.push(NodeDef { id: NodeId(n.id.clone()), kind, name, task });
}
// 预统计每个 from 节点的出边数量,用于启发式:条件节点仅一条出边且包含多个条件时,默认组装 AND
use std::collections::HashMap;
let mut out_deg: HashMap<&str, usize> = HashMap::new();
for e in &design.edges { *out_deg.entry(e.from.as_str()).or_insert(0) += 1; }
// 兼容旧版的基于 sourcePortID 的条件编码:
// 若边上带有 source_port_id则在源节点条件节点的 data.conditions 中查找同 key 的条件值,作为边的 condition
// 新增:当 source_port_id 为空,或取值为 and/all/group/true 时,将该条件节点内所有 conditions 的 value 组成数组,按 AND 语义挂到该边上
// 进一步新增启发式:当源为条件节点且其出边仅有 1 条,且该节点内包含多个 conditions则即便 source_port_id 指向了具体 key也按 AND 组装
let mut links: Vec<LinkDef> = Vec::new();
for e in &design.edges {
let mut cond: Option<String> = None;
if let Some(src) = design.nodes.iter().find(|x| x.id == e.from) {
if src.kind.as_str() == "condition" {
let conds = src.data.get("conditions").and_then(|v| v.as_array());
let conds_len = conds.map(|a| a.len()).unwrap_or(0);
let only_one_out = out_deg.get(src.id.as_str()).copied().unwrap_or(0) == 1;
match &e.source_port_id {
Some(spid) => {
let spid_l = spid.to_lowercase();
let mut want_group = spid_l == "and" || spid_l == "all" || spid_l == "group" || spid_l == "true";
if !want_group && only_one_out && conds_len > 1 {
// 启发式回退:单出边 + 多条件 => 组装为 AND 组
want_group = true;
}
if want_group {
if let Some(arr) = conds {
let mut values: Vec<Value> = Vec::new();
for item in arr { if let Some(v) = item.get("value").cloned() { values.push(v); } }
if !values.is_empty() { if let Ok(s) = serde_json::to_string(&Value::Array(values)) { cond = Some(s); } }
}
} else {
if let Some(arr) = conds {
if let Some(item) = arr.iter().find(|c| c.get("key").and_then(|v| v.as_str()) == Some(spid.as_str())) {
if let Some(val) = item.get("value") { if let Ok(s) = serde_json::to_string(val) { cond = Some(s); } }
}
}
}
}
None => {
// 没有指定具体端口:将该节点的全部条件组成 AND 组
if let Some(arr) = conds {
let mut values: Vec<Value> = Vec::new();
for item in arr { if let Some(v) = item.get("value").cloned() { values.push(v); } }
if !values.is_empty() { if let Ok(s) = serde_json::to_string(&Value::Array(values)) { cond = Some(s); } }
}
}
}
}
}
links.push(LinkDef { from: NodeId(e.from.clone()), to: NodeId(e.to.clone()), condition: cond });
}
Ok(ChainDef { name: design.name.clone(), nodes, links, groups })
}
// Rewire external API to typed syntax -> validate -> build
pub fn chain_from_design_json(design: &Value) -> anyhow::Result<super::domain::ChainDef> {
// Accept both JSON object and stringified JSON
let parsed: Option<Value> = match design {
Value::String(s) => serde_json::from_str::<Value>(s).ok(),
_ => None,
};
let design = parsed.as_ref().unwrap_or(design);
let mut syntax: DesignSyntax = serde_json::from_value(design.clone())?;
// fill source_port_id for backward compat if edges carry sourcePortID
if let Some(arr) = design.get("edges").and_then(|v| v.as_array()) {
for (i, e) in arr.iter().enumerate() {
if let Some(spid) = e.get("sourcePortID").and_then(|v| v.as_str()) {
if i < syntax.edges.len() {
syntax.edges[i].source_port_id = Some(spid.to_string());
}
}
}
}
validate_design(&syntax)?;
build_chain_from_design(&syntax)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn build_chain_ok_with_start_end_and_tasks() {
let design = json!({
"name": "demo",
"nodes": [
{"id": "n1", "type": "start", "data": {"title": "Start"}},
{"id": "n2", "type": "http", "data": {"title": "HTTP"}},
{"id": "n3", "type": "end", "data": {"title": "End"}}
],
"edges": [
{"from": "n1", "to": "n2"},
{"from": "n2", "to": "n3"}
]
});
let chain = chain_from_design_json(&design).unwrap();
assert_eq!(chain.nodes.len(), 3);
assert_eq!(chain.links.len(), 2);
}
#[test]
fn duplicate_node_id_should_error() {
let design = json!({
"name": "demo",
"nodes": [
{"id": "n1", "type": "start"},
{"id": "n1", "type": "end"}
],
"edges": []
});
let err = chain_from_design_json(&design).unwrap_err();
assert!(err.to_string().contains("duplicate node id"));
}
#[test]
fn missing_start_or_end_should_error() {
let design = json!({
"name": "demo",
"nodes": [
{"id": "n1", "type": "start"}
],
"edges": []
});
let err = chain_from_design_json(&design).unwrap_err();
assert!(err.to_string().contains("at least one end"));
}
#[test]
fn edge_ref_unknown_node_should_error() {
let design = json!({
"name": "demo",
"nodes": [
{"id": "n1", "type": "start"},
{"id": "n2", "type": "end"}
],
"edges": [
{"from": "n1", "to": "n3"}
]
});
let err = chain_from_design_json(&design).unwrap_err();
assert!(err.to_string().contains("edge to references unknown node"));
}
}