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