feat(workflow): 添加自定义函数类型和按钮三阶段动作链功能

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

View File

@ -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);

View File

@ -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);

View File

@ -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,

View File

@ -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);

View File

@ -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("菜单标题不能为空")

View 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]}...";
}

View File

@ -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>;

View File

@ -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);

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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();

View File

@ -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);
}

View File

@ -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");
}
}
}

View File

@ -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")

View File

@ -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);

View File

@ -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);

View File

@ -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);