diff --git a/BuildFlow.slnx b/BuildFlow.slnx index f842aad..1c12d3f 100644 --- a/BuildFlow.slnx +++ b/BuildFlow.slnx @@ -41,5 +41,6 @@ + diff --git a/src/modules/scripting/Application/DTOs/ScriptExecutionResult.cs b/src/modules/scripting/Application/DTOs/ScriptExecutionResult.cs new file mode 100644 index 0000000..6d43120 --- /dev/null +++ b/src/modules/scripting/Application/DTOs/ScriptExecutionResult.cs @@ -0,0 +1,11 @@ +using Scripting.Domain.Enums; + +namespace Scripting.Application.DTOs; + +/// 脚本执行结果 DTO。 +public sealed record ScriptExecutionResult( + ScriptExecutionStatus Status, + string? ResultJson, + string? ErrorMessage, + long DurationMs, + string? LogLines = null); diff --git a/src/modules/scripting/Application/Host/ScriptGlobals.cs b/src/modules/scripting/Application/Host/ScriptGlobals.cs new file mode 100644 index 0000000..c41d969 --- /dev/null +++ b/src/modules/scripting/Application/Host/ScriptGlobals.cs @@ -0,0 +1,45 @@ +using BuildFlow.Shared.Domain.Common; +using MediatR; + +namespace Scripting.Application.Host; + +/// +/// 脚本宿主对象:向 C# 脚本暴露输入变量、MediatR 发送器、当前用户上下文、超时令牌与受控日志。 +/// 脚本通过此对象调用系统任意 Command/Query(MediatR 全开放),权限继承触发者身份。 +/// +public sealed class ScriptGlobals +{ + public ScriptGlobals( + IReadOnlyDictionary variables, + string inputJson, + ISender mediator, + ICurrentUserContext currentUser, + CancellationToken cancellationToken, + ScriptLogger logger) + { + Variables = variables; + InputJson = inputJson; + Mediator = mediator; + CurrentUser = currentUser; + CancellationToken = cancellationToken; + Logger = logger; + } + + /// 输入变量(已从 JSON 转为 .NET 原生类型:long/double/decimal/bool/string/DateTime/Guid)。 + public IReadOnlyDictionary Variables { get; } + + /// 原始输入 JSON 字符串,兜底使用。 + public string InputJson { get; } + + /// MediatR 发送器。脚本里 await Mediator.Send(query, CancellationToken) 调用任意系统方法。 + public ISender Mediator { get; } + + /// 当前触发用户上下文(继承触发者身份与权限)。 + public ICurrentUserContext CurrentUser { get; } + + /// 超时令牌。必须传给 Mediator.Send 的第二参数,否则脚本超时时 handler 不会停。 + public CancellationToken CancellationToken { get; } + + /// 受控日志器。Logger.Info/Debug 输出会写入 ScriptExecutionLog.LogLines。 + public ScriptLogger Logger { get; } +} diff --git a/src/modules/scripting/Application/Host/ScriptLogger.cs b/src/modules/scripting/Application/Host/ScriptLogger.cs new file mode 100644 index 0000000..d05c5b2 --- /dev/null +++ b/src/modules/scripting/Application/Host/ScriptLogger.cs @@ -0,0 +1,27 @@ +namespace Scripting.Application.Host; + +/// 受控日志器:脚本里 Logger.Info/Debug/Warn 的输出会被收集,写入 ScriptExecutionLog.LogLines。 +public sealed class ScriptLogger +{ + private readonly List _lines = new(); + private readonly object _lock = new(); + + public IReadOnlyList 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); } + } + + /// 收集到的日志合并为多行文本,供持久化到 ScriptExecutionLog.LogLines。 + public string ToText() => string.Join("\n", Lines); +} diff --git a/src/modules/scripting/Application/Scripting.Application.csproj b/src/modules/scripting/Application/Scripting.Application.csproj index ee8b86d..9b4bd6b 100644 --- a/src/modules/scripting/Application/Scripting.Application.csproj +++ b/src/modules/scripting/Application/Scripting.Application.csproj @@ -10,9 +10,16 @@ + + + + + + + diff --git a/src/modules/scripting/Application/ScriptingApplicationModule.cs b/src/modules/scripting/Application/ScriptingApplicationModule.cs index adee835..8a8ab54 100644 --- a/src/modules/scripting/Application/ScriptingApplicationModule.cs +++ b/src/modules/scripting/Application/ScriptingApplicationModule.cs @@ -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; /// -/// Scripting Application 层模块。注册 MediatR(自动发现 Handler)。 -/// Roslyn 脚本引擎等服务注册留待 T3。 +/// Scripting Application 层模块。注册 MediatR(自动发现 Handler)与脚本执行服务。 /// [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(); } } diff --git a/src/modules/scripting/Application/Services/IScriptExecutionService.cs b/src/modules/scripting/Application/Services/IScriptExecutionService.cs new file mode 100644 index 0000000..0cc9eb7 --- /dev/null +++ b/src/modules/scripting/Application/Services/IScriptExecutionService.cs @@ -0,0 +1,16 @@ +using Scripting.Application.DTOs; + +namespace Scripting.Application.Services; + +/// 脚本执行服务:编译+运行 C# 脚本,注入 MediatR/CurrentUser/CancellationToken,并写执行日志。 +public interface IScriptExecutionService +{ + /// 按 Id 执行已发布脚本(按钮动作链用)。 + Task ExecuteByIdAsync(Guid scriptId, string inputJson, CancellationToken ct); + + /// 按 Name 执行已发布脚本(HTTP /scripts/{name}/invoke 用)。 + Task ExecuteByNameAsync(string scriptName, string inputJson, CancellationToken ct); + + /// 测试运行:不校验 Published 状态,允许草稿直接测,仍写日志(FunctionName 标 [TEST])。 + Task TestExecuteAsync(Guid scriptId, string inputJson, CancellationToken ct); +} diff --git a/src/modules/scripting/Application/Services/ScriptExecutionService.cs b/src/modules/scripting/Application/Services/ScriptExecutionService.cs new file mode 100644 index 0000000..8eac9dc --- /dev/null +++ b/src/modules/scripting/Application/Services/ScriptExecutionService.cs @@ -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; + +/// +/// 脚本执行服务:编译+运行受控 C# 脚本,注入 MediatR/CurrentUser/CancellationToken/Logger,并写执行日志。 +/// 通过 MediatR 全开放系统方法调用(权限继承触发者),含沙箱(程序集白名单 + API 黑名单)、 +/// 编译缓存(按 code SHA256 缓存委托)、调用深度限制(防递归)、超时控制。 +/// +public partial class ScriptExecutionService( + ScriptingDbContext db, + ISender mediator, + ICurrentUserContext currentUser) : IScriptExecutionService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + /// 脚本调用深度计数器(AsyncLocal 跨 await 流动,防脚本递归调用自身)。 + private static readonly AsyncLocal _recursionDepth = new(); + private const int MaxRecursionDepth = 5; + + /// 编译缓存:按 code SHA256 缓存可复用委托,避免每次执行重复编译。 + private static readonly ConcurrentDictionary> _compiledCache = new(); + private const int MaxCacheSize = 500; + + private const int DefaultTimeoutMs = 5000; + private const int MaxTimeoutMs = 30000; + + /// 脚本程序集白名单(§5.1):仅允许安全程序集,排除 Infrastructure(防直接访问 DbContext)。 + private static readonly MetadataReference[] AllowedAssemblies = BuildAllowedAssemblies(); + + /// 禁用 API 正则黑名单(§5.3,软约束 best-effort):拦截反射、文件、网络、进程等危险调用。 + [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; } + + // ── 公共入口 ────────────────────────────────────────────────────────── + + /// + public Task ExecuteByIdAsync(Guid scriptId, string inputJson, CancellationToken ct) + => ExecuteAsync(scriptId, null, inputJson, requirePublished: true, isTest: false, ct); + + /// + public Task ExecuteByNameAsync(string scriptName, string inputJson, CancellationToken ct) + => ExecuteAsync(null, scriptName, inputJson, requirePublished: true, isTest: false, ct); + + /// + public Task TestExecuteAsync(Guid scriptId, string inputJson, CancellationToken ct) + => ExecuteAsync(scriptId, null, inputJson, requirePublished: false, isTest: true, ct); + + // ── 核心执行 ────────────────────────────────────────────────────────── + + private async Task 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--; + } + } + + // ── 编译缓存 ────────────────────────────────────────────────────────── + + /// 按 code SHA256 获取或创建可复用委托。超 500 条时淘汰一半(v1 简单策略)。 + private ScriptRunner 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(code, BuildScriptOptions(), typeof(ScriptGlobals)) + .CreateDelegate()); + } + + /// 脚本编译选项(§5.1 程序集白名单 + §5.2 using 白名单)。 + 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 类型) ───────────────────────── + + /// + /// 将输入 JSON 解析为原生 .NET 类型字典,让脚本可直接写 (decimal)Variables["x"] 而非操作 JsonElement。 + /// 算法:Number→long/decimal/double,Bool→bool,String→DateTime/Guid/string,Object/Array→保留 JsonElement。 + /// + internal static IReadOnlyDictionary ParseVariables(string inputJson) + { + if (string.IsNullOrWhiteSpace(inputJson)) + { + return new Dictionary(StringComparer.Ordinal); + } + + var values = JsonSerializer.Deserialize>(inputJson) + ?? new Dictionary(); + + var result = new Dictionary(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); + } +} diff --git a/tests/BuildFlow.Scripting.Tests/BuildFlow.Scripting.Tests.csproj b/tests/BuildFlow.Scripting.Tests/BuildFlow.Scripting.Tests.csproj new file mode 100644 index 0000000..ae15c64 --- /dev/null +++ b/tests/BuildFlow.Scripting.Tests/BuildFlow.Scripting.Tests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + true + false + true + + + + + + + + + + + diff --git a/tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceParseVariablesTests.cs b/tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceParseVariablesTests.cs new file mode 100644 index 0000000..6567e4e --- /dev/null +++ b/tests/BuildFlow.Scripting.Tests/ScriptExecutionServiceParseVariablesTests.cs @@ -0,0 +1,161 @@ +using System.Text.Json; +using FluentAssertions; +using Scripting.Application.Services; +using Xunit; + +namespace BuildFlow.Scripting.Tests; + +/// +/// ScriptExecutionService.ParseVariables 类型映射逻辑测试(纯函数,无需 DI/数据库)。 +/// 验证 JSON 输入被正确转为原生 .NET 类型,让脚本能直接 (decimal)Variables["x"] 而非操作 JsonElement。 +/// +public class ScriptExecutionServiceParseVariablesTests +{ + // ParseVariables 是 internal static,通过 InternalsVisibleTo 暴露给本测试程序集 + private static IReadOnlyDictionary 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().Which.Should().Be(42L); + // 9007199254740993 超出 double 精度但 long 可表示,应保持 long + vars["big"].Should().BeOfType().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().Which.Should().Be(19.99m); + } + + [Fact] + public void Bool_Maps_To_Boolean() + { + var vars = Parse("{\"active\": true, \"deleted\": false}"); + + vars["active"].Should().BeOfType().Which.Should().BeTrue(); + vars["deleted"].Should().BeOfType().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().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(); + 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)vars["id"]!).Should().Be(Guid.Parse(guidStr)); + } + + /// ISO 8601 日期优先于 Guid(日期串不会误判为 Guid)。 + [Fact] + public void Date_Parsed_Before_Guid_Check() + { + var vars = Parse("{\"ts\": \"2026-01-15\"}"); + vars["ts"].Should().BeOfType(); + } + + [Fact] + public void Object_Maps_To_JsonElement() + { + var vars = Parse("{\"nested\": {\"a\": 1, \"b\": 2}}"); + + vars["nested"].Should().BeOfType(); + 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(); + 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(); + vars["price"].Should().BeOfType(); + vars["active"].Should().BeOfType(); + vars["name"].Should().BeOfType(); + vars["id"].Should().BeOfType(); + vars["date"].Should().BeOfType(); + vars["meta"].Should().BeOfType(); + vars["nullable"].Should().BeNull(); + } + + /// 脚本端典型用法:原生类型可直接强转,无需 GetInt64() 等中间步骤。 + [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); + } + + /// Ordinal 键比较器:键名大小写敏感。 + [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"); + } +}