xiangning a978e7f868 feat(workflow): 添加自定义函数类型和按钮三阶段动作链功能
- 引入 CustomFunctionType 枚举,支持 Script 和 Http 两种函数类型
- 在自定义函数中添加 Http 相关字段(HttpMethod、HttpUrl、HttpHeaders、HttpBodyTemplate)
- 实现 HTTP 函数执行器,支持变量替换和 SSRF 防护
- 将按钮动作链拆分为 before、main、after 三个阶段
- 新增前置和后置动作 JSON 字段存储
- 实现批量执行多个函数的 executeFunctions 动作类型
- 更新数据库迁移以支持新的字段结构
- 调整相关 DTO、命令和处理器以兼容新功能
2026-07-06 18:03:22 +08:00

190 lines
7.4 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using BuildFlow.Shared.Domain.Common;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Workflow.Application.BuildFlow.CustomFunctions.Services;
using Workflow.Application.Notifications;
using Workflow.Domain.Entities;
namespace BuildFlow.Host.Services.Integration;
/// <summary>
/// HTTP 自定义函数执行器实现。
/// 复用 Webhook 的 IHttpClientFactory("Webhook" 命名客户端)和 SSRF 白名单防护。
/// 对 URL/Header/Body 中的 $input.xxx 占位符做变量替换后发起请求,
/// 非 2xx 响应、SSRF 拦截、超时均抛 BusinessException。
/// </summary>
public class HttpFunctionExecutor(
IHttpClientFactory httpClientFactory,
IOptions<NotificationOptions> options,
ILogger<HttpFunctionExecutor> logger) : IHttpFunctionExecutor
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
private readonly NotificationOptions _options = options.Value;
public async Task<string> ExecuteAsync(CustomFunction function, string inputJson, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(function.HttpUrl))
{
throw new BusinessException($"HTTP 函数 {function.DisplayName} 未配置目标 URL");
}
var url = ReplaceVariables(function.HttpUrl, inputJson);
if (!IsHostAllowed(url))
{
logger.LogWarning("HTTP 函数 {FunctionId} URL {Url} 被拒绝(SSRF 白名单拦截)", function.Id, url);
throw new BusinessException($"HTTP 函数目标地址不在允许的白名单内:{new Uri(url).Host}");
}
var client = httpClientFactory.CreateClient("Webhook");
using var request = new HttpRequestMessage(
new HttpMethod(string.IsNullOrWhiteSpace(function.HttpMethod) ? "POST" : function.HttpMethod.ToUpperInvariant()),
url);
// 请求头
if (!string.IsNullOrWhiteSpace(function.HttpHeaders))
{
var headersJson = ReplaceVariables(function.HttpHeaders, inputJson);
var headers = JsonSerializer.Deserialize<Dictionary<string, string>>(headersJson)
?? new Dictionary<string, string>();
foreach (var (key, value) in headers)
{
request.Headers.TryAddWithoutValidation(key, value);
}
}
// 请求体
if (!string.IsNullOrWhiteSpace(function.HttpBodyTemplate))
{
var body = ReplaceVariables(function.HttpBodyTemplate, inputJson);
request.Content = new StringContent(body, Encoding.UTF8, "application/json");
}
// 执行请求
var response = await client.SendAsync(request, ct);
var responseBody = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("HTTP 函数 {FunctionId} 调用 {Url} 返回非成功状态 {Status}", function.Id, url, (int)response.StatusCode);
throw new BusinessException(
$"HTTP 函数调用失败:{(int)response.StatusCode} {response.StatusCode}" +
(string.IsNullOrEmpty(responseBody) ? "" : $"\n{Truncate(responseBody, 500)}"));
}
// 尝试将响应体解析为 JSON;若不是合法 JSON 则包装为 { result: "<body>" }
try
{
using var doc = JsonDocument.Parse(responseBody);
return JsonSerializer.Serialize(doc.RootElement, JsonOptions);
}
catch (JsonException)
{
return JsonSerializer.Serialize(new { result = responseBody }, JsonOptions);
}
}
/// <summary>
/// 替换模板中的 $input.xxx 占位符为 inputJson 中对应路径的值。
/// 占位符出现在字符串中会被原地替换为 JSON 字面量(去掉外层引号的 JSON token)。
/// </summary>
private static string ReplaceVariables(string template, string inputJson)
{
if (string.IsNullOrEmpty(template)) return template;
using var input = string.IsNullOrWhiteSpace(inputJson)
? JsonDocument.Parse("{}")
: JsonDocument.Parse(inputJson);
var result = new StringBuilder(template);
var i = 0;
while ((i = result.ToString().IndexOf("$input.", i, StringComparison.Ordinal)) >= 0)
{
// 提取 $input. 后的路径(支持字母数字下划线和点)
var end = i + "$input.".Length;
while (end < result.Length && (char.IsLetterOrDigit(result[end]) || result[end] == '_' || result[end] == '.'))
{
end++;
}
var path = result.ToString(i + "$input.".Length, end - i - "$input.".Length);
var value = ReadPath(input.RootElement, path);
var literal = value switch
{
{ } v when v.ValueKind == JsonValueKind.String => v.GetString() ?? "",
{ } v when v.ValueKind == JsonValueKind.Number => v.GetRawText(),
{ } v when v.ValueKind == JsonValueKind.True => "true",
{ } v when v.ValueKind == JsonValueKind.False => "false",
{ } v when v.ValueKind == JsonValueKind.Null => "null",
{ } v when v.ValueKind == JsonValueKind.Object => v.GetRawText(),
{ } v when v.ValueKind == JsonValueKind.Array => v.GetRawText(),
_ => "",
};
result.Remove(i, end - i);
result.Insert(i, literal);
i += literal.Length;
}
return result.ToString();
}
private static JsonElement? ReadPath(JsonElement root, string path)
{
var current = root;
foreach (var segment in path.Split('.', StringSplitOptions.RemoveEmptyEntries))
{
if (current.ValueKind != JsonValueKind.Object ||
!current.TryGetProperty(segment, out current))
{
return null;
}
}
return current;
}
/// <summary>SSRF 防护:校验 URL 主机在白名单内,拒绝 localhost 与私有/内网 IP。</summary>
internal bool IsHostAllowed(string url)
{
if (_options.Webhook.AllowedHosts is null || _options.Webhook.AllowedHosts.Length == 0)
{
return false;
}
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{
return false;
}
var host = uri.Host;
if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (IPAddress.TryParse(host, out var ip))
{
if (IPAddress.IsLoopback(ip)) return false;
if (ip.AddressFamily == AddressFamily.InterNetwork)
{
var bytes = ip.GetAddressBytes();
if (bytes[0] == 10) return false;
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return false;
if (bytes[0] == 192 && bytes[1] == 168) return false;
}
}
return _options.Webhook.AllowedHosts
.Any(allowed => string.Equals(allowed, host, StringComparison.OrdinalIgnoreCase));
}
private static string Truncate(string text, int maxLength) =>
text.Length <= maxLength ? text : $"{text[..maxLength]}...";
}