refactor(executors): 将 Rhai 引擎评估逻辑迁移至 script_rhai 模块 docs: 添加 Flow 架构文档与示例 JSON feat(i18n): 新增前端多语言支持 perf(axios): 优化 token 刷新与 401 处理逻辑 style: 统一代码格式化与简化条件判断
396 lines
17 KiB
Rust
396 lines
17 KiB
Rust
//! 模块:流程 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<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"));
|
||
}
|
||
} |