- 实现三阶段动作链:前置钩子(BeforeActionsJson)、主链(ActionsJson)、后置钩子(AfterActionsJson) - 扩展自定义函数支持HTTP请求类型,添加相关字段配置 - 重构异常处理机制,引入语义错误码字符串替代数字码 - 优化数据库表名前缀,统一使用b_前缀提升命名规范性 - 实现菜单路由名称自动派生,支持Code到Name的转换 - 重构错误响应信封,code字段改为语义错误码字符串格式 - 添加ImportJobProcessorService后台服务支持 - 优化Swagger文档默认URL重定向逻辑 - 修复IM模块列缺失的数据库补丁机制
23 KiB
Scripting 模块设计 · 浏览器可编程 C# 脚本函数(MediatR 全开放)
- 状态:已批准(Approved)
- 日期:2026-07-06
- 作者:brainstorming session
- 影响范围:
build-flow.api(新建modules/scripting/,改造workflow按钮链,删除旧 CustomFunction)、build-flow.web(新建views/scripts/)
一、目标与现状差距
把现有的"自定义函数"能力从 workflow 模块解耦,独立成 scripting 模块,实现浏览器里写 C# 脚本 → 通过 MediatR 调用系统任意内部方法,作为"函数即服务"被 HTTP 按名调用、按钮动作链触发。
现状痛点(已通过代码探索确认):
| 维度 | 现状 | 痛点 |
|---|---|---|
| 位置 | 耦合在 workflow/Application/BuildFlow/CustomFunctions/,唯一调用方是按钮动作链 |
无法跨模块复用 |
| 脚本能力 | CustomFunctionScriptGlobals 只注入 Variables + InputJson |
只能纯计算,不能调用任何系统内部方法 |
| 编辑器 | 前端只是个 Textarea(create-form.vue:44-48) |
无 C# 语法高亮/补全 |
| 编译 | 每次执行都重新 CSharpScript.EvaluateAsync(CustomFunctionExecutionService.cs:81) |
无编译缓存,性能差 |
| 可编辑性 | 无 Update 端点(搜索 UpdateCustomFunction 为空) |
函数创建后不可修改 |
| 测试 | TestCustomFunctionCommandHandler 走正式执行服务,要求 Published(CustomFunctionExecutionService.cs:36-39) |
草稿不能测试 |
| 其他 | 无版本历史、无执行日志查看页、无 Validator、沙箱仅靠隐式引用裁剪 | 体验和安全都差 |
二、核心决策(已与用户确认)
| # | 决策点 | 选择 | 理由 |
|---|---|---|---|
| 1 | 脚本能调用什么 | MediatR 全开放 | 最大灵活性,复用所有现有 CQRS 能力 |
| 2 | 与现有 CustomFunction 关系 | 独立新模块 + 迁移 | 架构最干净,表重命名保数据,旧代码删除 |
| 3 | 脚本执行身份 | 触发者身份 | 最小权限,零额外权限代码,复用现有权限拦截 |
| 4 | 前端编辑器 | Monaco + 智能提示面板 | 性价比最高,C# 高亮 + 可调用方法/Command 面板 |
| 5 | 调用入口 | HTTP 按名调用 + 按钮动作链触发 | "函数即服务"的核心 + 兼容现有低代码使用者 |
| 6 | 安全沙箱 | 程序集白名单 | 用 Roslyn WithReferences + WithImports 裁剪,best-effort |
| 7 | 元数据来源 | 反射自动扫描 MediatR request 类型 | 零维护,自动追随代码变更 |
| 8 | 迁移策略 | 保数据重命名表(b_funcs → funcs) |
保留生产数据,仅升级能力 |
三、模块架构
3.1 目录结构
build-flow.api/src/modules/scripting/
├── Domain/
│ ├── Entities/
│ │ ├── ScriptFunction.cs (从 CustomFunction 迁移)
│ │ └── ScriptExecutionLog.cs
│ └── Enums/
│ ├── ScriptStatus.cs (Draft=0/Published=1/Disabled=2)
│ └── ScriptExecutionStatus.cs (Success=0/Failed=1/Timeout=2)
├── Application/
│ ├── Commands/
│ │ ├── CreateScriptCommand.cs + Handler
│ │ ├── UpdateScriptCommand.cs + Handler ← 新增能力
│ │ ├── DeleteScriptCommand.cs + Handler ← 新增能力
│ │ ├── PublishScriptCommand.cs + Handler
│ │ ├── DisableScriptCommand.cs + Handler
│ │ ├── TestScriptCommand.cs + Handler ← 草稿可测
│ │ └── InvokeScriptCommand.cs + Handler ← HTTP 按名调用
│ ├── Queries/
│ │ ├── GetScriptListQuery.cs + Handler
│ │ ├── GetScriptDetailQuery.cs + Handler ← 新增
│ │ ├── GetScriptExecutionLogsQuery.cs + Handler ← 新增
│ │ └── GetScriptMetaQuery.cs + Handler ← 反射扫描元数据
│ ├── Host/
│ │ ├── ScriptGlobals.cs ← 注入 ISender + ICurrentUserContext + Ct + Logger
│ │ └── ScriptLogger.cs ← 受控日志,写入 ScriptExecutionLog.LogLines
│ ├── Services/
│ │ ├── IScriptExecutionService.cs (ExecuteByIdAsync / ExecuteByNameAsync)
│ │ ├── ScriptExecutionService.cs (Roslyn + 沙箱 + 编译缓存)
│ │ └── ScriptTypeRegistry.cs ← 启动反射扫描 MediatR 类型
│ ├── DTOs/ (ScriptDto / ExecutionResultDto / ScriptTypeMetaDto)
│ └── ScriptingApplicationModule.cs
└── Infrastructure/
├── Persistence/
│ ├── ScriptingDbContext.cs
│ └── Configurations/
│ ├── ScriptFunctionConfiguration.cs
│ └── ScriptExecutionLogConfiguration.cs
└── ScriptingInfrastructureModule.cs
3.2 模块依赖(ABP [DependsOn])
BuildFlowHostModule
├── WorkflowApplicationModule
│ └── ScriptingApplicationModule ← 新增依赖(workflow 用 IScriptExecutionService)
│ └── PlatformApplicationModule (复用 ICurrentUserContext、用户查询等)
├── ScriptingInfrastructureModule ← 新增
└── ...(其他模块不变)
工作流模块通过 [DependsOn(typeof(ScriptingApplicationModule))] 引入脚本能力,注入 IScriptExecutionService。脚本执行服务反过来通过 MediatR ISender 调用任意模块的 Command/Query Handler,不会产生循环引用(MediatR 是松耦合的运行时分发,不是编译期依赖)。
四、脚本运行时(核心)
4.1 ScriptGlobals —— 用户脚本可见的宿主对象
public sealed class ScriptGlobals
{
public IReadOnlyDictionary<string, object?> Variables { get; } // 输入变量(已转原生类型)
public string InputJson { get; } // 原始输入 JSON 兜底
public ISender Mediator { get; } // ← MediatR 全开放入口
public ICurrentUserContext CurrentUser { get; } // 当前用户
public CancellationToken CancellationToken { get; } // 必须传给 Mediator.Send
public ScriptLogger Logger { get; } // 受控日志
public IServiceProvider Services { get; } // 逃生舱(按需 Resolve<T>)
}
用户脚本示例(HTTP 调用场景):
using BuildFlow.Platform.Application.Users.Queries;
var q = new GetUserQuery { Id = (Guid)Variables["userId"] };
var user = await Mediator.Send(q, CancellationToken);
Logger.Info($"查询到用户 {user.Name}");
return new { name = user.Name, level = user.Level };
4.2 触发者身份的工作原理(无需额外权限代码)
这是本设计最关键的安全特性:
CurrentUserContext(BuildFlow.Host/Services/Users/CurrentUserContext.cs)基于IHttpContextAccessor,从 JWT claims 解析用户- 脚本里
await Mediator.Send(handler)执行时,handler 内部注入的ICurrentUserContext仍指向触发 HTTP 请求的同一个用户(因为整个调用链在同一个 HTTP 请求 Scope 内) - 该用户没有
user.delete权限 → handler 内的权限拦截自然拒绝(抛UnauthorizedException) - 零额外权限代码,完整复用现有所有 Command/Query Handler 的权限拦截逻辑
这意味着:脚本不可能"提权"。普通用户触发的脚本,永远只能做该用户本身能做的事。
4.3 变量类型升级(修现状痛点)
现状 Variables 的值是 JsonElement,脚本里要写:
// 现状(冗长)
var amount = ((System.Text.Json.JsonElement)Variables["amount"]).GetDecimal();
新版 ParseVariables 在解析 inputJson 时把 JsonElement 转换为 .NET 原生类型:
| JSON 类型 | .NET 类型 |
|---|---|
| 整数 | long |
| 浮点 | double |
| 数字(字符串形式为 decimal 时) | decimal |
| 布尔 | bool |
| 字符串 | string(尝试解析为 DateTime/Guid,成功则用原生类型) |
| 日期字符串 | DateTime |
| Guid 字符串 | Guid |
| 对象/数组 | 保留 JsonElement(兜底) |
| null | null |
效果:脚本里写 (decimal)Variables["amount"] 即可。
4.4 编译缓存(修现状痛点)
现状每次 CSharpScript.EvaluateAsync 重新编译,几十到几百 ms。
新版用 CSharpScript.Create(code, options, globalsType) 创建 Script<object> 对象,调用 .CreateDelegate() 得到可复用委托,按 code 的 SHA256 哈希缓存到 ConcurrentDictionary<string, ScriptDelegate>。
- globals 类型固定
ScriptGlobals,所以委托可复用 - 每次执行只 new 一个 ScriptGlobals 实例传给委托
- 脚本更新(草稿编辑、发布)后 code 哈希变化,自然走新缓存项
- 缓存上限配置(默认 500 条,简单的 LRU 淘汰)
五、安全沙箱(程序集白名单)
5.1 程序集白名单
ScriptOptions.WithReferences(...) 显式列出允许引用的程序集。
允许(脚本编译时可见):
System.Private.CoreLib(typeof(object).Assembly)System.Linq(typeof(Enumerable).Assembly)System.Text.Json(typeof(JsonElement).Assembly)System.CollectionsMediatR(typeof(ISender).Assembly)- 所有
BuildFlow.*.Application程序集(脚本要new GetUserQuery()) - 所有
BuildFlow.*.Domain程序集(DTO/枚举类型) BuildFlow.Shared程序集
禁止(关键安全约束):
- ❌ 所有
*.Infrastructure程序集 → 脚本不能直接 new DbContext 绕过业务规则 - ❌
System.Reflection.*→ 防反射探索 - ❌
System.IO.*/System.IO.FileSystem→ 禁文件访问 - ❌
System.Net.Http.*/System.Net.Sockets→ 禁直接网络(必须走业务方法) - ❌
System.Diagnostics.Process→ 禁启进程
5.2 using 白名单
ScriptOptions.WithImports(...) 显式列允许的命名空间:
System, System.Linq, System.Text.Json, System.Collections.Generic,
System.Threading, System.Threading.Tasks,
MediatR,
BuildFlow.Shared.Domain.Common,
BuildFlow.Platform.Application.*, BuildFlow.Application.* (按实际 Application 命名空间)
5.3 软约束:代码黑名单扫描
System.Private.CoreLib 通过 typeof(object).Assembly 仍隐式可达,理论上能 typeof(object).Assembly.GetType("System.Reflection.Assembly") 反射探索。这是 Roslyn scripting 的固有限制。
缓解:编译前对脚本源码做正则扫描,命中以下模式直接拒绝编译(返回 BusinessException("脚本包含禁用 API")):
System.ReflectionSystem.IO.File/System.IO.DirectorySystem.Net.Http/System.Net.SocketsSystem.Diagnostics.Processtypeof(后跟.Assembly(限制 typeof 仅用于已知类型)AppDomain/Assembly.Load
⚠ 重要标注:这是 best-effort 软约束,不是强安全边界。强隔离需上进程级方案(本期不做,见第十一章 YAGNI)。
六、调用入口
6.1 API 端点清单(全部挂在 /api/scripts)
管理类(前端管理页用):
| Method | Path | 权限码 | 说明 |
|---|---|---|---|
| GET | /api/scripts |
scripts.read |
分页列表 |
| GET | /api/scripts/{id} |
scripts.read |
详情 |
| POST | /api/scripts |
scripts.create |
创建 |
| PUT | /api/scripts/{id} |
scripts.update |
更新(新增,现状没有) |
| DELETE | /api/scripts/{id} |
scripts.delete |
删除(新增) |
| POST | /api/scripts/{id}/publish |
scripts.publish |
发布 |
| POST | /api/scripts/{id}/disable |
scripts.disable |
禁用 |
| POST | /api/scripts/{id}/test |
scripts.test |
测试(草稿可测) |
| GET | /api/scripts/{id}/logs |
scripts.read |
执行日志(新增) |
元数据类(Monaco 面板用):
| Method | Path | 权限码 | 说明 |
|---|---|---|---|
| GET | /api/scripts/meta/types |
scripts.read |
反射扫描的所有可用 Command/Query |
| GET | /api/scripts/meta/assemblies |
scripts.read |
可用程序集清单(调试用) |
调用类(核心):
| Method | Path | 权限码 | 说明 |
|---|---|---|---|
| POST | /api/scripts/{name}/invoke |
scripts.run |
按名调用已发布脚本 |
/invoke 请求/响应:
// POST /api/scripts/get-user-detail/invoke
Request: { "input": { "userId": "..." } }
Response: { "status": "Success", "result": {...}, "durationMs": 123 }
6.2 按钮动作链改造
BuildFlowButtonActionExecutor(workflow/Application/BuildFlow/Buttons/Services/BuildFlowButtonActionExecutor.cs)改造点:
- 依赖从
ICustomFunctionExecutionService改为IScriptExecutionService runFunctionaction 的target(GUID)保持不变 → 调ExecuteByIdAsync- Scope 校验(
FormDefinitionId必须匹配按钮的表单,EnsureFunctionScopeAsync)逻辑迁移过来,不变 BuildFunctionInputJson($input.xxx占位符映射)逻辑迁移过来,不变
6.3 ScriptTypeRegistry —— 反射扫描元数据
启动时(ScriptingInfrastructureModule 或 hosted service 初始化):
- 遍历
AppDomain.CurrentDomain.GetAssemblies()中所有BuildFlow.*程序集 - 找出所有实现
IBaseRequest(MediatR)的类型 - 提取每个类型:
FullName、Namespace、所有公开属性(名+类型+XML 注释)、所属程序集、是 Command 还是 Query(按返回类型推断) - 缓存到
IMemoryCache,key=script-types,TTL=10 分钟(首次加载后基本不变) GET /api/scripts/meta/types?keyword=GetUser&kind=Query&page=1支持分页+搜索
前端 Monaco 用这个端点填充"方法面板"。
七、数据模型与迁移
7.1 表迁移(EF Core Migration)
RENAME 保数据:
b_funcs→funcsb_func_execution_logs→func_execution_logs
字段调整:
funcs新增列:LogLines(text, 可空)— 测试时收集 Logger 输出- 其他列保持不变(
Id, FormDefinitionId, Name, DisplayName, Description, Code, InputSchema, OutputSchema, Status, Version, TimeoutMs, 审计字段)
枚举迁移:
BuildFlowMetadataStatus→ScriptStatus(值映射 0/1/2 不变)CustomFunctionExecutionStatus→ScriptExecutionStatus(值不变)
7.2 SeedData 迁移
旧权限码删除(来自 SeedData.cs:149-153):
buildflow:function:read/create/publish/disable/test
新权限码(添加到 admin 角色):
scripts.read / create / update / publish / disable / delete / test / run
菜单迁移(来自 SeedData.cs:242):
- 旧
buildflow.functions→/buildflow/custom-functions/index - 新
scripts→/scripts/index
八、前端(build-flow.web)
8.1 新增依赖
apps/web-antdv-next/package.json 增加:
monaco-editor(C# 语言支持)@guolao/vue-monaco-editor(Vue 3 封装,与 vben 兼容)
8.2 页面结构
apps/web-antdv-next/src/views/scripts/:
index.vue 列表页(VxeGrid,复刻现有 custom-functions/index.vue)
data.ts 列定义
detail.vue 详情+编辑页(替换 create-form.vue)
modules/
script-editor.vue Monaco 编辑器组件(C# 语法高亮+补全)
method-panel.vue 方法面板( Queries/Commands Tab + 搜索 + 点击插入 )
test-modal.vue 测试弹窗(schema 表单输入 + 控制台输出 + JSON 结果)
log-drawer.vue 执行日志抽屉
route-context.ts 路由 meta 处理
8.3 编辑器布局(detail.vue)
┌────────────────────────────┬─────────────────┐
│ Monaco C# 编辑器 │ 方法面板 │
│ │ 🔍 搜索... │
│ using System; │ │
│ using MediatR; │ 📦 Queries │
│ │ • GetUser │
│ var q = new GetUserQuery │ • ListUsers │
│ { │ 📦 Commands │
│ Id = (Guid)Vars["uid"] │ • UpdateUser │
│ }; │ • DeleteUser │
│ var u = await Mediator │ │
│ .Send(q, CancellationToken)│ 点击 → 插入代码 │
│ return u.Name; │ │
├────────────────────────────┴─────────────────┤
│ [测试运行] [保存草稿] [发布] [查看日志] [禁用] │
└──────────────────────────────────────────────┘
8.4 测试运行改进(修现状痛点)
- 现状草稿不能测试 → 改为草稿/已发布都能测试
- 测试时跳过
Status == Published校验,直接用脚本代码运行 - 输入区:基于
InputSchema动态生成表单(schema-form),不再手填 JSON - 结果区:美化 JSON 显示 + Logger 控制台输出区
- 仍写一条
ScriptExecutionLog,FunctionName标注[TEST]
8.5 API 封装
apps/web-antdv-next/src/api/core/scripts.ts(新建文件,从 workflow.ts 剥离):
- 9 个管理 API + 2 个元数据 API + 1 个 invoke API
- TypeScript 类型定义(
ScriptsApi.Script,ScriptsApi.ExecutionResult,ScriptsApi.TypeMeta等)
九、边缘场景处理
| # | 场景 | 处理 |
|---|---|---|
| E1 | 死循环 / 死递归 | TimeoutMs 兜底(默认 5s,最大 30s),超时强 cancel |
| E2 | 脚本调脚本(递归 invoke) | AsyncLocal<int> 调用深度计数器,超过 5 抛 BusinessException("脚本调用深度超限") |
| E3 | 编译开销 | 见 4.4 编译缓存(按 code 哈希缓存委托,LRU 500 条) |
| E4 | await 不传 CancellationToken |
globals 暴露 CancellationToken,文档强制传;端点透传超时 token |
| E5 | 共享 DbContext 并发 | 保持 Scoped 生命周期(每 HTTP 请求一 Scope),与现状一致 |
| E6 | 直接绕过 MediatR 写库 | 白名单不含 Infrastructure 程序集,强制走 MediatR 业务路径 |
| E7 | 反射绕过 | 见 5.3 代码黑名单扫描(best-effort 软约束) |
| E8 | 超大输入/输出 | inputJson 最大 1MB(端点配置);result 序列化超 1MB 截断存日志 |
| E9 | 测试模式权限 | 测试入口要求 scripts.test(管理员级),仍以触发者身份执行 |
| E10 | 事务边界 | 每条 Mediator.Send 独立事务(与现状一致);脚本内多写操作部分失败需用户自己补偿,文档警告 |
十、实现任务拆解(subagent-driven-development)
每个 task 边界清晰、上下文自洽,实现后 git commit,下一个 subagent 看到的是已落地代码。每个 task 走 implementer → spec reviewer → code quality reviewer 三段式。
| # | Task | 模型 | 关键产出 |
|---|---|---|---|
| T1 | 新建 scripting 模块骨架 | cheap | 3 个 csproj(Domain/Application/Infrastructure)+ ABP Module 类 + 挂到 Host [DependsOn] |
| T2 | 迁移 Domain 实体 + EF 配置 + 表重命名迁移 | standard | ScriptFunction/ScriptExecutionLog 实体、ScriptingDbContext、b_funcs→funcs 迁移文件 |
| T3 | ScriptGlobals + ScriptExecutionService + 沙箱 | standard | globals 注入 ISender、变量类型升级、程序集白名单、编译缓存、深度计数器 |
| T4 | ScriptTypeRegistry 反射扫描 + 元数据端点 | standard | 启动扫描 + GET /api/scripts/meta/types |
| T5 | CQRS Commands/Queries + 管理端点 | standard | Create/Update/Delete/Publish/Disable/Test/Invoke/List/Detail/Logs 全套 |
| T6 | 按钮动作链改造指向 IScriptExecutionService | standard | BuildFlowButtonActionExecutor 改依赖、Scope 校验迁移 |
| T7 | 删除旧 CustomFunction 代码 + 权限/菜单 SeedData 迁移 | cheap | 删 workflow/.../CustomFunctions/、删 b_func* 旧配置、SeedData 替换权限码 |
| T8 | 前端 Monaco + 方法面板 + 编辑/测试/日志页 | standard | scripts/ 视图全集、scripts.ts API、test 支持草稿 |
| T9 | 集成测试 + 端到端验证 | standard | 后端 ScriptExecutionTests(含 MediatR 调用、沙箱拦截、超时、深度)、按钮链回归 |
十一、不做的事(YAGNI)
明确排除(避免范围蔓延):
- ❌ 进程级隔离 / 容器沙箱(best-effort 软沙箱已够本期)
- ❌ 脚本断点调试 / step debugging(Monaco + 控制台输出足够)
- ❌ 脚本级分布式事务(每 Send 独立事务)
- ❌ 函数级权限码
scripts.run.{name}(统一scripts.run,按需后期加) - ❌ 脚本版本历史快照(Version 自增保留,但不存历史 Code 快照)
- ❌ Webhook 调用入口 / 后台 SDK 调用(仅 HTTP + 按钮链两个入口)
十二、实现后产出清单
build-flow.api/src/modules/scripting/全新三层模块- EF Core 迁移:表重命名 + 新增列
BuildFlowButtonActionExecutor改造- 删除
workflow/Application/BuildFlow/CustomFunctions/全部 apps/web-antdv-next/src/views/scripts/全新视图apps/web-antdv-next/src/api/core/scripts.ts新 API 文件- 后端集成测试
ScriptExecutionTests.cs - SeedData 权限码/菜单更新
- 本设计文档(已就位)
十三、验收标准
实现完成后,以下场景必须可演示:
- 浏览器写脚本调系统方法:在前端 Monaco 编辑器里写一段 C# 脚本,调用
GetUserQuery,测试运行成功,返回用户数据。 - HTTP 按名调用:通过
POST /api/scripts/{name}/invoke调用一个已发布脚本,返回正确结果。 - 触发者权限生效:普通用户触发调用
DeleteUserCommand的脚本,被权限拦截拒绝(403)。 - 沙箱拦截:脚本里写
System.IO.File.ReadAllText("/etc/passwd")编译失败,返回"脚本包含禁用 API"。 - 草稿可测:草稿状态脚本能测试运行(修复现状痛点)。
- 可编辑:已创建的脚本能 Update 修改 Code/Schema(修复现状痛点)。
- 按钮链回归:现有 BuildFlow 按钮动作链(
runFunction)仍能正常触发脚本,行为不变。 - 编译缓存:同一脚本第二次执行明显快于第一次(编译命中缓存)。
- 方法面板:前端 Monaco 右侧面板能列出系统所有 Command/Query,点击插入代码片段。
- 执行日志:每次执行(含测试)都写入
func_execution_logs,前端能查看。