feat(flows): 新增流程编辑器基础功能与相关组件

feat(backend): 添加流程模型与服务支持
feat(frontend): 实现流程编辑器UI与交互
feat(assets): 添加流程节点图标资源
feat(plugins): 实现上下文菜单和运行时插件
feat(components): 新增基础节点和侧边栏组件
feat(routes): 添加流程相关路由配置
feat(models): 创建流程和运行日志数据模型
feat(services): 实现流程服务层逻辑
feat(migration): 添加流程相关数据库迁移
feat(config): 更新前端配置支持流程编辑器
feat(utils): 增强axios错误处理和工具函数
This commit is contained in:
2025-09-15 00:27:13 +08:00
parent 9da3978f91
commit b0963e5e37
291 changed files with 17947 additions and 86 deletions

View File

@ -0,0 +1,261 @@
use async_trait::async_trait;
use serde_json::{json, Value};
use tracing::info;
use crate::flow::task::TaskComponent;
#[derive(Default)]
pub struct DbTask;
#[async_trait]
impl TaskComponent for DbTask {
async fn execute(&self, ctx: &mut Value) -> anyhow::Result<()> {
// 1) 获取当前节点ID
let node_id_opt = ctx
.get("__current_node_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 2) 读取 db 配置:仅节点级 db不再回退到全局 ctx.db避免误用项目数据库
let cfg = match (&node_id_opt, ctx.get("nodes")) {
(Some(node_id), Some(nodes)) => nodes.get(&node_id).and_then(|n| n.get("db")).cloned(),
_ => None,
};
let Some(cfg) = cfg else {
info!(target = "udmin.flow", "db task: no config found, skip");
return Ok(());
};
// 3) 解析配置(包含可选连接信息)
let (sql, params, output_key, conn, mode_from_db) = parse_db_config(cfg)?;
// 提前读取结果模式,优先 connection.mode其次 db.output.mode/db.outputMode/db.mode
let result_mode = get_result_mode_from_conn(&conn).or(mode_from_db);
info!(target = "udmin.flow", "db task: exec sql: {}", sql);
// 4) 获取连接:必须显式声明 db.connection禁止回退到项目全局数据库避免安全风险
let db: std::borrow::Cow<'_, crate::db::Db>;
let tmp_conn; // 用于在本作用域内持有临时连接
use sea_orm::{Statement, ConnectionTrait};
let conn_cfg = conn.ok_or_else(|| anyhow::anyhow!("db task: connection config is required (db.connection)"))?;
// 构造 URL 并建立临时连接
let url = extract_connection_url(conn_cfg)?;
use sea_orm::{ConnectOptions, Database};
use std::time::Duration;
let mut opt = ConnectOptions::new(url);
opt.max_connections(20)
.min_connections(1)
.connect_timeout(Duration::from_secs(8))
.idle_timeout(Duration::from_secs(120))
.sqlx_logging(true);
tmp_conn = Database::connect(opt).await?;
db = std::borrow::Cow::Owned(tmp_conn);
// 判定是否为 SELECT简单判断前缀允许前导空白与括号
let is_select = {
let s = sql.trim_start();
let s = s.trim_start_matches('(');
s.to_uppercase().starts_with("SELECT")
};
// 构建参数列表(支持位置和命名两种形式)
let params_vec: Vec<sea_orm::Value> = match params {
None => vec![],
Some(Value::Array(arr)) => arr.into_iter().map(json_to_db_value).collect::<anyhow::Result<_>>()?,
Some(Value::Object(obj)) => {
// 对命名参数对象,保持插入顺序不可控,这里仅将值收集为位置绑定,建议 SQL 使用 `?` 占位
obj.into_iter().map(|(_, v)| json_to_db_value(v)).collect::<anyhow::Result<_>>()?
}
Some(v) => {
// 其它类型:当作单个位置参数
vec![json_to_db_value(v)?]
}
};
let stmt = Statement::from_sql_and_values(db.get_database_backend(), &sql, params_vec);
let result = if is_select {
let rows = db.query_all(stmt).await?;
// 将 QueryResult 转换为 JSON 数组
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let mut obj = serde_json::Map::new();
// 读取列名列表
let cols = row.column_names();
for (idx, col_name) in cols.iter().enumerate() {
let key = col_name.to_string();
// 尝试以通用 JSON 值提取优先字符串、数值、布尔、二进制、null
let val = try_get_as_json(&row, idx, &key);
obj.insert(key, val);
}
out.push(Value::Object(obj));
}
// 默认 rows 模式:直接返回数组
match result_mode.as_deref() {
// 返回首行字段对象(无则 Null
Some("fields") | Some("first") => {
if let Some(Value::Object(m)) = out.get(0) { Value::Object(m.clone()) } else { Value::Null }
}
// 默认与显式 rows 都返回数组
_ => Value::Array(out),
}
} else {
let exec = db.execute(stmt).await?;
// 非 SELECT 默认返回受影响行数
match result_mode.as_deref() {
// 如显式要求 rows则返回空数组
Some("rows") => json!([]),
_ => json!(exec.rows_affected()),
}
};
// 5) 写回 ctx并对敏感信息脱敏
let write_key = output_key.unwrap_or_else(|| "db_response".to_string());
if let (Some(node_id), Some(obj)) = (node_id_opt, ctx.as_object_mut()) {
if let Some(nodes) = obj.get_mut("nodes").and_then(|v| v.as_object_mut()) {
if let Some(target) = nodes.get_mut(&node_id).and_then(|v| v.as_object_mut()) {
// 写入结果
target.insert(write_key, result);
// 对密码字段脱敏(保留其它配置不变)
if let Some(dbv) = target.get_mut("db") {
if let Some(dbo) = dbv.as_object_mut() {
if let Some(connv) = dbo.get_mut("connection") {
match connv {
Value::Object(m) => {
if let Some(pw) = m.get_mut("password") {
*pw = Value::String("***".to_string());
}
if let Some(Value::String(url)) = m.get_mut("url") {
*url = "***".to_string();
}
}
Value::String(s) => {
*s = "***".to_string();
}
_ => {}
}
}
}
}
return Ok(());
}
}
}
if let Value::Object(map) = ctx { map.insert(write_key, result); }
Ok(())
}
}
fn parse_db_config(cfg: Value) -> anyhow::Result<(String, Option<Value>, Option<String>, Option<Value>, Option<String>)> {
match cfg {
Value::String(sql) => Ok((sql, None, None, None, None)),
Value::Object(mut m) => {
let sql = m
.remove("sql")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.ok_or_else(|| anyhow::anyhow!("db config missing sql"))?;
let params = m.remove("params");
let output_key = m.remove("outputKey").and_then(|v| v.as_str().map(|s| s.to_string()));
// 在移除 connection 前,从 db 层读取可能的输出模式
let mode_from_db = {
// db.output.mode
let from_output = m.get("output").and_then(|v| v.as_object()).and_then(|o| o.get("mode")).and_then(|v| v.as_str()).map(|s| s.to_string());
// db.outputMode 或 db.mode
let from_flat = m.get("outputMode").and_then(|v| v.as_str()).map(|s| s.to_string())
.or_else(|| m.get("mode").and_then(|v| v.as_str()).map(|s| s.to_string()));
from_output.or(from_flat)
};
let conn = m.remove("connection");
// 安全策略:必须显式声明连接,禁止默认落到全局数据库
if conn.is_none() {
return Err(anyhow::anyhow!("db config missing connection (db.connection is required)"));
}
Ok((sql, params, output_key, conn, mode_from_db))
}
_ => Err(anyhow::anyhow!("invalid db config")),
}
}
fn extract_connection_url(cfg: Value) -> anyhow::Result<String> {
match cfg {
Value::String(url) => Ok(url),
Value::Object(mut m) => {
if let Some(url) = m.remove("url").and_then(|v| v.as_str().map(|s| s.to_string())) {
return Ok(url);
}
let driver = m
.remove("driver")
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| "mysql".to_string());
// sqlite 特殊处理:仅需要 database文件路径或 :memory:
if driver == "sqlite" {
let database = m.remove("database").and_then(|v| v.as_str().map(|s| s.to_string())).ok_or_else(|| anyhow::anyhow!("connection.database is required for sqlite unless url provided"))?;
return Ok(format!("sqlite://{}", database));
}
let host = m.remove("host").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_else(|| "localhost".to_string());
let port = m.remove("port").map(|v| match v { Value::Number(n) => n.to_string(), Value::String(s) => s, _ => String::new() });
let database = m.remove("database").and_then(|v| v.as_str().map(|s| s.to_string())).ok_or_else(|| anyhow::anyhow!("connection.database is required unless url provided"))?;
let username = m.remove("username").and_then(|v| v.as_str().map(|s| s.to_string())).ok_or_else(|| anyhow::anyhow!("connection.username is required unless url provided"))?;
let password = m.remove("password").and_then(|v| v.as_str().map(|s| s.to_string())).unwrap_or_default();
let port_part = port.filter(|s| !s.is_empty()).map(|s| format!(":{}", s)).unwrap_or_default();
let url = format!(
"{}://{}:{}@{}{}{}",
driver,
percent_encoding::utf8_percent_encode(&username, percent_encoding::NON_ALPHANUMERIC),
percent_encoding::utf8_percent_encode(&password, percent_encoding::NON_ALPHANUMERIC),
host,
port_part,
format!("/{}", database)
);
Ok(url)
}
_ => Err(anyhow::anyhow!("invalid connection config")),
}
}
fn get_result_mode_from_conn(conn: &Option<Value>) -> Option<String> {
match conn {
Some(Value::Object(m)) => m.get("mode").and_then(|v| v.as_str()).map(|s| s.to_string()),
_ => None,
}
}
fn json_to_db_value(v: Value) -> anyhow::Result<sea_orm::Value> {
use sea_orm::Value as DbValue;
let dv = match v {
Value::Null => DbValue::String(None),
Value::Bool(b) => DbValue::Bool(Some(b)),
Value::Number(n) => {
if let Some(i) = n.as_i64() { DbValue::BigInt(Some(i)) }
else if let Some(u) = n.as_u64() { DbValue::BigUnsigned(Some(u)) }
else if let Some(f) = n.as_f64() { DbValue::Double(Some(f)) }
else { DbValue::String(None) }
}
Value::String(s) => DbValue::String(Some(Box::new(s))),
Value::Array(arr) => {
// 无通用跨库数组类型:存为 JSON 字符串
let s = serde_json::to_string(&Value::Array(arr))?;
DbValue::String(Some(Box::new(s)))
}
Value::Object(obj) => {
let s = serde_json::to_string(&Value::Object(obj))?;
DbValue::String(Some(Box::new(s)))
}
};
Ok(dv)
}
fn try_get_as_json(row: &sea_orm::QueryResult, idx: usize, col_name: &str) -> Value {
use sea_orm::TryGetable;
// 尝试多种基础类型
if let Ok(v) = row.try_get::<Option<String>>("", col_name) { return v.map(Value::String).unwrap_or(Value::Null); }
if let Ok(v) = row.try_get::<Option<i64>>("", col_name) { return v.map(|x| json!(x)).unwrap_or(Value::Null); }
if let Ok(v) = row.try_get::<Option<u64>>("", col_name) { return v.map(|x| json!(x)).unwrap_or(Value::Null); }
if let Ok(v) = row.try_get::<Option<f64>>("", col_name) { return v.map(|x| json!(x)).unwrap_or(Value::Null); }
if let Ok(v) = row.try_get::<Option<bool>>("", col_name) { return v.map(|x| json!(x)).unwrap_or(Value::Null); }
// 回退:按索引读取成字符串
if let Ok(v) = row.try_get_by_index::<Option<String>>(idx) { return v.map(Value::String).unwrap_or(Value::Null); }
Value::Null
}

View File

@ -0,0 +1,161 @@
use async_trait::async_trait;
use serde_json::{Value, json, Map};
use tracing::info;
use std::collections::HashMap;
use std::time::Duration;
use reqwest::Certificate;
use crate::flow::task::TaskComponent;
#[derive(Default)]
pub struct HttpTask;
#[derive(Default, Clone)]
struct HttpOpts {
timeout_ms: Option<u64>,
insecure: bool,
ca_pem: Option<String>,
http1_only: bool,
}
#[async_trait]
impl TaskComponent for HttpTask {
async fn execute(&self, ctx: &mut Value) -> anyhow::Result<()> {
// 1) 读取当前节点ID由引擎在执行前写入 ctx.__current_node_id
let node_id_opt = ctx
.get("__current_node_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// 2) 从 ctx 中提取 http 配置
// 优先 nodes.<node_id>.http其次全局 http
let cfg = match (&node_id_opt, ctx.get("nodes")) {
(Some(node_id), Some(nodes)) => nodes.get(&node_id).and_then(|n| n.get("http")).cloned(),
_ => None,
}.or_else(|| ctx.get("http").cloned());
let Some(cfg) = cfg else {
// 未提供配置,直接跳过(也可选择返回错误)
info!(target = "udmin.flow", "http task: no config found, skip");
return Ok(());
};
// 3) 解析配置
let (method, url, headers, query, body, opts) = parse_http_config(cfg)?;
info!(target = "udmin.flow", "http task: {} {}", method, url);
// 4) 发送请求(支持 HTTPS 相关选项)
let client = {
let mut builder = reqwest::Client::builder();
if let Some(ms) = opts.timeout_ms { builder = builder.timeout(Duration::from_millis(ms)); }
if opts.insecure { builder = builder.danger_accept_invalid_certs(true); }
if opts.http1_only { builder = builder.http1_only(); }
if let Some(pem) = opts.ca_pem {
if let Ok(cert) = Certificate::from_pem(pem.as_bytes()) {
builder = builder.add_root_certificate(cert);
}
}
builder.build()?
};
let mut req = client.request(method.parse()?, url);
if let Some(hs) = headers {
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
let mut map = HeaderMap::new();
for (k, v) in hs {
if let (Ok(name), Ok(value)) = (HeaderName::from_bytes(k.as_bytes()), HeaderValue::from_str(&v)) {
map.insert(name, value);
}
}
req = req.headers(map);
}
if let Some(qs) = query {
// 将查询参数转成 (String, String) 列表,便于 reqwest 序列化
let mut pairs: Vec<(String, String)> = Vec::new();
for (k, v) in qs {
let s = match v {
Value::String(s) => s,
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
other => other.to_string(),
};
pairs.push((k, s));
}
req = req.query(&pairs);
}
if let Some(b) = body {
req = req.json(&b);
}
let resp = req.send().await?;
let status = resp.status().as_u16();
let headers_out: Map<String, Value> = resp
.headers()
.iter()
.map(|(k, v)| (k.to_string(), Value::String(v.to_str().unwrap_or("").to_string())))
.collect();
// 尝试以 JSON 解析,否则退回文本
let text = resp.text().await?;
let parsed_body: Value = serde_json::from_str(&text).unwrap_or_else(|_| Value::String(text));
// 5) 将结果写回 ctx
let result = json!({
"status": status,
"headers": headers_out,
"body": parsed_body,
});
// 优先写 nodes.<node_id>.http_response否则写入全局 http_response
if let (Some(node_id), Some(obj)) = (node_id_opt, ctx.as_object_mut()) {
if let Some(nodes) = obj.get_mut("nodes").and_then(|v| v.as_object_mut()) {
if let Some(target) = nodes.get_mut(&node_id).and_then(|v| v.as_object_mut()) {
target.insert("http_response".to_string(), result);
return Ok(());
}
}
}
// 退回:写入全局
if let Value::Object(map) = ctx {
map.insert("http_response".to_string(), result);
}
Ok(())
}
}
fn parse_http_config(cfg: Value) -> anyhow::Result<(
String,
String,
Option<HashMap<String, String>>,
Option<Map<String, Value>>,
Option<Value>,
HttpOpts,
)> {
// 支持两种配置:
// 1) 字符串:视为 URL方法 GET
// 2) 对象:{ method, url, headers, query, body }
match cfg {
Value::String(url) => Ok(("GET".into(), url, None, None, None, HttpOpts::default())),
Value::Object(mut m) => {
let method = m.remove("method").and_then(|v| v.as_str().map(|s| s.to_uppercase())).unwrap_or_else(|| "GET".into());
let url = m.remove("url").and_then(|v| v.as_str().map(|s| s.to_string()))
.ok_or_else(|| anyhow::anyhow!("http config missing url"))?;
let headers = m.remove("headers").and_then(|v| v.as_object().cloned()).map(|obj| {
obj.into_iter().filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_string()))).collect::<HashMap<String, String>>()
});
let query = m.remove("query").and_then(|v| v.as_object().cloned());
let body = m.remove("body");
// 可选 HTTPS/超时/HTTP 版本配置
let timeout_ms = m.remove("timeout_ms").and_then(|v| v.as_u64());
let insecure = m.remove("insecure").and_then(|v| v.as_bool()).unwrap_or(false);
let http1_only = m.remove("http1_only").and_then(|v| v.as_bool()).unwrap_or(false);
let ca_pem = m.remove("ca_pem").and_then(|v| v.as_str().map(|s| s.to_string()));
let opts = HttpOpts { timeout_ms, insecure, ca_pem, http1_only };
Ok((method, url, headers, query, body, opts))
}
_ => Err(anyhow::anyhow!("invalid http config")),
}
}

View File

@ -0,0 +1,2 @@
pub mod http;
pub mod db;