//! 模块:流程 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")] /// 执行模式(兼容前端 executionMode),如:sync/async(目前仅占位) pub execution_mode: Option, /// 节点列表(按声明顺序) pub nodes: Vec, #[serde(default)] /// 边列表(from -> to,可选 condition) pub edges: Vec, } #[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, } #[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, } impl From 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, #[serde(default)] /// 边集合(自由布局) pub edges: Vec, } #[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, } /// 设计级别校验: /// - 节点 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 { use super::domain::{ChainDef, NodeDef, NodeId, NodeKind, LinkDef}; let mut nodes: Vec = Vec::new(); let mut groups: std::collections::HashMap = 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 = 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 = 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 = Vec::new(); for e in &design.edges { let mut cond: Option = 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 = 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 = 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 { // Accept both JSON object and stringified JSON let parsed: Option = match design { Value::String(s) => serde_json::from_str::(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")); } }