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.Integration.Tests/BuildFlow.IM.Integration.Tests.csproj" />
|
||||
<Project Path="tests/BuildFlow.Workflow.Tests/BuildFlow.Workflow.Tests.csproj" />
|
||||
<Project Path="tests/BuildFlow.Scripting.Tests/BuildFlow.Scripting.Tests.csproj" />
|
||||
</Folder>
|
||||
</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="FluentValidation" 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>
|
||||
<ProjectReference Include="..\Infrastructure\Scripting.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\platform\Application\Platform.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- 暴露 internal 成员给测试项目(ScriptExecutionService.ParseVariables 纯函数测试) -->
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="BuildFlow.Scripting.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
using BuildFlow.Platform.Application;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Scripting.Application.Services;
|
||||
using Scripting.Infrastructure;
|
||||
using Volo.Abp.Modularity;
|
||||
|
||||
namespace Scripting.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Scripting Application 层模块。注册 MediatR(自动发现 Handler)。
|
||||
/// Roslyn 脚本引擎等服务注册留待 T3。
|
||||
/// Scripting Application 层模块。注册 MediatR(自动发现 Handler)与脚本执行服务。
|
||||
/// </summary>
|
||||
[DependsOn(typeof(ScriptingInfrastructureModule), typeof(PlatformApplicationModule))]
|
||||
public class ScriptingApplicationModule : AbpModule
|
||||
@ -18,5 +18,6 @@ public class ScriptingApplicationModule : AbpModule
|
||||
var assembly = typeof(ScriptingApplicationModule).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