build-flow.api/docs/superpowers/specs/2026-07-06-scripting-module-design.md
xiangning b0cf3949d0 feat(workflow): 扩展按钮定义功能并优化错误处理
- 实现三阶段动作链:前置钩子(BeforeActionsJson)、主链(ActionsJson)、后置钩子(AfterActionsJson)
- 扩展自定义函数支持HTTP请求类型,添加相关字段配置
- 重构异常处理机制,引入语义错误码字符串替代数字码
- 优化数据库表名前缀,统一使用b_前缀提升命名规范性
- 实现菜单路由名称自动派生,支持Code到Name的转换
- 重构错误响应信封,code字段改为语义错误码字符串格式
- 添加ImportJobProcessorService后台服务支持
- 优化Swagger文档默认URL重定向逻辑
- 修复IM模块列缺失的数据库补丁机制
2026-07-06 17:27:32 +08:00

23 KiB
Raw Blame History

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.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 请求/响应:

// 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<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 权限码/菜单更新
  • 本设计文档(已就位)

十三、验收标准

实现完成后,以下场景必须可演示:

  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,前端能查看。