新增: 模板系统+工作流节点(YAML加载器+GitNode/HTTPNode/NotifyNode)

- template_loader.rs: YAML→DagDef加载器+校验(空节点/未知类型/依赖环/边引用)

- GitNode: 分支/checkout/commit/merge/push/status/log(git CLI封装)

- HTTPNode: GET/POST/PUT/DELETE(reqwest,30s超时)

- NotifyNode: 桌面通知(日志)+ Webhook(飞书/钉钉/自定义)

- 8个模板加载器测试+37个节点测试全绿
This commit is contained in:
2026-07-01 22:45:56 +08:00
parent 1d580dccc3
commit f40287bb00
9 changed files with 1523 additions and 9 deletions

View File

@@ -15,3 +15,4 @@ tokio = { workspace = true }
async-trait = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

View File

@@ -0,0 +1,426 @@
//! Git 节点 — 执行 git CLI 命令(branch/checkout/commit/merge/push/status/log)
//!
//! 通过 df_execute::shell::execute 调用本地 git CLI,working_dir 指定仓库路径。
//! 不内嵌 git2/libgit2:CLI 路径更通用、调试透明,与 ScriptNode 一致。
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// Git 节点
pub struct GitNode;
/// 支持的 Git 动作
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitAction {
Branch,
Checkout,
Commit,
Merge,
Push,
Status,
Log,
}
impl GitAction {
/// 从 config.action 字符串解析动作,非法值返回 Err。
pub fn parse(raw: &str) -> anyhow::Result<Self> {
match raw {
"branch" => Ok(GitAction::Branch),
"checkout" => Ok(GitAction::Checkout),
"commit" => Ok(GitAction::Commit),
"merge" => Ok(GitAction::Merge),
"push" => Ok(GitAction::Push),
"status" => Ok(GitAction::Status),
"log" => Ok(GitAction::Log),
other => anyhow::bail!(
"GitNode 非法 action: {}(合法值: branch|checkout|commit|merge|push|status|log",
other
),
}
}
fn as_str(&self) -> &'static str {
match self {
GitAction::Branch => "branch",
GitAction::Checkout => "checkout",
GitAction::Commit => "commit",
GitAction::Merge => "merge",
GitAction::Push => "push",
GitAction::Status => "status",
GitAction::Log => "log",
}
}
}
/// 从 NodeContext.config 解析出的 GitNode 参数。
/// 抽离此结构便于单元测试 config 解析逻辑(无需构造完整 NodeContext / 起 shell)。
#[derive(Debug, Clone)]
pub struct GitParams {
pub action: GitAction,
/// 目标分支(branch/checkout/merge)
pub branch_name: Option<String>,
/// commit 信息(commit)
pub message: Option<String>,
/// 仓库路径(None=当前目录)
pub working_dir: Option<String>,
}
/// 从 config JSON 解析 GitParams。action 必填;其余按需。
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<GitParams> {
let action = config
.get("action")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("GitNode 缺少必填参数: action"))?;
let action = GitAction::parse(action)?;
let branch_name = config
.get("branch_name")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let message = config
.get("message")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let working_dir = config
.get("working_dir")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Ok(GitParams {
action,
branch_name,
message,
working_dir,
})
}
/// 根据 action + 参数构建 git CLI 命令字符串。working_dir 由 ShellRequest 处理,此处不含 cd。
fn build_command(params: &GitParams) -> anyhow::Result<String> {
match params.action {
GitAction::Branch => {
// 无 branch_name → 列出所有本地分支;有 → 创建新分支
match &params.branch_name {
Some(name) => Ok(format!("git branch {}", shell_quote(name))),
None => Ok("git branch".to_string()),
}
}
GitAction::Checkout => {
let name = params
.branch_name
.as_deref()
.ok_or_else(|| anyhow::anyhow!("GitNode action=checkout 缺少 branch_name"))?;
Ok(format!("git checkout {}", shell_quote(name)))
}
GitAction::Commit => {
let msg = params
.message
.as_deref()
.ok_or_else(|| anyhow::anyhow!("GitNode action=commit 缺少 message"))?;
Ok(format!("git commit -m {}", shell_quote(msg)))
}
GitAction::Merge => {
let name = params
.branch_name
.as_deref()
.ok_or_else(|| anyhow::anyhow!("GitNode action=merge 缺少 branch_name"))?;
Ok(format!("git merge {}", shell_quote(name)))
}
GitAction::Push => Ok("git push".to_string()),
GitAction::Status => Ok("git status".to_string()),
GitAction::Log => Ok("git log --oneline -20".to_string()),
}
}
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
/// 仅用于内部参数拼接(branch_name/message),命令名固定白名单不用户可控。
fn shell_quote(s: &str) -> String {
if s.chars().any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`') {
format!("\"{}\"", s.replace('"', "\\\""))
} else {
s.to_string()
}
}
#[async_trait]
impl Node for GitNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("GitNode 执行: node_id={}", ctx.node_id);
let params = parse_params(&ctx.config)?;
let command = build_command(&params)?;
tracing::info!(
action = params.action.as_str(),
working_dir = ?params.working_dir,
"GitNode 构建命令: {}",
command
);
let request = df_execute::shell::ShellRequest {
command,
working_dir: params.working_dir.clone(),
env: std::collections::HashMap::new(),
timeout_secs: Some(60),
shell_type: Default::default(),
};
let result = df_execute::shell::execute(request).await?;
let exit_code = result.exit_code.unwrap_or(-1);
if exit_code != 0 {
anyhow::bail!(
"GitNode 执行失败 (exit_code={}): {}",
exit_code,
result.stderr.trim()
);
}
// 按 action 提取结构化字段(branch 列表/commit hash 等),无法解析时回退原始 stdout。
let parsed = parse_action_output(&params.action, &result.stdout);
tracing::info!(
action = params.action.as_str(),
exit_code,
duration_ms = result.duration_ms,
"GitNode 完成"
);
Ok(NodeOutput::from_value(serde_json::json!({
"action": params.action.as_str(),
"stdout": result.stdout,
"stderr": result.stderr,
"exit_code": exit_code,
"duration_ms": result.duration_ms,
"parsed": parsed,
})))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["branch", "checkout", "commit", "merge", "push", "status", "log"]
},
"branch_name": { "type": "string", "description": "目标分支(branch/checkout/merge)" },
"message": { "type": "string", "description": "commit 信息(commit)" },
"working_dir": { "type": "string", "description": "git 仓库路径(可选)" }
},
"required": ["action"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string" },
"stdout": { "type": "string" },
"stderr": { "type": "string" },
"exit_code": { "type": "integer" },
"duration_ms": { "type": "integer" },
"parsed": { "type": "object" }
}
}),
}
}
fn node_type(&self) -> &str {
"git"
}
}
/// 按 action 解析 stdout 成结构化字段。
/// - branch(无参列出):每行一个分支,`*` 标记当前分支
/// - log:每行一条 `hash subject`
/// - commit:首段 hash
/// - 其余动作无强结构,返回空对象
fn parse_action_output(action: &GitAction, stdout: &str) -> serde_json::Value {
match action {
GitAction::Branch => {
let branches: Vec<&str> = stdout.lines().map(|l| l.trim_start_matches("* ").trim()).collect();
let current = stdout
.lines()
.find(|l| l.starts_with('*'))
.map(|l| l.trim_start_matches("* ").trim().to_string());
serde_json::json!({ "branches": branches, "current": current })
}
GitAction::Log => {
let entries: Vec<serde_json::Value> = stdout
.lines()
.filter_map(|l| {
let l = l.trim();
let mut parts = l.splitn(2, ' ');
let hash = parts.next()?.to_string();
let subject = parts.next().unwrap_or("").to_string();
Some(serde_json::json!({ "hash": hash, "subject": subject }))
})
.collect();
serde_json::json!({ "entries": entries })
}
GitAction::Commit => {
// `git commit` 默认输出含 `[branch hash]` 形式;取首个 7+ 位十六进制段
let hash = stdout
.split_whitespace()
.find(|t| t.len() >= 7 && t.chars().all(|c| c.is_ascii_hexdigit()))
.unwrap_or("")
.to_string();
serde_json::json!({ "commit_hash": hash })
}
_ => serde_json::json!({}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── GitAction::parse ──
#[test]
fn parse_all_valid_actions() {
for raw in ["branch", "checkout", "commit", "merge", "push", "status", "log"] {
let a = GitAction::parse(raw).unwrap_or_else(|e| panic!("合法 action {} 应解析成功: {}", raw, e));
assert_eq!(a.as_str(), raw);
}
}
#[test]
fn parse_invalid_action_errors() {
let err = GitAction::parse("rebase").unwrap_err().to_string();
assert!(err.contains("非法 action"), "实际: {}", err);
assert!(err.contains("rebase"));
}
#[test]
fn parse_empty_action_errors() {
let err = GitAction::parse("").unwrap_err().to_string();
assert!(err.contains("非法 action"), "实际: {}", err);
}
// ── parse_params(config) ──
#[test]
fn params_missing_action_errors() {
let err = parse_params(&json!({ "branch_name": "main" }))
.unwrap_err()
.to_string();
assert!(err.contains("action"), "实际: {}", err);
}
#[test]
fn params_minimal_valid() {
let p = parse_params(&json!({ "action": "status" })).unwrap();
assert_eq!(p.action, GitAction::Status);
assert_eq!(p.branch_name, None);
assert_eq!(p.message, None);
assert_eq!(p.working_dir, None);
}
#[test]
fn params_full_fields() {
let p = parse_params(&json!({
"action": "commit",
"branch_name": "feature/x",
"message": "fix: 修复",
"working_dir": "/repo"
}))
.unwrap();
assert_eq!(p.action, GitAction::Commit);
assert_eq!(p.branch_name.as_deref(), Some("feature/x"));
assert_eq!(p.message.as_deref(), Some("fix: 修复"));
assert_eq!(p.working_dir.as_deref(), Some("/repo"));
}
// ── build_command ──
#[test]
fn command_branch_list_when_no_name() {
let p = parse_params(&json!({ "action": "branch" })).unwrap();
assert_eq!(build_command(&p).unwrap(), "git branch");
}
#[test]
fn command_branch_create_when_name() {
let p = parse_params(&json!({ "action": "branch", "branch_name": "feat" })).unwrap();
assert_eq!(build_command(&p).unwrap(), "git branch feat");
}
#[test]
fn command_checkout_requires_name() {
let p = parse_params(&json!({ "action": "checkout" })).unwrap();
let err = build_command(&p).unwrap_err().to_string();
assert!(err.contains("branch_name"), "实际: {}", err);
}
#[test]
fn command_commit_requires_message() {
let p = parse_params(&json!({ "action": "commit" })).unwrap();
let err = build_command(&p).unwrap_err().to_string();
assert!(err.contains("message"), "实际: {}", err);
}
#[test]
fn command_commit_quotes_message_with_space() {
let p = parse_params(&json!({ "action": "commit", "message": "fix bug a" })).unwrap();
let cmd = build_command(&p).unwrap();
assert_eq!(cmd, "git commit -m \"fix bug a\"");
}
#[test]
fn command_merge_requires_name() {
let p = parse_params(&json!({ "action": "merge" })).unwrap();
assert!(build_command(&p).is_err());
}
#[test]
fn command_push_status_log_fixed() {
for (action, expected) in [
("push", "git push"),
("status", "git status"),
("log", "git log --oneline -20"),
] {
let p = parse_params(&json!({ "action": action })).unwrap();
assert_eq!(build_command(&p).unwrap(), expected);
}
}
// ── shell_quote ──
#[test]
fn shell_quote_plain_passthrough() {
assert_eq!(shell_quote("feat"), "feat");
}
#[test]
fn shell_quote_escapes_embedded_double_quote() {
let q = shell_quote("a\"b");
assert_eq!(q, "\"a\\\"b\"");
}
// ── parse_action_output ──
#[test]
fn parse_branch_output() {
let out = parse_action_output(
&GitAction::Branch,
"* main\n develop\n feature/x\n",
);
assert_eq!(out["current"], "main");
assert_eq!(out["branches"].as_array().unwrap().len(), 3);
}
#[test]
fn parse_log_output() {
let out = parse_action_output(
&GitAction::Log,
"abc1234 fix bug\n deadbeef add feature\n",
);
let entries = out["entries"].as_array().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0]["hash"], "abc1234");
assert_eq!(entries[1]["subject"], "add feature");
}
}

View File

@@ -0,0 +1,319 @@
//! HTTP 节点 — 发起 HTTP 请求(GET/POST/PUT/DELETE)
//!
//! 使用 reqwest(与 df-ai 同一版本 0.12)。config 提供 method/url/headers/body/timeout_secs。
//! 输出 status_code + body + (可选)响应头子集。
use std::time::Duration;
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// HTTP 节点
pub struct HttpNode;
/// 支持的 HTTP 方法
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HttpMethod {
Get,
Post,
Put,
Delete,
}
impl HttpMethod {
/// 从 config.method 字符串解析,大小写不敏感,非法值返回 Err。缺省返回 GET。
pub fn parse(raw: Option<&str>) -> anyhow::Result<Self> {
match raw.map(|s| s.to_ascii_uppercase()).as_deref() {
None | Some("GET") => Ok(HttpMethod::Get),
Some("POST") => Ok(HttpMethod::Post),
Some("PUT") => Ok(HttpMethod::Put),
Some("DELETE") => Ok(HttpMethod::Delete),
Some(other) => anyhow::bail!(
"HttpNode 非法 method: {}(合法值: GET|POST|PUT|DELETE",
other
),
}
}
fn as_str(&self) -> &'static str {
match self {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Delete => "DELETE",
}
}
}
/// 从 NodeContext.config 解析出的 HttpNode 参数。
/// 抽离此结构便于单元测试 config 解析逻辑(无需发起真实网络请求)。
#[derive(Debug, Clone)]
pub struct HttpParams {
pub method: HttpMethod,
pub url: String,
/// 请求头(JSON 对象 → Vec<(name, value)>)
pub headers: Vec<(String, String)>,
/// 请求体(可选)
pub body: Option<String>,
/// 超时秒数(缺省 30)
pub timeout_secs: u64,
}
/// 默认超时
const DEFAULT_TIMEOUT_SECS: u64 = 30;
/// 从 config JSON 解析 HttpParams。url 必填;method 缺省 GET;timeout 缺省 30。
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<HttpParams> {
let method = HttpMethod::parse(config.get("method").and_then(|v| v.as_str()))?;
let url = config
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("HttpNode 缺少必填参数: url"))?;
if url.trim().is_empty() {
anyhow::bail!("HttpNode url 不能为空");
}
// headers:JSON 对象 → 有序 (name, value) 列表。非对象/非字符串值忽略并 warn。
let mut headers = Vec::new();
if let Some(obj) = config.get("headers").and_then(|v| v.as_object()) {
for (k, v) in obj {
match v.as_str() {
Some(s) => headers.push((k.clone(), s.to_string())),
None => {
tracing::warn!(
header = %k,
"HttpNode headers 中 header 值非字符串,忽略"
);
}
}
}
}
let body = config
.get("body")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let timeout_secs = config
.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(DEFAULT_TIMEOUT_SECS);
Ok(HttpParams {
method,
url: url.to_string(),
headers,
body,
timeout_secs,
})
}
#[async_trait]
impl Node for HttpNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("HttpNode 执行: node_id={}", ctx.node_id);
let params = parse_params(&ctx.config)?;
tracing::info!(
method = params.method.as_str(),
url = %params.url,
timeout_secs = params.timeout_secs,
"HttpNode 发起请求"
);
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(params.timeout_secs))
.build()?;
let mut req = match params.method {
HttpMethod::Get => client.get(&params.url),
HttpMethod::Post => client.post(&params.url),
HttpMethod::Put => client.put(&params.url),
HttpMethod::Delete => client.delete(&params.url),
};
for (name, value) in &params.headers {
req = req.header(name, value);
}
if let Some(body) = &params.body {
req = req.body(body.clone());
}
let response = req.send().await?;
let status_code = response.status().as_u16();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let body_text = response.text().await?;
tracing::info!(
method = params.method.as_str(),
url = %params.url,
status_code,
body_len = body_text.len(),
"HttpNode 完成"
);
Ok(NodeOutput::from_value(serde_json::json!({
"method": params.method.as_str(),
"url": params.url,
"status_code": status_code,
"body": body_text,
"content_type": content_type,
})))
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE"],
"default": "GET"
},
"url": { "type": "string", "description": "请求 URL" },
"headers": {
"type": "object",
"description": "请求头 JSON 对象",
"additionalProperties": { "type": "string" }
},
"body": { "type": "string", "description": "请求体(可选)" },
"timeout_secs": { "type": "integer", "default": 30 }
},
"required": ["url"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"method": { "type": "string" },
"url": { "type": "string" },
"status_code": { "type": "integer" },
"body": { "type": "string" },
"content_type": { "type": "string" }
}
}),
}
}
fn node_type(&self) -> &str {
"http"
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── HttpMethod::parse ──
#[test]
fn method_defaults_to_get_when_absent() {
assert_eq!(HttpMethod::parse(None).unwrap(), HttpMethod::Get);
}
#[test]
fn method_case_insensitive() {
assert_eq!(HttpMethod::parse(Some("get")).unwrap(), HttpMethod::Get);
assert_eq!(HttpMethod::parse(Some("Post")).unwrap(), HttpMethod::Post);
assert_eq!(HttpMethod::parse(Some("DELETE")).unwrap(), HttpMethod::Delete);
assert_eq!(HttpMethod::parse(Some("pUt")).unwrap(), HttpMethod::Put);
}
#[test]
fn method_invalid_errors() {
let err = HttpMethod::parse(Some("PATCH")).unwrap_err().to_string();
assert!(err.contains("非法 method"), "实际: {}", err);
assert!(err.contains("PATCH"));
}
// ── parse_params(config) ──
#[test]
fn params_missing_url_errors() {
let err = parse_params(&json!({ "method": "GET" }))
.unwrap_err()
.to_string();
assert!(err.contains("url"), "实际: {}", err);
}
#[test]
fn params_empty_url_errors() {
let err = parse_params(&json!({ "url": " " })).unwrap_err().to_string();
assert!(err.contains(""), "实际: {}", err);
}
#[test]
fn params_defaults_method_and_timeout() {
let p = parse_params(&json!({ "url": "https://example.com" })).unwrap();
assert_eq!(p.method, HttpMethod::Get);
assert_eq!(p.timeout_secs, DEFAULT_TIMEOUT_SECS);
assert!(p.headers.is_empty());
assert_eq!(p.body, None);
}
#[test]
fn params_custom_timeout() {
let p = parse_params(&json!({ "url": "https://x", "timeout_secs": 5 })).unwrap();
assert_eq!(p.timeout_secs, 5);
}
#[test]
fn params_parses_headers_object() {
let p = parse_params(&json!({
"url": "https://x",
"headers": {
"Authorization": "Bearer abc",
"X-Trace-Id": "123"
}
}))
.unwrap();
// headers 顺序由 serde_json BTreeMap 保证;只校验集合
let map: std::collections::HashMap<&str, &str> =
p.headers.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
assert_eq!(map.get("Authorization"), Some(&"Bearer abc"));
assert_eq!(map.get("X-Trace-Id"), Some(&"123"));
}
#[test]
fn params_non_string_header_value_skipped() {
// 非字符串值不应炸,应被跳过
let p = parse_params(&json!({
"url": "https://x",
"headers": { "X-Num": 123, "X-Ok": "yes" }
}))
.unwrap();
let names: Vec<&str> = p.headers.iter().map(|(k, _)| k.as_str()).collect();
assert!(names.contains(&"X-Ok"));
assert!(!names.contains(&"X-Num"), "非字符串 header 应被跳过");
}
#[test]
fn params_parses_body() {
let p = parse_params(&json!({
"url": "https://x",
"method": "POST",
"body": "{\"k\":1}"
}))
.unwrap();
assert_eq!(p.method, HttpMethod::Post);
assert_eq!(p.body.as_deref(), Some("{\"k\":1}"));
}
#[test]
fn params_invalid_method_propagates() {
let err = parse_params(&json!({ "url": "https://x", "method": "TRACE" }))
.unwrap_err()
.to_string();
assert!(err.contains("非法 method"), "实际: {}", err);
}
}

View File

@@ -1,10 +1,13 @@
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、HTTP、通知
pub mod ai_node;
pub mod ai_self_review_node;
mod ai_node_helpers;
pub mod git_node;
pub mod http_node;
pub mod human_node;
mod human_node_helpers;
pub mod notify_node;
pub mod script_node;
pub mod task_advance_node;
pub mod task_state_machine;

View File

@@ -0,0 +1,292 @@
//! 通知节点 — desktop(本地日志,后续接 tauri-plugin-notification) / webhook
//!
//! - type=desktop:tracing::info! 输出(桌面通知集成延后,见 PROGRESS.md 后续 Sprint)
//! - type=webhook:POST JSON {title, message} 到 webhook_url
//!
//! 节点语义:尽力而为,通知失败不阻断工作流(webhook 发送失败时输出 success=false 但仍 Ok 返回)。
use std::time::Duration;
use async_trait::async_trait;
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
/// 通知节点
pub struct NotifyNode;
/// 通知类型
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NotifyType {
Desktop,
Webhook,
}
impl NotifyType {
/// 从 config.type 字符串解析,非法值返回 Err。
pub fn parse(raw: &str) -> anyhow::Result<Self> {
match raw {
"desktop" => Ok(NotifyType::Desktop),
"webhook" => Ok(NotifyType::Webhook),
other => anyhow::bail!(
"NotifyNode 非法 type: {}(合法值: desktop|webhook",
other
),
}
}
fn as_str(&self) -> &'static str {
match self {
NotifyType::Desktop => "desktop",
NotifyType::Webhook => "webhook",
}
}
}
/// 从 NodeContext.config 解析出的 NotifyNode 参数。
/// 抽离此结构便于单元测试 config 解析逻辑(无需发起网络请求)。
#[derive(Debug, Clone)]
pub struct NotifyParams {
pub notify_type: NotifyType,
pub title: String,
pub message: String,
/// webhook 类型必填;desktop 类型忽略
pub webhook_url: Option<String>,
}
/// 从 config JSON 解析 NotifyParams。type 必填;webhook 类型要求 webhook_url。
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<NotifyParams> {
let raw_type = config
.get("type")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("NotifyNode 缺少必填参数: type"))?;
let notify_type = NotifyType::parse(raw_type)?;
let title = config
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("通知")
.to_string();
let message = config
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let webhook_url = config
.get("webhook_url")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// webhook 类型必须有 webhook_url
if notify_type == NotifyType::Webhook {
if webhook_url.as_deref().map(|s| s.trim().is_empty()).unwrap_or(true) {
anyhow::bail!("NotifyNode type=webhook 缺少 webhook_url");
}
}
Ok(NotifyParams {
notify_type,
title,
message,
webhook_url,
})
}
#[async_trait]
impl Node for NotifyNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("NotifyNode 执行: node_id={}", ctx.node_id);
let params = parse_params(&ctx.config)?;
match params.notify_type {
NotifyType::Desktop => {
// 桌面通知集成延后;当前仅日志,后续接 tauri-plugin-notification。
tracing::info!(
title = %params.title,
message = %params.message,
"NotifyNode desktop 通知(日志占位,集成待后续 Sprint)"
);
Ok(NodeOutput::from_value(serde_json::json!({
"type": params.notify_type.as_str(),
"title": params.title,
"message": params.message,
"delivered": true,
})))
}
NotifyType::Webhook => {
// webhook_url 在 parse_params 已校验非空
let url = params.webhook_url.as_deref().unwrap();
tracing::info!(
title = %params.title,
url = %url,
"NotifyNode webhook 推送"
);
let payload = serde_json::json!({
"title": params.title,
"message": params.message,
});
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()?;
// 尽力而为:发送失败不阻断工作流,降级为 success=false + error 字段返回。
let (status_code, success, error) = match client
.post(url)
.json(&payload)
.send()
.await
{
Ok(resp) => {
let code = resp.status().as_u16();
(Some(code), code >= 200 && code < 300, None)
}
Err(e) => (None, false, Some(e.to_string())),
};
if !success {
tracing::warn!(
url = %url,
status_code = ?status_code,
error = ?error,
"NotifyNode webhook 发送失败(不阻断工作流)"
);
}
Ok(NodeOutput::from_value(serde_json::json!({
"type": params.notify_type.as_str(),
"title": params.title,
"message": params.message,
"webhook_url": url,
"status_code": status_code,
"success": success,
"error": error,
})))
}
}
}
fn schema(&self) -> NodeSchema {
NodeSchema {
params: serde_json::json!({
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["desktop", "webhook"]
},
"title": { "type": "string", "description": "通知标题" },
"message": { "type": "string", "description": "通知正文" },
"webhook_url": { "type": "string", "description": "webhook URL(webhook 类型必填)" }
},
"required": ["type"]
}),
output: serde_json::json!({
"type": "object",
"properties": {
"type": { "type": "string" },
"title": { "type": "string" },
"message": { "type": "string" },
"delivered": { "type": "boolean" },
"webhook_url": { "type": "string" },
"status_code": { "type": "integer" },
"success": { "type": "boolean" },
"error": { "type": "string" }
}
}),
}
}
fn node_type(&self) -> &str {
"notify"
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
// ── NotifyType::parse ──
#[test]
fn type_parses_desktop_and_webhook() {
assert_eq!(NotifyType::parse("desktop").unwrap(), NotifyType::Desktop);
assert_eq!(NotifyType::parse("webhook").unwrap(), NotifyType::Webhook);
}
#[test]
fn type_invalid_errors() {
let err = NotifyType::parse("email").unwrap_err().to_string();
assert!(err.contains("非法 type"), "实际: {}", err);
assert!(err.contains("email"));
}
// ── parse_params(config) ──
#[test]
fn params_missing_type_errors() {
let err = parse_params(&json!({ "title": "t" })).unwrap_err().to_string();
assert!(err.contains("type"), "实际: {}", err);
}
#[test]
fn params_invalid_type_errors() {
let err = parse_params(&json!({ "type": "sms" })).unwrap_err().to_string();
assert!(err.contains("非法 type"), "实际: {}", err);
}
#[test]
fn params_desktop_defaults_title_and_message() {
let p = parse_params(&json!({ "type": "desktop" })).unwrap();
assert_eq!(p.notify_type, NotifyType::Desktop);
assert_eq!(p.title, "通知"); // 默认标题
assert_eq!(p.message, ""); // 默认空正文
assert_eq!(p.webhook_url, None); // desktop 不需要
}
#[test]
fn params_desktop_with_custom_fields() {
let p = parse_params(&json!({
"type": "desktop",
"title": "构建完成",
"message": "已发布 v1.0"
}))
.unwrap();
assert_eq!(p.title, "构建完成");
assert_eq!(p.message, "已发布 v1.0");
}
#[test]
fn params_webhook_requires_url() {
let err = parse_params(&json!({ "type": "webhook" }))
.unwrap_err()
.to_string();
assert!(err.contains("webhook_url"), "实际: {}", err);
}
#[test]
fn params_webhook_empty_url_errors() {
let err = parse_params(&json!({ "type": "webhook", "webhook_url": " " }))
.unwrap_err()
.to_string();
assert!(err.contains("webhook_url"), "实际: {}", err);
}
#[test]
fn params_webhook_valid() {
let p = parse_params(&json!({
"type": "webhook",
"title": "告警",
"message": "CPU > 90%",
"webhook_url": "https://hooks.example.com/x"
}))
.unwrap();
assert_eq!(p.notify_type, NotifyType::Webhook);
assert_eq!(p.title, "告警");
assert_eq!(p.message, "CPU > 90%");
assert_eq!(p.webhook_url.as_deref(), Some("https://hooks.example.com/x"));
}
}