# 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 —— 用户脚本可见的宿主对象 ```csharp public sealed class ScriptGlobals { public IReadOnlyDictionary 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) } ``` **用户脚本示例**(HTTP 调用场景): ```csharp 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`,脚本里要写: ```csharp // 现状(冗长) 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` 对象,调用 `.CreateDelegate()` 得到可复用委托,按 `code` 的 SHA256 哈希缓存到 `ConcurrentDictionary`。 - 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.Collections` - `MediatR`(`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.Reflection` - `System.IO.File` / `System.IO.Directory` - `System.Net.Http` / `System.Net.Sockets` - `System.Diagnostics.Process` - `typeof(` 后跟 `.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` 请求/响应: ```json // 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` - `runFunction` action 的 `target`(GUID)保持不变 → 调 `ExecuteByIdAsync` - Scope 校验(`FormDefinitionId` 必须匹配按钮的表单,`EnsureFunctionScopeAsync`)逻辑迁移过来,**不变** - `BuildFunctionInputJson`(`$input.xxx` 占位符映射)逻辑迁移过来,**不变** ### 6.3 ScriptTypeRegistry —— 反射扫描元数据 启动时(`ScriptingInfrastructureModule` 或 hosted service 初始化): 1. 遍历 `AppDomain.CurrentDomain.GetAssemblies()` 中所有 `BuildFlow.*` 程序集 2. 找出所有实现 `IBaseRequest`(MediatR)的类型 3. 提取每个类型:`FullName`、`Namespace`、所有公开属性(名+类型+XML 注释)、所属程序集、是 Command 还是 Query(按返回类型推断) 4. 缓存到 `IMemoryCache`,key=`script-types`,TTL=10 分钟(首次加载后基本不变) 5. `GET /api/scripts/meta/types?keyword=GetUser&kind=Query&page=1` 支持分页+搜索 前端 Monaco 用这个端点填充"方法面板"。 --- ## 七、数据模型与迁移 ### 7.1 表迁移(EF Core Migration) **RENAME 保数据**: - `b_funcs` → `funcs` - `b_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` 调用深度计数器,超过 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 权限码/菜单更新 - 本设计文档(已就位) --- ## 十三、验收标准 实现完成后,以下场景必须可演示: 1. **浏览器写脚本调系统方法**:在前端 Monaco 编辑器里写一段 C# 脚本,调用 `GetUserQuery`,测试运行成功,返回用户数据。 2. **HTTP 按名调用**:通过 `POST /api/scripts/{name}/invoke` 调用一个已发布脚本,返回正确结果。 3. **触发者权限生效**:普通用户触发调用 `DeleteUserCommand` 的脚本,被权限拦截拒绝(403)。 4. **沙箱拦截**:脚本里写 `System.IO.File.ReadAllText("/etc/passwd")` 编译失败,返回"脚本包含禁用 API"。 5. **草稿可测**:草稿状态脚本能测试运行(修复现状痛点)。 6. **可编辑**:已创建的脚本能 Update 修改 Code/Schema(修复现状痛点)。 7. **按钮链回归**:现有 BuildFlow 按钮动作链(`runFunction`)仍能正常触发脚本,行为不变。 8. **编译缓存**:同一脚本第二次执行明显快于第一次(编译命中缓存)。 9. **方法面板**:前端 Monaco 右侧面板能列出系统所有 Command/Query,点击插入代码片段。 10. **执行日志**:每次执行(含测试)都写入 `func_execution_logs`,前端能查看。