feat(workflow): 添加自定义函数类型和按钮三阶段动作链功能
- 引入 CustomFunctionType 枚举,支持 Script 和 Http 两种函数类型 - 在自定义函数中添加 Http 相关字段(HttpMethod、HttpUrl、HttpHeaders、HttpBodyTemplate) - 实现 HTTP 函数执行器,支持变量替换和 SSRF 防护 - 将按钮动作链拆分为 before、main、after 三个阶段 - 新增前置和后置动作 JSON 字段存储 - 实现批量执行多个函数的 executeFunctions 动作类型 - 更新数据库迁移以支持新的字段结构 - 调整相关 DTO、命令和处理器以兼容新功能
This commit is contained in:
parent
d1c8988054
commit
a978e7f868
@ -30,7 +30,9 @@ public class CreateBuildFlowButtonDefinitionEndpoint(IMediator mediator)
|
||||
req.VisibleWhenJson,
|
||||
req.DisabledWhenJson,
|
||||
req.ConfirmJson,
|
||||
req.BeforeActionsJson,
|
||||
req.ActionsJson,
|
||||
req.AfterActionsJson,
|
||||
req.SortOrder),
|
||||
ct);
|
||||
await Send.ResponseAsync(result, 201, ct);
|
||||
|
||||
@ -2,7 +2,7 @@ using Workflow.Domain.Enums;
|
||||
|
||||
namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.Buttons;
|
||||
|
||||
/// <summary>创建 BuildFlow 按钮定义请求。</summary>
|
||||
/// <summary>创建 BuildFlow 按钮定义请求。含三阶段动作链(before/main/after)。</summary>
|
||||
public sealed record CreateBuildFlowButtonDefinitionRequest(
|
||||
Guid? ApplicationId,
|
||||
Guid? PageDefinitionId,
|
||||
@ -16,5 +16,7 @@ public sealed record CreateBuildFlowButtonDefinitionRequest(
|
||||
string? VisibleWhenJson,
|
||||
string? DisabledWhenJson,
|
||||
string? ConfirmJson,
|
||||
string? BeforeActionsJson,
|
||||
string ActionsJson,
|
||||
string? AfterActionsJson,
|
||||
int SortOrder);
|
||||
|
||||
@ -21,7 +21,12 @@ public class CreateCustomFunctionEndpoint(IMediator mediator)
|
||||
req.Name,
|
||||
req.DisplayName,
|
||||
req.Description,
|
||||
req.Type,
|
||||
req.Code,
|
||||
req.HttpMethod,
|
||||
req.HttpUrl,
|
||||
req.HttpHeaders,
|
||||
req.HttpBodyTemplate,
|
||||
req.InputSchema,
|
||||
req.OutputSchema,
|
||||
req.TimeoutMs,
|
||||
|
||||
@ -1,12 +1,19 @@
|
||||
using Workflow.Domain.Enums;
|
||||
|
||||
namespace BuildFlow.Host.Endpoints.Workflow.BuildFlow.CustomFunctions;
|
||||
|
||||
/// <summary>创建自定义函数请求。</summary>
|
||||
/// <summary>创建自定义函数请求。Type=Script 使用 Code;Type=Http 使用 HttpMethod/Url/Headers/BodyTemplate。</summary>
|
||||
public sealed record CreateCustomFunctionRequest(
|
||||
Guid? FormDefinitionId,
|
||||
string Name,
|
||||
string DisplayName,
|
||||
string? Description,
|
||||
CustomFunctionType Type,
|
||||
string Code,
|
||||
string? HttpMethod,
|
||||
string? HttpUrl,
|
||||
string? HttpHeaders,
|
||||
string? HttpBodyTemplate,
|
||||
string? InputSchema,
|
||||
string? OutputSchema,
|
||||
int TimeoutMs);
|
||||
|
||||
@ -9,8 +9,11 @@ public class CreateMenuRequestValidator : Validator<CreateMenuRequest>
|
||||
{
|
||||
public CreateMenuRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().WithMessage("菜单路由名称不能为空")
|
||||
.MaximumLength(100).WithMessage("菜单路由名称长度不能超过 100 个字符");
|
||||
// Name 可选:留空时由后端根据 Code 派生,无需用户填写。
|
||||
When(x => x.Name is not null, () =>
|
||||
{
|
||||
RuleFor(x => x.Name!).MaximumLength(100).WithMessage("菜单路由名称长度不能超过 100 个字符");
|
||||
});
|
||||
RuleFor(x => x.Path).NotEmpty().WithMessage("路由路径不能为空")
|
||||
.MaximumLength(200).WithMessage("路由路径长度不能超过 200 个字符");
|
||||
RuleFor(x => x.Title).NotEmpty().WithMessage("菜单标题不能为空")
|
||||
|
||||
189
src/BuildFlow.Host/Services/Integration/HttpFunctionExecutor.cs
Normal file
189
src/BuildFlow.Host/Services/Integration/HttpFunctionExecutor.cs
Normal file
@ -0,0 +1,189 @@
|
||||
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]}...";
|
||||
}
|
||||
@ -4,7 +4,7 @@ using Workflow.Domain.Enums;
|
||||
|
||||
namespace Workflow.Application.BuildFlow.Buttons.Commands;
|
||||
|
||||
/// <summary>创建 BuildFlow 按钮定义命令。按钮动作链以后端元数据形式保存。</summary>
|
||||
/// <summary>创建 BuildFlow 按钮定义命令。三阶段动作链(before/main/after)以后端元数据形式保存。</summary>
|
||||
public sealed record CreateBuildFlowButtonDefinitionCommand(
|
||||
Guid? ApplicationId,
|
||||
Guid? PageDefinitionId,
|
||||
@ -18,5 +18,7 @@ public sealed record CreateBuildFlowButtonDefinitionCommand(
|
||||
string? VisibleWhenJson,
|
||||
string? DisabledWhenJson,
|
||||
string? ConfirmJson,
|
||||
string? BeforeActionsJson,
|
||||
string ActionsJson,
|
||||
string? AfterActionsJson,
|
||||
int SortOrder) : IRequest<BuildFlowButtonDefinitionDto>;
|
||||
|
||||
@ -2,7 +2,7 @@ using Workflow.Domain.Enums;
|
||||
|
||||
namespace Workflow.Application.BuildFlow.Buttons.DTOs;
|
||||
|
||||
/// <summary>BuildFlow 按钮定义 DTO,用于运行时渲染按钮和动作配置。</summary>
|
||||
/// <summary>BuildFlow 按钮定义 DTO,用于运行时渲染按钮和动作配置。含三阶段动作链(before/main/after)。</summary>
|
||||
public sealed record BuildFlowButtonDefinitionDto(
|
||||
Guid Id,
|
||||
Guid? ApplicationId,
|
||||
@ -17,6 +17,8 @@ public sealed record BuildFlowButtonDefinitionDto(
|
||||
string? VisibleWhenJson,
|
||||
string? DisabledWhenJson,
|
||||
string? ConfirmJson,
|
||||
string? BeforeActionsJson,
|
||||
string ActionsJson,
|
||||
string? AfterActionsJson,
|
||||
BuildFlowMetadataStatus Status,
|
||||
int SortOrder);
|
||||
|
||||
@ -29,7 +29,9 @@ public class CreateBuildFlowButtonDefinitionCommandHandler(WorkflowDbContext db)
|
||||
VisibleWhenJson = request.VisibleWhenJson,
|
||||
DisabledWhenJson = request.DisabledWhenJson,
|
||||
ConfirmJson = request.ConfirmJson,
|
||||
BeforeActionsJson = request.BeforeActionsJson,
|
||||
ActionsJson = string.IsNullOrWhiteSpace(request.ActionsJson) ? "[]" : request.ActionsJson,
|
||||
AfterActionsJson = request.AfterActionsJson,
|
||||
SortOrder = request.SortOrder,
|
||||
};
|
||||
db.BuildFlowButtons.Add(entity);
|
||||
@ -52,7 +54,9 @@ public class CreateBuildFlowButtonDefinitionCommandHandler(WorkflowDbContext db)
|
||||
entity.VisibleWhenJson,
|
||||
entity.DisabledWhenJson,
|
||||
entity.ConfirmJson,
|
||||
entity.BeforeActionsJson,
|
||||
entity.ActionsJson,
|
||||
entity.AfterActionsJson,
|
||||
entity.Status,
|
||||
entity.SortOrder);
|
||||
}
|
||||
|
||||
@ -38,7 +38,9 @@ public class DisableBuildFlowButtonDefinitionCommandHandler(WorkflowDbContext db
|
||||
entity.VisibleWhenJson,
|
||||
entity.DisabledWhenJson,
|
||||
entity.ConfirmJson,
|
||||
entity.BeforeActionsJson,
|
||||
entity.ActionsJson,
|
||||
entity.AfterActionsJson,
|
||||
entity.Status,
|
||||
entity.SortOrder);
|
||||
}
|
||||
|
||||
@ -66,7 +66,9 @@ public sealed class GetBuildFlowButtonDefinitionListQueryHandler(WorkflowDbConte
|
||||
x.VisibleWhenJson,
|
||||
x.DisabledWhenJson,
|
||||
x.ConfirmJson,
|
||||
x.BeforeActionsJson,
|
||||
x.ActionsJson,
|
||||
x.AfterActionsJson,
|
||||
x.Status,
|
||||
x.SortOrder))
|
||||
.ToListAsync(ct);
|
||||
|
||||
@ -38,7 +38,9 @@ public class PublishBuildFlowButtonDefinitionCommandHandler(WorkflowDbContext db
|
||||
entity.VisibleWhenJson,
|
||||
entity.DisabledWhenJson,
|
||||
entity.ConfirmJson,
|
||||
entity.BeforeActionsJson,
|
||||
entity.ActionsJson,
|
||||
entity.AfterActionsJson,
|
||||
entity.Status,
|
||||
entity.SortOrder);
|
||||
}
|
||||
|
||||
@ -51,34 +51,13 @@ public class BuildFlowButtonActionExecutor(
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var results = new List<CustomFunctionExecutionResult>();
|
||||
var actions = ParseActions(button.ActionsJson);
|
||||
var input = ParseInput(inputJson);
|
||||
|
||||
foreach (var action in actions)
|
||||
{
|
||||
var type = action["type"]?.GetValue<string>();
|
||||
if (type == "submitForm")
|
||||
{
|
||||
await SubmitFormAsync(button, action, input, ct);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type != "runFunction")
|
||||
{
|
||||
throw new BusinessException($"按钮动作类型 {type} 暂不支持");
|
||||
}
|
||||
|
||||
var functionIdText = action["target"]?.GetValue<string>();
|
||||
if (!Guid.TryParse(functionIdText, out var functionId))
|
||||
{
|
||||
throw new BusinessException("runFunction 动作缺少有效函数ID");
|
||||
}
|
||||
|
||||
await EnsureFunctionScopeAsync(button, functionId, ct);
|
||||
var functionInput = BuildFunctionInputJson(action["params"] as JsonObject, input);
|
||||
var result = await functionExecutionService.ExecuteAsync(functionId, functionInput, ct);
|
||||
results.Add(result);
|
||||
}
|
||||
// 三阶段执行:before(执行期/前置钩子)→ main(执行中/主链)→ after(执行后/后置钩子)
|
||||
// 任一阶段失败即中断,input 对象在阶段间和动作间流转
|
||||
await ExecuteActionsList(button, button.BeforeActionsJson, input, results, "前置动作", ct);
|
||||
await ExecuteActionsList(button, button.ActionsJson, input, results, "主动作", ct);
|
||||
await ExecuteActionsList(button, button.AfterActionsJson, input, results, "后置动作", ct);
|
||||
|
||||
stopwatch.Stop();
|
||||
var resultJson = JsonSerializer.Serialize(results, JsonOptions);
|
||||
@ -96,6 +75,92 @@ public class BuildFlowButtonActionExecutor(
|
||||
return new BuildFlowButtonActionResult(true, results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行一条动作链(before/main/after 共用)。
|
||||
/// actionsJson 为空或 "[]" 时直接返回。任一动作失败即中断。
|
||||
/// </summary>
|
||||
private async Task ExecuteActionsList(
|
||||
BuildFlowButtonDefinition button,
|
||||
string? actionsJson,
|
||||
JsonObject input,
|
||||
List<CustomFunctionExecutionResult> results,
|
||||
string phaseLabel,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(actionsJson))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var actions = ParseActions(actionsJson);
|
||||
foreach (var action in actions)
|
||||
{
|
||||
await ExecuteSingleAction(button, action, input, results, phaseLabel, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteSingleAction(
|
||||
BuildFlowButtonDefinition button,
|
||||
JsonObject action,
|
||||
JsonObject input,
|
||||
List<CustomFunctionExecutionResult> results,
|
||||
string phaseLabel,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var type = action["type"]?.GetValue<string>();
|
||||
|
||||
if (type == "submitForm")
|
||||
{
|
||||
await SubmitFormAsync(button, action, input, ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == "runFunction")
|
||||
{
|
||||
var functionId = ResolveFunctionId(action, "runFunction");
|
||||
await EnsureFunctionScopeAsync(button, functionId, ct);
|
||||
var functionInput = BuildFunctionInputJson(action["params"] as JsonObject, input);
|
||||
var result = await functionExecutionService.ExecuteAsync(functionId, functionInput, ct);
|
||||
results.Add(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == "executeFunctions")
|
||||
{
|
||||
// 批量执行多个函数,targets 是函数 ID 数组
|
||||
var targets = action["targets"];
|
||||
if (targets is not JsonArray targetArray || targetArray.Count == 0)
|
||||
{
|
||||
throw new BusinessException("executeFunctions 动作缺少 targets 函数ID数组");
|
||||
}
|
||||
foreach (var targetNode in targetArray)
|
||||
{
|
||||
var functionIdText = targetNode?.GetValue<string>();
|
||||
if (!Guid.TryParse(functionIdText, out var functionId))
|
||||
{
|
||||
throw new BusinessException("executeFunctions 动作 targets 包含无效函数ID");
|
||||
}
|
||||
await EnsureFunctionScopeAsync(button, functionId, ct);
|
||||
var functionInput = BuildFunctionInputJson(action["params"] as JsonObject, input);
|
||||
var result = await functionExecutionService.ExecuteAsync(functionId, functionInput, ct);
|
||||
results.Add(result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new BusinessException($"按钮{phaseLabel}类型 {type} 暂不支持");
|
||||
}
|
||||
|
||||
private static Guid ResolveFunctionId(JsonObject action, string actionType)
|
||||
{
|
||||
var functionIdText = action["target"]?.GetValue<string>();
|
||||
if (!Guid.TryParse(functionIdText, out var functionId))
|
||||
{
|
||||
throw new BusinessException($"{actionType} 动作缺少有效函数ID");
|
||||
}
|
||||
return functionId;
|
||||
}
|
||||
|
||||
private async Task EnsureFunctionScopeAsync(
|
||||
BuildFlowButtonDefinition button,
|
||||
Guid functionId,
|
||||
|
||||
@ -1,14 +1,23 @@
|
||||
using MediatR;
|
||||
using Workflow.Application.BuildFlow.CustomFunctions.DTOs;
|
||||
using Workflow.Domain.Enums;
|
||||
|
||||
namespace Workflow.Application.BuildFlow.CustomFunctions.Commands;
|
||||
|
||||
/// <summary>创建自定义函数命令。新函数默认是草稿,必须发布后才能被动作链执行。</summary>
|
||||
/// <summary>
|
||||
/// 创建自定义函数命令。新函数默认是草稿,必须发布后才能被动作链执行。
|
||||
/// Type=Script 时使用 Code 字段;Type=Http 时使用 HttpMethod/HttpUrl/HttpHeaders/HttpBodyTemplate。
|
||||
/// </summary>
|
||||
public sealed record CreateCustomFunctionCommand(
|
||||
string Name,
|
||||
string DisplayName,
|
||||
string? Description,
|
||||
CustomFunctionType Type,
|
||||
string Code,
|
||||
string? HttpMethod,
|
||||
string? HttpUrl,
|
||||
string? HttpHeaders,
|
||||
string? HttpBodyTemplate,
|
||||
string? InputSchema,
|
||||
string? OutputSchema,
|
||||
int TimeoutMs,
|
||||
|
||||
@ -13,7 +13,12 @@ internal static class CustomFunctionMapper
|
||||
entity.Name,
|
||||
entity.DisplayName,
|
||||
entity.Description,
|
||||
entity.Type,
|
||||
entity.Code,
|
||||
entity.HttpMethod,
|
||||
entity.HttpUrl,
|
||||
entity.HttpHeaders,
|
||||
entity.HttpBodyTemplate,
|
||||
entity.InputSchema,
|
||||
entity.OutputSchema,
|
||||
entity.Status,
|
||||
|
||||
@ -2,14 +2,19 @@ using Workflow.Domain.Enums;
|
||||
|
||||
namespace Workflow.Application.BuildFlow.CustomFunctions.DTOs;
|
||||
|
||||
/// <summary>自定义函数 DTO,用于函数管理页面和按钮动作选择器。</summary>
|
||||
/// <summary>自定义函数 DTO,用于函数管理页面和按钮动作选择器。含执行类型和 HTTP 配置。</summary>
|
||||
public sealed record CustomFunctionDto(
|
||||
Guid Id,
|
||||
Guid? FormDefinitionId,
|
||||
string Name,
|
||||
string DisplayName,
|
||||
string? Description,
|
||||
CustomFunctionType Type,
|
||||
string Code,
|
||||
string? HttpMethod,
|
||||
string? HttpUrl,
|
||||
string? HttpHeaders,
|
||||
string? HttpBodyTemplate,
|
||||
string? InputSchema,
|
||||
string? OutputSchema,
|
||||
BuildFlowMetadataStatus Status,
|
||||
|
||||
@ -24,7 +24,12 @@ public class CreateCustomFunctionCommandHandler(WorkflowDbContext db)
|
||||
Name = request.Name,
|
||||
DisplayName = request.DisplayName,
|
||||
Description = request.Description,
|
||||
Type = request.Type,
|
||||
Code = request.Code,
|
||||
HttpMethod = request.HttpMethod,
|
||||
HttpUrl = request.HttpUrl,
|
||||
HttpHeaders = request.HttpHeaders,
|
||||
HttpBodyTemplate = request.HttpBodyTemplate,
|
||||
InputSchema = request.InputSchema,
|
||||
OutputSchema = request.OutputSchema,
|
||||
TimeoutMs = request.TimeoutMs <= 0 ? 5000 : request.TimeoutMs,
|
||||
|
||||
@ -44,7 +44,12 @@ public sealed class GetCustomFunctionListQueryHandler(WorkflowDbContext db)
|
||||
x.Name,
|
||||
x.DisplayName,
|
||||
x.Description,
|
||||
x.Type,
|
||||
x.Code,
|
||||
x.HttpMethod,
|
||||
x.HttpUrl,
|
||||
x.HttpHeaders,
|
||||
x.HttpBodyTemplate,
|
||||
x.InputSchema,
|
||||
x.OutputSchema,
|
||||
x.Status,
|
||||
|
||||
@ -7,13 +7,17 @@ using Workflow.Application.BuildFlow.CustomFunctions.DTOs;
|
||||
using Workflow.Domain.Entities;
|
||||
using Workflow.Domain.Enums;
|
||||
using Workflow.Infrastructure.Persistence;
|
||||
using BuildFlow.Shared.Domain.Common;
|
||||
|
||||
namespace Workflow.Application.BuildFlow.CustomFunctions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 自定义函数执行服务。仅运行已发布函数,使用超时令牌限制脚本执行,并写入函数执行日志。
|
||||
/// 按 function.Type 分派到脚本执行器(CSharpScript)或 HTTP 执行器(IHttpFunctionExecutor)。
|
||||
/// </summary>
|
||||
public class CustomFunctionExecutionService(WorkflowDbContext db) : ICustomFunctionExecutionService
|
||||
public class CustomFunctionExecutionService(
|
||||
WorkflowDbContext db,
|
||||
IHttpFunctionExecutor? httpExecutor = null) : ICustomFunctionExecutionService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@ -43,9 +47,24 @@ public class CustomFunctionExecutionService(WorkflowDbContext db) : ICustomFunct
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(function.TimeoutMs <= 0 ? 5000 : function.TimeoutMs));
|
||||
var result = await EvaluateAsync(function.Code, inputJson, timeoutCts.Token);
|
||||
|
||||
string resultJson;
|
||||
if (function.Type == CustomFunctionType.Http)
|
||||
{
|
||||
if (httpExecutor is null)
|
||||
{
|
||||
throw new BusinessException("HTTP 函数执行器未注册,无法执行 HTTP 类型函数");
|
||||
}
|
||||
var httpResult = await httpExecutor.ExecuteAsync(function, inputJson, timeoutCts.Token);
|
||||
resultJson = httpResult;
|
||||
}
|
||||
else
|
||||
{
|
||||
var result = await EvaluateAsync(function.Code, inputJson, timeoutCts.Token);
|
||||
resultJson = JsonSerializer.Serialize(result, JsonOptions);
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var resultJson = JsonSerializer.Serialize(result, JsonOptions);
|
||||
await AddLogAsync(function, CustomFunctionExecutionStatus.Success, inputJson, resultJson, null, stopwatch.ElapsedMilliseconds, ct);
|
||||
return new CustomFunctionExecutionResult(
|
||||
CustomFunctionExecutionStatus.Success,
|
||||
@ -60,6 +79,13 @@ public class CustomFunctionExecutionService(WorkflowDbContext db) : ICustomFunct
|
||||
await AddLogAsync(function, CustomFunctionExecutionStatus.Timeout, inputJson, null, message, stopwatch.ElapsedMilliseconds, CancellationToken.None);
|
||||
throw new BusinessException(message);
|
||||
}
|
||||
catch (BusinessException)
|
||||
{
|
||||
// HTTP 执行器抛出的 BusinessException(如 SSRF 拦截、非 2xx)直接冒泡,不包裹
|
||||
stopwatch.Stop();
|
||||
await AddLogAsync(function, CustomFunctionExecutionStatus.Failed, inputJson, null, "HTTP 函数执行失败", stopwatch.ElapsedMilliseconds, ct);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is CompilationErrorException or InvalidOperationException or JsonException)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
using Workflow.Domain.Entities;
|
||||
|
||||
namespace Workflow.Application.BuildFlow.CustomFunctions.Services;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP 函数执行器抽象。实现位于 Host 层(可访问 IHttpClientFactory 和 SSRF 白名单配置)。
|
||||
/// 由 CustomFunctionExecutionService 按 function.Type=Http 分派调用。
|
||||
/// </summary>
|
||||
public interface IHttpFunctionExecutor
|
||||
{
|
||||
/// <summary>
|
||||
/// 执行 HTTP 类型函数。返回响应体 JSON 字符串。
|
||||
/// 超时、SSRF 校验、状态码非 2xx 均抛 BusinessException。
|
||||
/// </summary>
|
||||
Task<string> ExecuteAsync(CustomFunction function, string inputJson, CancellationToken ct);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,138 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Workflow.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomFunctionTypeAndButtonPhases : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "code",
|
||||
table: "b_funcs",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
comment: "C# 脚本代码(Script类型使用)",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldComment: "C# 脚本代码");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "http_body_template",
|
||||
table: "b_funcs",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
comment: "HTTP请求体模板(Http类型使用)");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "http_headers",
|
||||
table: "b_funcs",
|
||||
type: "jsonb",
|
||||
nullable: true,
|
||||
comment: "HTTP请求头JSON(Http类型使用)");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "http_method",
|
||||
table: "b_funcs",
|
||||
type: "character varying(20)",
|
||||
maxLength: 20,
|
||||
nullable: true,
|
||||
comment: "HTTP方法(Http类型使用)");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "http_url",
|
||||
table: "b_funcs",
|
||||
type: "character varying(2000)",
|
||||
maxLength: 2000,
|
||||
nullable: true,
|
||||
comment: "HTTP目标URL(Http类型使用)");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "type",
|
||||
table: "b_funcs",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
comment: "函数执行类型:Script=受控C#脚本;Http=HTTP请求");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "actions_json",
|
||||
table: "b_buttons",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
comment: "执行中(主链)动作链JSON",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "jsonb",
|
||||
oldComment: "按钮动作链JSON");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "after_actions_json",
|
||||
table: "b_buttons",
|
||||
type: "jsonb",
|
||||
nullable: true,
|
||||
comment: "执行后(后置)动作链JSON");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "before_actions_json",
|
||||
table: "b_buttons",
|
||||
type: "jsonb",
|
||||
nullable: true,
|
||||
comment: "执行期(前置)动作链JSON");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "http_body_template",
|
||||
table: "b_funcs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "http_headers",
|
||||
table: "b_funcs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "http_method",
|
||||
table: "b_funcs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "http_url",
|
||||
table: "b_funcs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "type",
|
||||
table: "b_funcs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "after_actions_json",
|
||||
table: "b_buttons");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "before_actions_json",
|
||||
table: "b_buttons");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "code",
|
||||
table: "b_funcs",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
comment: "C# 脚本代码",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "text",
|
||||
oldComment: "C# 脚本代码(Script类型使用)");
|
||||
|
||||
migrationBuilder.AlterColumn<string>(
|
||||
name: "actions_json",
|
||||
table: "b_buttons",
|
||||
type: "jsonb",
|
||||
nullable: false,
|
||||
comment: "按钮动作链JSON",
|
||||
oldClrType: typeof(string),
|
||||
oldType: "jsonb",
|
||||
oldComment: "执行中(主链)动作链JSON");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -200,13 +200,23 @@ namespace BuildFlow.Workflow.Infrastructure.Persistence.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("actions_json")
|
||||
.HasComment("按钮动作链JSON");
|
||||
.HasComment("执行中(主链)动作链JSON");
|
||||
|
||||
b.Property<string>("AfterActionsJson")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("after_actions_json")
|
||||
.HasComment("执行后(后置)动作链JSON");
|
||||
|
||||
b.Property<Guid?>("ApplicationId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("application_id")
|
||||
.HasComment("所属 BuildFlow 应用ID");
|
||||
|
||||
b.Property<string>("BeforeActionsJson")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("before_actions_json")
|
||||
.HasComment("执行期(前置)动作链JSON");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
@ -669,7 +679,7 @@ namespace BuildFlow.Workflow.Infrastructure.Persistence.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("code")
|
||||
.HasComment("C# 脚本代码");
|
||||
.HasComment("C# 脚本代码(Script类型使用)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
@ -701,6 +711,28 @@ namespace BuildFlow.Workflow.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("form_definition_id")
|
||||
.HasComment("绑定的自定义表单定义ID,为空表示全局可复用函数");
|
||||
|
||||
b.Property<string>("HttpBodyTemplate")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("http_body_template")
|
||||
.HasComment("HTTP请求体模板(Http类型使用)");
|
||||
|
||||
b.Property<string>("HttpHeaders")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("http_headers")
|
||||
.HasComment("HTTP请求头JSON(Http类型使用)");
|
||||
|
||||
b.Property<string>("HttpMethod")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("http_method")
|
||||
.HasComment("HTTP方法(Http类型使用)");
|
||||
|
||||
b.Property<string>("HttpUrl")
|
||||
.HasMaxLength(2000)
|
||||
.HasColumnType("character varying(2000)")
|
||||
.HasColumnName("http_url")
|
||||
.HasComment("HTTP目标URL(Http类型使用)");
|
||||
|
||||
b.Property<string>("InputSchema")
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("input_schema")
|
||||
@ -742,6 +774,11 @@ namespace BuildFlow.Workflow.Infrastructure.Persistence.Migrations
|
||||
.HasColumnName("timeout_ms")
|
||||
.HasComment("执行超时时间(毫秒)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("type")
|
||||
.HasComment("函数执行类型:Script=受控C#脚本;Http=HTTP请求");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
|
||||
@ -115,7 +115,9 @@ public class BuildFlowApplicationTests
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"[]",
|
||||
null,
|
||||
10),
|
||||
CancellationToken.None);
|
||||
await buttonHandler.Handle(
|
||||
@ -132,7 +134,9 @@ public class BuildFlowApplicationTests
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"[]",
|
||||
null,
|
||||
20),
|
||||
CancellationToken.None);
|
||||
|
||||
@ -160,10 +164,10 @@ public class BuildFlowApplicationTests
|
||||
var createHandler = new CreateCustomFunctionCommandHandler(db);
|
||||
var publishHandler = new PublishCustomFunctionCommandHandler(db);
|
||||
var draft = await createHandler.Handle(
|
||||
new CreateCustomFunctionCommand("draftRule", "草稿规则", null, "1", null, null, 1000),
|
||||
new CreateCustomFunctionCommand("draftRule", "草稿规则", null, CustomFunctionType.Script, "1", null, null, null, null, null, null, 1000),
|
||||
CancellationToken.None);
|
||||
var published = await createHandler.Handle(
|
||||
new CreateCustomFunctionCommand("publishedRule", "已发布规则", null, "2", null, null, 1000),
|
||||
new CreateCustomFunctionCommand("publishedRule", "已发布规则", null, CustomFunctionType.Script, "2", null, null, null, null, null, null, 1000),
|
||||
CancellationToken.None);
|
||||
await publishHandler.Handle(new PublishCustomFunctionCommand(published.Id), CancellationToken.None);
|
||||
|
||||
@ -189,9 +193,14 @@ public class BuildFlowApplicationTests
|
||||
"expenseAmountCalc",
|
||||
"费用金额计算",
|
||||
null,
|
||||
CustomFunctionType.Script,
|
||||
"return input.amount;",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
1000,
|
||||
expenseFormId),
|
||||
CancellationToken.None);
|
||||
@ -200,9 +209,14 @@ public class BuildFlowApplicationTests
|
||||
"purchaseAmountCalc",
|
||||
"采购金额计算",
|
||||
null,
|
||||
CustomFunctionType.Script,
|
||||
"return input.amount;",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
1000,
|
||||
purchaseFormId),
|
||||
CancellationToken.None);
|
||||
|
||||
@ -40,7 +40,9 @@ public class BuildFlowButtonActionTests
|
||||
null,
|
||||
null,
|
||||
"""{"title":"确认提交?"}""",
|
||||
null,
|
||||
"""[]""",
|
||||
null,
|
||||
10),
|
||||
CancellationToken.None);
|
||||
|
||||
@ -287,9 +289,14 @@ public class BuildFlowButtonActionTests
|
||||
"button-amount-level",
|
||||
"按钮金额分级",
|
||||
null,
|
||||
CustomFunctionType.Script,
|
||||
"((System.Text.Json.JsonElement)Variables[\"amount\"]).GetDecimal() > 1000 ? \"high\" : \"normal\"",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
5000,
|
||||
formDefinitionId),
|
||||
CancellationToken.None);
|
||||
|
||||
@ -25,7 +25,12 @@ public class CustomFunctionTests
|
||||
"amount-level",
|
||||
"金额分级",
|
||||
"按金额输出审批等级",
|
||||
CustomFunctionType.Script,
|
||||
"((System.Text.Json.JsonElement)Variables[\"amount\"]).GetDecimal() > 1000 ? \"high\" : \"normal\"",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
"""{"type":"object"}""",
|
||||
"""{"type":"string"}""",
|
||||
5000),
|
||||
@ -56,9 +61,14 @@ public class CustomFunctionTests
|
||||
"disabled-func",
|
||||
"禁用函数",
|
||||
null,
|
||||
CustomFunctionType.Script,
|
||||
""""ok"""",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
5000),
|
||||
CancellationToken.None);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user