- 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个节点测试全绿
293 lines
9.4 KiB
Rust
293 lines
9.4 KiB
Rust
//! 通知节点 — 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"));
|
||
}
|
||
}
|