feat(scripting): ScriptGlobals + ScriptExecutionService + 沙箱 + 编译缓存
- ScriptGlobals 注入 ISender/ICurrentUserContext/CancellationToken/ScriptLogger - ScriptLogger 收集日志写入 ScriptExecutionLog.LogLines - 变量类型升级:JsonElement→原生类型(long/double/decimal/bool/string/DateTime/Guid) - 程序集白名单(含 BuildFlow.* Application/Domain,排除 Infrastructure) - 代码黑名单扫描(防 System.Reflection/IO.File/Net.Http 等) - 编译缓存(按 code SHA256 缓存委托,上限 500) - 调用深度计数器(AsyncLocal,超 5 抛异常,防递归) - 草稿可测(TestExecuteAsync 跳过 Published 校验) Task T3 of scripting module refactor
This commit is contained in:
parent
a978e7f868
commit
624579cf78
@ -41,5 +41,6 @@
|
|||||||
<Project Path="tests/BuildFlow.IM.Tests/BuildFlow.IM.Tests.csproj" />
|
<Project Path="tests/BuildFlow.IM.Tests/BuildFlow.IM.Tests.csproj" />
|
||||||
<Project Path="tests/BuildFlow.IM.Integration.Tests/BuildFlow.IM.Integration.Tests.csproj" />
|
<Project Path="tests/BuildFlow.IM.Integration.Tests/BuildFlow.IM.Integration.Tests.csproj" />
|
||||||
<Project Path="tests/BuildFlow.Workflow.Tests/BuildFlow.Workflow.Tests.csproj" />
|
<Project Path="tests/BuildFlow.Workflow.Tests/BuildFlow.Workflow.Tests.csproj" />
|
||||||
|
<Project Path="tests/BuildFlow.Scripting.Tests/BuildFlow.Scripting.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
using Scripting.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Scripting.Application.DTOs;
|
||||||
|
|
||||||
|
/// <summary>脚本执行结果 DTO。</summary>
|
||||||
|
public sealed record ScriptExecutionResult(
|
||||||
|
ScriptExecutionStatus Status,
|
||||||
|
string? ResultJson,
|
||||||
|
string? ErrorMessage,
|
||||||
|
long DurationMs,
|
||||||
|
string? LogLines = null);
|
||||||
45
src/modules/scripting/Application/Host/ScriptGlobals.cs
Normal file
45
src/modules/scripting/Application/Host/ScriptGlobals.cs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
using BuildFlow.Shared.Domain.Common;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Scripting.Application.Host;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 脚本宿主对象:向 C# 脚本暴露输入变量、MediatR 发送器、当前用户上下文、超时令牌与受控日志。
|
||||||
|
/// 脚本通过此对象调用系统任意 Command/Query(MediatR 全开放),权限继承触发者身份。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ScriptGlobals
|
||||||
|
{
|
||||||
|
public ScriptGlobals(
|
||||||
|
IReadOnlyDictionary<string, object?> variables,
|
||||||
|
string inputJson,
|
||||||
|
ISender mediator,
|
||||||
|
ICurrentUserContext currentUser,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
ScriptLogger logger)
|
||||||
|
{
|
||||||
|
Variables = variables;
|
||||||
|
InputJson = inputJson;
|
||||||
|
Mediator = mediator;
|
||||||
|
CurrentUser = currentUser;
|
||||||
|
CancellationToken = cancellationToken;
|
||||||
|
Logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>输入变量(已从 JSON 转为 .NET 原生类型:long/double/decimal/bool/string/DateTime/Guid)。</summary>
|
||||||
|
public IReadOnlyDictionary<string, object?> Variables { get; }
|
||||||
|
|
||||||
|
/// <summary>原始输入 JSON 字符串,兜底使用。</summary>
|
||||||
|
public string InputJson { get; }
|
||||||
|
|
||||||
|
/// <summary>MediatR 发送器。脚本里 await Mediator.Send(query, CancellationToken) 调用任意系统方法。</summary>
|
||||||
|
public ISender Mediator { get; }
|
||||||
|
|
||||||
|
/// <summary>当前触发用户上下文(继承触发者身份与权限)。</summary>
|
||||||
|
public ICurrentUserContext CurrentUser { get; }
|
||||||
|
|
||||||
|
/// <summary>超时令牌。必须传给 Mediator.Send 的第二参数,否则脚本超时时 handler 不会停。</summary>
|
||||||
|
public CancellationToken CancellationToken { get; }
|
||||||
|
|
||||||
|
/// <summary>受控日志器。Logger.Info/Debug 输出会写入 ScriptExecutionLog.LogLines。</summary>
|
||||||
|
public ScriptLogger Logger { get; }
|
||||||
|
}
|
||||||
27
src/modules/scripting/Application/Host/ScriptLogger.cs
Normal file
27
src/modules/scripting/Application/Host/ScriptLogger.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
namespace Scripting.Application.Host;
|
||||||
|
|
||||||
|
/// <summary>受控日志器:脚本里 Logger.Info/Debug/Warn 的输出会被收集,写入 ScriptExecutionLog.LogLines。</summary>
|
||||||
|
public sealed class ScriptLogger
|
||||||
|
{
|
||||||
|
private readonly List<string> _lines = new();
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Lines
|
||||||
|
{
|
||||||
|
get { lock (_lock) { return _lines.ToArray(); } }
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Info(string message) => Append("INFO", message);
|
||||||
|
public void Debug(string message) => Append("DEBUG", message);
|
||||||
|
public void Warn(string message) => Append("WARN", message);
|
||||||
|
public void Error(string message) => Append("ERROR", message);
|
||||||
|
|
||||||
|
private void Append(string level, string message)
|
||||||
|
{
|
||||||
|
var line = $"[{level}] {message}";
|
||||||
|
lock (_lock) { _lines.Add(line); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>收集到的日志合并为多行文本,供持久化到 ScriptExecutionLog.LogLines。</summary>
|
||||||
|
public string ToText() => string.Join("\n", Lines);
|
||||||
|
}
|
||||||
@ -10,9 +10,16 @@
|
|||||||
<PackageReference Include="MediatR" Version="12.5.0" />
|
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||||
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
<PackageReference Include="FluentValidation" Version="12.1.1" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.Scripting" Version="5.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Infrastructure\Scripting.Infrastructure.csproj" />
|
<ProjectReference Include="..\Infrastructure\Scripting.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\platform\Application\Platform.Application.csproj" />
|
<ProjectReference Include="..\..\platform\Application\Platform.Application.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<!-- 暴露 internal 成员给测试项目(ScriptExecutionService.ParseVariables 纯函数测试) -->
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="BuildFlow.Scripting.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
using BuildFlow.Platform.Application;
|
using BuildFlow.Platform.Application;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Scripting.Application.Services;
|
||||||
using Scripting.Infrastructure;
|
using Scripting.Infrastructure;
|
||||||
using Volo.Abp.Modularity;
|
using Volo.Abp.Modularity;
|
||||||
|
|
||||||
namespace Scripting.Application;
|
namespace Scripting.Application;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scripting Application 层模块。注册 MediatR(自动发现 Handler)。
|
/// Scripting Application 层模块。注册 MediatR(自动发现 Handler)与脚本执行服务。
|
||||||
/// Roslyn 脚本引擎等服务注册留待 T3。
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[DependsOn(typeof(ScriptingInfrastructureModule), typeof(PlatformApplicationModule))]
|
[DependsOn(typeof(ScriptingInfrastructureModule), typeof(PlatformApplicationModule))]
|
||||||
public class ScriptingApplicationModule : AbpModule
|
public class ScriptingApplicationModule : AbpModule
|
||||||
@ -18,5 +18,6 @@ public class ScriptingApplicationModule : AbpModule
|
|||||||
var assembly = typeof(ScriptingApplicationModule).Assembly;
|
var assembly = typeof(ScriptingApplicationModule).Assembly;
|
||||||
|
|
||||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
|
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
|
||||||
|
services.AddScoped<IScriptExecutionService, ScriptExecutionService>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,16 @@
|
|||||||
|
using Scripting.Application.DTOs;
|
||||||
|
|
||||||
|
namespace Scripting.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>脚本执行服务:编译+运行 C# 脚本,注入 MediatR/CurrentUser/CancellationToken,并写执行日志。</summary>
|
||||||
|
public interface IScriptExecutionService
|
||||||
|
{
|
||||||
|
/// <summary>按 Id 执行已发布脚本(按钮动作链用)。</summary>
|
||||||
|
Task<ScriptExecutionResult> ExecuteByIdAsync(Guid scriptId, string inputJson, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>按 Name 执行已发布脚本(HTTP /scripts/{name}/invoke 用)。</summary>
|
||||||
|
Task<ScriptExecutionResult> ExecuteByNameAsync(string scriptName, string inputJson, CancellationToken ct);
|
||||||
|
|
||||||
|
/// <summary>测试运行:不校验 Published 状态,允许草稿直接测,仍写日志(FunctionName 标 [TEST])。</summary>
|
||||||
|
Task<ScriptExecutionResult> TestExecuteAsync(Guid scriptId, string inputJson, CancellationToken ct);
|
||||||
|
}
|
||||||
@ -0,0 +1,339 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using BuildFlow.Shared.Domain.Common;
|
||||||
|
using BuildFlow.Shared.Domain.Exceptions;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||||
|
using Microsoft.CodeAnalysis.Scripting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Scripting.Application.DTOs;
|
||||||
|
using Scripting.Application.Host;
|
||||||
|
using Scripting.Domain.Entities;
|
||||||
|
using Scripting.Domain.Enums;
|
||||||
|
using Scripting.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
namespace Scripting.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 脚本执行服务:编译+运行受控 C# 脚本,注入 MediatR/CurrentUser/CancellationToken/Logger,并写执行日志。
|
||||||
|
/// 通过 MediatR 全开放系统方法调用(权限继承触发者),含沙箱(程序集白名单 + API 黑名单)、
|
||||||
|
/// 编译缓存(按 code SHA256 缓存委托)、调用深度限制(防递归)、超时控制。
|
||||||
|
/// </summary>
|
||||||
|
public partial class ScriptExecutionService(
|
||||||
|
ScriptingDbContext db,
|
||||||
|
ISender mediator,
|
||||||
|
ICurrentUserContext currentUser) : IScriptExecutionService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>脚本调用深度计数器(AsyncLocal 跨 await 流动,防脚本递归调用自身)。</summary>
|
||||||
|
private static readonly AsyncLocal<int> _recursionDepth = new();
|
||||||
|
private const int MaxRecursionDepth = 5;
|
||||||
|
|
||||||
|
/// <summary>编译缓存:按 code SHA256 缓存可复用委托,避免每次执行重复编译。</summary>
|
||||||
|
private static readonly ConcurrentDictionary<string, ScriptRunner<object>> _compiledCache = new();
|
||||||
|
private const int MaxCacheSize = 500;
|
||||||
|
|
||||||
|
private const int DefaultTimeoutMs = 5000;
|
||||||
|
private const int MaxTimeoutMs = 30000;
|
||||||
|
|
||||||
|
/// <summary>脚本程序集白名单(§5.1):仅允许安全程序集,排除 Infrastructure(防直接访问 DbContext)。</summary>
|
||||||
|
private static readonly MetadataReference[] AllowedAssemblies = BuildAllowedAssemblies();
|
||||||
|
|
||||||
|
/// <summary>禁用 API 正则黑名单(§5.3,软约束 best-effort):拦截反射、文件、网络、进程等危险调用。</summary>
|
||||||
|
[GeneratedRegex(
|
||||||
|
@"System\.Reflection|System\.IO\.(File|Directory|FileInfo|FileStream)|System\.Net\.(Http|Sockets|WebClient)|System\.Diagnostics\.Process|AppDomain|Assembly\.(Load|Get)|typeof\s*\(",
|
||||||
|
RegexOptions.Compiled | RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex ForbiddenApiRegex { get; }
|
||||||
|
|
||||||
|
// ── 公共入口 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ScriptExecutionResult> ExecuteByIdAsync(Guid scriptId, string inputJson, CancellationToken ct)
|
||||||
|
=> ExecuteAsync(scriptId, null, inputJson, requirePublished: true, isTest: false, ct);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ScriptExecutionResult> ExecuteByNameAsync(string scriptName, string inputJson, CancellationToken ct)
|
||||||
|
=> ExecuteAsync(null, scriptName, inputJson, requirePublished: true, isTest: false, ct);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<ScriptExecutionResult> TestExecuteAsync(Guid scriptId, string inputJson, CancellationToken ct)
|
||||||
|
=> ExecuteAsync(scriptId, null, inputJson, requirePublished: false, isTest: true, ct);
|
||||||
|
|
||||||
|
// ── 核心执行 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task<ScriptExecutionResult> ExecuteAsync(
|
||||||
|
Guid? scriptId, string? scriptName, string inputJson,
|
||||||
|
bool requirePublished, bool isTest, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// E2 调用深度检查:防脚本通过 MediatR 触发自身形成无限递归
|
||||||
|
if (_recursionDepth.Value >= MaxRecursionDepth)
|
||||||
|
{
|
||||||
|
throw new BusinessException($"脚本调用深度超限(最大 {MaxRecursionDepth})");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载脚本定义
|
||||||
|
ScriptFunction function;
|
||||||
|
if (scriptId is { } id)
|
||||||
|
{
|
||||||
|
function = await db.Scripts.FindAsync([id], ct)
|
||||||
|
?? throw new BusinessException($"脚本函数 {id} 不存在");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var name = scriptName ?? throw new BusinessException("脚本名称不能为空");
|
||||||
|
function = await db.Scripts.FirstOrDefaultAsync(f => f.Name == name, ct)
|
||||||
|
?? throw new BusinessException($"脚本函数 {name} 不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Published 校验(TestExecute 跳过 — 草稿可测)
|
||||||
|
if (requirePublished)
|
||||||
|
{
|
||||||
|
if (function.Status == ScriptStatus.Disabled)
|
||||||
|
{
|
||||||
|
throw new BusinessException($"脚本函数 {function.DisplayName} 已禁用");
|
||||||
|
}
|
||||||
|
if (function.Status != ScriptStatus.Published)
|
||||||
|
{
|
||||||
|
throw new BusinessException($"脚本函数 {function.DisplayName} 尚未发布");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// E7 禁用 API 扫描(编译前静态检查,best-effort 软约束)
|
||||||
|
var forbiddenMatch = ForbiddenApiRegex.Match(function.Code);
|
||||||
|
if (forbiddenMatch.Success)
|
||||||
|
{
|
||||||
|
throw new BusinessException($"脚本包含禁用 API: {forbiddenMatch.Value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 深度计数 +1(finally 归位)
|
||||||
|
_recursionDepth.Value++;
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
var logger = new ScriptLogger();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 超时令牌:默认 5s,上限 30s
|
||||||
|
var timeoutMs = function.TimeoutMs <= 0 ? DefaultTimeoutMs : Math.Min(function.TimeoutMs, MaxTimeoutMs);
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
timeoutCts.CancelAfter(TimeSpan.FromMilliseconds(timeoutMs));
|
||||||
|
|
||||||
|
var variables = ParseVariables(inputJson);
|
||||||
|
var globals = new ScriptGlobals(
|
||||||
|
variables, inputJson, mediator, currentUser, timeoutCts.Token, logger);
|
||||||
|
|
||||||
|
// 编译缓存命中则复用委托,否则编译并入缓存
|
||||||
|
var runner = GetOrCreateRunner(function.Code);
|
||||||
|
var result = await runner(globals, timeoutCts.Token);
|
||||||
|
|
||||||
|
stopwatch.Stop();
|
||||||
|
var resultJson = JsonSerializer.Serialize(result, JsonOptions);
|
||||||
|
var logText = logger.ToText();
|
||||||
|
|
||||||
|
await AddLogAsync(function, isTest, ScriptExecutionStatus.Success,
|
||||||
|
inputJson, resultJson, null, stopwatch.ElapsedMilliseconds, logText, ct);
|
||||||
|
|
||||||
|
return new ScriptExecutionResult(
|
||||||
|
ScriptExecutionStatus.Success, resultJson, null,
|
||||||
|
stopwatch.ElapsedMilliseconds, string.IsNullOrEmpty(logText) ? null : logText);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
stopwatch.Stop();
|
||||||
|
const string message = "脚本执行超时";
|
||||||
|
await AddLogAsync(function, isTest, ScriptExecutionStatus.Timeout,
|
||||||
|
inputJson, null, message, stopwatch.ElapsedMilliseconds, logger.ToText(), CancellationToken.None);
|
||||||
|
throw new BusinessException(message);
|
||||||
|
}
|
||||||
|
catch (BusinessException)
|
||||||
|
{
|
||||||
|
// MediatR handler 抛出的业务异常(权限拒绝等)直接冒泡,不包裹
|
||||||
|
stopwatch.Stop();
|
||||||
|
await AddLogAsync(function, isTest, ScriptExecutionStatus.Failed,
|
||||||
|
inputJson, null, "脚本调用业务异常", stopwatch.ElapsedMilliseconds, logger.ToText(), ct);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is CompilationErrorException or InvalidOperationException or JsonException)
|
||||||
|
{
|
||||||
|
stopwatch.Stop();
|
||||||
|
await AddLogAsync(function, isTest, ScriptExecutionStatus.Failed,
|
||||||
|
inputJson, null, ex.Message, stopwatch.ElapsedMilliseconds, logger.ToText(), ct);
|
||||||
|
throw new BusinessException($"脚本执行失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_recursionDepth.Value--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 编译缓存 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>按 code SHA256 获取或创建可复用委托。超 500 条时淘汰一半(v1 简单策略)。</summary>
|
||||||
|
private ScriptRunner<object> GetOrCreateRunner(string code)
|
||||||
|
{
|
||||||
|
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(code));
|
||||||
|
var key = Convert.ToHexString(hash);
|
||||||
|
|
||||||
|
// 简单容量控制:超上限时淘汰一半
|
||||||
|
if (_compiledCache.Count > MaxCacheSize)
|
||||||
|
{
|
||||||
|
foreach (var k in _compiledCache.Keys.Take(MaxCacheSize / 2).ToList())
|
||||||
|
{
|
||||||
|
_compiledCache.TryRemove(k, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _compiledCache.GetOrAdd(key, _ =>
|
||||||
|
CSharpScript.Create<object>(code, BuildScriptOptions(), typeof(ScriptGlobals))
|
||||||
|
.CreateDelegate());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>脚本编译选项(§5.1 程序集白名单 + §5.2 using 白名单)。</summary>
|
||||||
|
private static ScriptOptions BuildScriptOptions()
|
||||||
|
{
|
||||||
|
return ScriptOptions.Default
|
||||||
|
.WithImports(AllowedImports)
|
||||||
|
.WithReferences(AllowedAssemblies);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly string[] AllowedImports =
|
||||||
|
[
|
||||||
|
"System",
|
||||||
|
"System.Linq",
|
||||||
|
"System.Text.Json",
|
||||||
|
"System.Collections.Generic",
|
||||||
|
"System.Threading",
|
||||||
|
"System.Threading.Tasks",
|
||||||
|
"MediatR",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── 沙箱程序集白名单 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static MetadataReference[] BuildAllowedAssemblies()
|
||||||
|
{
|
||||||
|
// 基线:CLR 核心 + LINQ + JSON + MediatR + Shared kernel
|
||||||
|
var baseline = new[]
|
||||||
|
{
|
||||||
|
typeof(object).Assembly, // System.Private.CoreLib
|
||||||
|
typeof(Enumerable).Assembly, // System.Linq
|
||||||
|
typeof(JsonElement).Assembly, // System.Text.Json
|
||||||
|
typeof(ISender).Assembly, // MediatR
|
||||||
|
typeof(ICurrentUserContext).Assembly, // BuildFlow.Shared
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载所有 BuildFlow.* 的 Application/Domain 程序集(让脚本能 new Query/Command)
|
||||||
|
// 排除 Infrastructure(防直接 DbContext 访问)与 Tests 程序集
|
||||||
|
var moduleAssemblies = AppDomain.CurrentDomain.GetAssemblies()
|
||||||
|
.Where(a => a.GetName().Name is { } name
|
||||||
|
&& name.StartsWith("BuildFlow")
|
||||||
|
&& !name.EndsWith(".Infrastructure")
|
||||||
|
&& !name.Contains(".Tests"));
|
||||||
|
|
||||||
|
return baseline.Concat(moduleAssemblies).Distinct()
|
||||||
|
.Select(a => MetadataReference.CreateFromFile(a.Location))
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 变量解析(JsonElement → 原生 .NET 类型) ─────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将输入 JSON 解析为原生 .NET 类型字典,让脚本可直接写 (decimal)Variables["x"] 而非操作 JsonElement。
|
||||||
|
/// 算法:Number→long/decimal/double,Bool→bool,String→DateTime/Guid/string,Object/Array→保留 JsonElement。
|
||||||
|
/// </summary>
|
||||||
|
internal static IReadOnlyDictionary<string, object?> ParseVariables(string inputJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(inputJson))
|
||||||
|
{
|
||||||
|
return new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(inputJson)
|
||||||
|
?? new Dictionary<string, JsonElement>();
|
||||||
|
|
||||||
|
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||||
|
foreach (var kvp in values)
|
||||||
|
{
|
||||||
|
result[kvp.Key] = ConvertElement(kvp.Value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object? ConvertElement(JsonElement element)
|
||||||
|
{
|
||||||
|
return element.ValueKind switch
|
||||||
|
{
|
||||||
|
JsonValueKind.Null => null,
|
||||||
|
JsonValueKind.True => true,
|
||||||
|
JsonValueKind.False => false,
|
||||||
|
JsonValueKind.Number => ConvertNumber(element),
|
||||||
|
JsonValueKind.String => ConvertString(element),
|
||||||
|
// Object/Array 保留为 JsonElement,脚本如需可进一步处理
|
||||||
|
_ => element,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object ConvertNumber(JsonElement element)
|
||||||
|
{
|
||||||
|
// 整数优先用 long(避免精度损失);decimal 次选(金额友好);最后 double
|
||||||
|
if (element.TryGetInt64(out var longVal))
|
||||||
|
{
|
||||||
|
return longVal;
|
||||||
|
}
|
||||||
|
// 含小数或超出 long 范围:优先 decimal
|
||||||
|
if (element.TryGetDecimal(out var decVal))
|
||||||
|
{
|
||||||
|
return decVal;
|
||||||
|
}
|
||||||
|
return element.GetDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object ConvertString(JsonElement element)
|
||||||
|
{
|
||||||
|
var str = element.GetString() ?? string.Empty;
|
||||||
|
|
||||||
|
// ISO 8601 日期优先
|
||||||
|
if (DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var dt))
|
||||||
|
{
|
||||||
|
return dt;
|
||||||
|
}
|
||||||
|
// Guid
|
||||||
|
if (Guid.TryParse(str, out var guid))
|
||||||
|
{
|
||||||
|
return guid;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 日志持久化 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task AddLogAsync(
|
||||||
|
ScriptFunction function, bool isTest,
|
||||||
|
ScriptExecutionStatus status, string inputJson,
|
||||||
|
string? resultJson, string? errorMessage,
|
||||||
|
long durationMs, string? logLines, CancellationToken ct)
|
||||||
|
{
|
||||||
|
db.ScriptExecutionLogs.Add(new ScriptExecutionLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
FunctionId = function.Id,
|
||||||
|
FunctionName = isTest ? $"[TEST] {function.Name}" : function.Name,
|
||||||
|
Status = status,
|
||||||
|
InputJson = string.IsNullOrWhiteSpace(inputJson) ? "{}" : inputJson,
|
||||||
|
ResultJson = resultJson,
|
||||||
|
ErrorMessage = errorMessage,
|
||||||
|
DurationMs = durationMs,
|
||||||
|
LogLines = logLines,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
|
<PackageReference Include="FluentAssertions" Version="6.12.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\modules\scripting\Application\Scripting.Application.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@ -0,0 +1,161 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Scripting.Application.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace BuildFlow.Scripting.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ScriptExecutionService.ParseVariables 类型映射逻辑测试(纯函数,无需 DI/数据库)。
|
||||||
|
/// 验证 JSON 输入被正确转为原生 .NET 类型,让脚本能直接 (decimal)Variables["x"] 而非操作 JsonElement。
|
||||||
|
/// </summary>
|
||||||
|
public class ScriptExecutionServiceParseVariablesTests
|
||||||
|
{
|
||||||
|
// ParseVariables 是 internal static,通过 InternalsVisibleTo 暴露给本测试程序集
|
||||||
|
private static IReadOnlyDictionary<string, object?> Parse(string json)
|
||||||
|
=> ScriptExecutionService.ParseVariables(json);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_Or_Whitespace_Input_Returns_Empty_Dictionary()
|
||||||
|
{
|
||||||
|
Parse("").Should().BeEmpty();
|
||||||
|
Parse(" ").Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Integer_Number_Maps_To_Long()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"count\": 42, \"big\": 9007199254740993}");
|
||||||
|
|
||||||
|
vars["count"].Should().BeOfType<long>().Which.Should().Be(42L);
|
||||||
|
// 9007199254740993 超出 double 精度但 long 可表示,应保持 long
|
||||||
|
vars["big"].Should().BeOfType<long>().Which.Should().Be(9007199254740993L);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Decimal_Number_Maps_To_Decimal_For_Money_Friendliness()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"price\": 19.99}");
|
||||||
|
|
||||||
|
vars["price"].Should().BeOfType<decimal>().Which.Should().Be(19.99m);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Bool_Maps_To_Boolean()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"active\": true, \"deleted\": false}");
|
||||||
|
|
||||||
|
vars["active"].Should().BeOfType<bool>().Which.Should().BeTrue();
|
||||||
|
vars["deleted"].Should().BeOfType<bool>().Which.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Null_Maps_To_Null()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"name\": null}");
|
||||||
|
|
||||||
|
vars["name"].Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Plain_String_Maps_To_String()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"title\": \"hello world\"}");
|
||||||
|
|
||||||
|
vars["title"].Should().BeOfType<string>().Which.Should().Be("hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Iso8601_String_Maps_To_DateTime()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"createdAt\": \"2026-07-06T10:30:00Z\"}");
|
||||||
|
|
||||||
|
vars["createdAt"].Should().BeOfType<DateTime>();
|
||||||
|
var dt = (DateTime)vars["createdAt"]!;
|
||||||
|
dt.Year.Should().Be(2026);
|
||||||
|
dt.Month.Should().Be(7);
|
||||||
|
dt.Day.Should().Be(6);
|
||||||
|
dt.Hour.Should().Be(10);
|
||||||
|
dt.Minute.Should().Be(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Guid_String_Maps_To_Guid()
|
||||||
|
{
|
||||||
|
var guidStr = "f47ac10b-58cc-4372-a567-0e02b2c3d479";
|
||||||
|
var vars = Parse("{\"id\": \"" + guidStr + "\"}");
|
||||||
|
|
||||||
|
vars["id"].Should().BeOfType<Guid>();
|
||||||
|
((Guid)vars["id"]!).Should().Be(Guid.Parse(guidStr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ISO 8601 日期优先于 Guid(日期串不会误判为 Guid)。</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Date_Parsed_Before_Guid_Check()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"ts\": \"2026-01-15\"}");
|
||||||
|
vars["ts"].Should().BeOfType<DateTime>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Object_Maps_To_JsonElement()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"nested\": {\"a\": 1, \"b\": 2}}");
|
||||||
|
|
||||||
|
vars["nested"].Should().BeOfType<JsonElement>();
|
||||||
|
var el = (JsonElement)vars["nested"]!;
|
||||||
|
el.ValueKind.Should().Be(JsonValueKind.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Array_Maps_To_JsonElement()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"items\": [1, 2, 3]}");
|
||||||
|
|
||||||
|
vars["items"].Should().BeOfType<JsonElement>();
|
||||||
|
var el = (JsonElement)vars["items"]!;
|
||||||
|
el.ValueKind.Should().Be(JsonValueKind.Array);
|
||||||
|
el.GetArrayLength().Should().Be(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Mixed_Types_All_Resolve_Correctly()
|
||||||
|
{
|
||||||
|
var json = "{\"count\": 100, \"price\": 49.95, \"active\": true, \"name\": \"widget\", "
|
||||||
|
+ "\"id\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\", \"date\": \"2026-07-06T00:00:00\", "
|
||||||
|
+ "\"meta\": {\"key\": \"val\"}, \"nullable\": null}";
|
||||||
|
|
||||||
|
var vars = Parse(json);
|
||||||
|
|
||||||
|
vars["count"].Should().BeOfType<long>();
|
||||||
|
vars["price"].Should().BeOfType<decimal>();
|
||||||
|
vars["active"].Should().BeOfType<bool>();
|
||||||
|
vars["name"].Should().BeOfType<string>();
|
||||||
|
vars["id"].Should().BeOfType<Guid>();
|
||||||
|
vars["date"].Should().BeOfType<DateTime>();
|
||||||
|
vars["meta"].Should().BeOfType<JsonElement>();
|
||||||
|
vars["nullable"].Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>脚本端典型用法:原生类型可直接强转,无需 GetInt64() 等中间步骤。</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Script_Idiom_Direct_Cast_Works()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"qty\": 5, \"rate\": 12.5}");
|
||||||
|
|
||||||
|
// 模拟脚本里写 (long)Variables["qty"] * (decimal)Variables["rate"]
|
||||||
|
var result = (long)vars["qty"]! * (decimal)vars["rate"]!;
|
||||||
|
result.Should().Be(62.5m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ordinal 键比较器:键名大小写敏感。</summary>
|
||||||
|
[Fact]
|
||||||
|
public void Keys_Are_Case_Sensitive_Ordinal()
|
||||||
|
{
|
||||||
|
var vars = Parse("{\"Name\": \"a\", \"name\": \"b\"}");
|
||||||
|
|
||||||
|
vars.Should().HaveCount(2);
|
||||||
|
vars["Name"].Should().Be("a");
|
||||||
|
vars["name"].Should().Be("b");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user