# 动态逻辑
本系统支持使用 Groovy (opens new window) 语言进行客制化开发, Groovy 语言是 Java 语言的一个超集,其支持 Java 语言的语法,但增加了更多动态特性,更加适用于进行领域建模和运行时增强。
提示
如果团队对 Groovy 语言不熟悉,也可以完全使用 Java 语言的语法进行开发,Groovy 与 Java 语言的兼容性非常好。
# 目标读者
本文档的目标读者为:本系统的开发和实施人员
# 动态逻辑定义的结构
动态逻辑通过菜单 开发定制 > 动态逻辑 > 动态逻辑 维护,新建时的表单如下图所示:

| 字段 | 说明 |
|---|---|
| 名称(name) | 逻辑的唯一名称,创建后不可修改。其他对象(动作、定时任务、表单等)通过名称或 id 引用该逻辑 |
| 执行引擎(dynamicLogicEngine) | 执行该逻辑的引擎,见下文 动态逻辑引擎 |
| 类型(logicType) | 逻辑的使用场景,见下文 动态逻辑类型。下拉框中显示的是类型的英文名称,例如 Function logic |
| 描述(description) | 必填,逻辑的用途说明 |
| 代码(code) | 逻辑代码,含义取决于执行引擎 |
| 版本(revision) | 只在编辑表单中显示。每次修改代码,版本号加 1,见 修订历史 |
| 历史版本、注释 | 只在编辑表单中显示,分别列出历史版本与评论 |
此外,动态逻辑还有两个界面上不显示的字段,只能通过 CSV 种子数据或数据 API(POST /data/DynamicLogic、PUT /data/DynamicLogic/{id};路径省略了 /api 前缀,见 地址前缀)设置:
| 字段 | 默认值 | 说明 |
|---|---|---|
enableLog | false | 为 true 时,通过 runLogic 执行该逻辑(表单 Form Hook / Data Hook、自定义函数调用等)会写一条 DynamicLogicExecuteRecord 执行记录,包括入参、返回值、异常堆栈。界面上没有这类记录的菜单,只能在数据库中查询表 dynamic_logic_execute_record。对象动作、对象客制化、定时任务不走这条路径,它们有各自的执行记录 |
rateLimit | 空 | 每秒最多执行次数(令牌桶限流)。超过后本次调用抛出错误码 1007(RateLimitExceed)的异常。为空或不大于 0 时不限流 |
提示
动态逻辑、对象动作、对象客制化、动态服务、显示主题的名称(name)创建后不可修改。定时任务的名称可以修改,但不建议修改,原因见 定时任务。
# 动态逻辑引擎
系统内置 5 种执行引擎(菜单 开发定制 > 动态逻辑 > 动态逻辑引擎):
| 动态逻辑引擎 | 描述 |
|---|---|
| GROOVY_CODE | 执行 代码 字段中的 Groovy 脚本(兼容纯 Java 语法)。绝大多数场景使用该引擎 |
| JVM_BYTECODE | 执行预编译的 JVM 字节码,代码 字段保存字节码信息的 JSON,不适合手工编写 |
| OS_COMMAND | 设计用于执行操作系统命令,当前版本不可用,见下方说明 |
| RENDER_LINK | 与 GROOVY_CODE 使用同一个执行器,行为完全相同 |
| BEAN_EXECUTOR | 执行插件中提供的 Java/Groovy 类,见下文 |
OS_COMMAND 当前不可用
OS_COMMAND 引擎执行的命令来自一个在当前版本中从未被赋值的内部字段,执行时会直接报错。需要调用外部命令时,请在 GROOVY_CODE 逻辑中自行调用(例如 "ls -l".execute())。
# BEAN_EXECUTOR 用法
BEAN_EXECUTOR 用于把逻辑实现放在插件代码里,便于单元测试和 IDE 调试:
- 在插件中实现接口
tech.muyan.api.logic.DynamicLogicExecutor:
package com.example.logic;
import tech.muyan.api.logic.DynamicLogicExecutor;
import java.util.Map;
public class DiscountPriceExecutor implements DynamicLogicExecutor {
@Override
public Map<String, ?> execute(Map<String, Object> params) {
// params 中包含该场景注入的全部变量,以及 logicName、logicType 两个额外参数
return Map.of("result", "OK");
}
}
2
3
4
5
6
7
8
9
10
11
12
- 新建动态逻辑,执行引擎选
BEAN_EXECUTOR,代码字段只填写该类的全限定类名,例如com.example.logic.DiscountPriceExecutor。
平台从插件的 ClassLoader 加载该类并取得对应的 Bean;类未实现 DynamicLogicExecutor 时执行报错。执行时,平台在该场景的注入变量之外,还会放入 logicName(逻辑名称)和 logicType(逻辑类型名称)两个参数。
# 动态逻辑开发指南
# 注入变量
动态逻辑的代码是一段 Groovy 脚本。运行时,平台把当前场景的上下文变量注入到脚本中,不同场景注入的变量不同,详见各场景的文档。
以下三个变量在所有 GROOVY_CODE 与 JVM_BYTECODE 逻辑中都可以使用:
| 变量 | 类型 | 说明 |
|---|---|---|
application | grails.core.GrailsApplication | 当前的 grails 应用上下文 |
invoke | Closure | 调用自定义函数,用法见 自定义函数 |
logger | org.slf4j.Logger | 名为 DynamicLogic 的日志对象,输出到平台后台日志 |
对象动作、对象客制化、定时任务、动态服务、Webhook 五种场景还会注入 log 闭包,用 log("...") 写入的内容会保存到对应执行记录的"执行日志"中:对象动作、对象客制化、定时任务、动态服务分别在菜单 执行记录 > 对象动作、执行记录 > 对象客制化、执行记录 > 定时任务、执行记录 > 服务 中查看(动态服务要开启 Enable Log 后才会保存执行记录);Webhook 的执行记录从 Webhook 列表的 Exec records 列进入。表单 Form Hook、Data Hook、自定义函数等其他场景没有 log 变量,请使用 logger。
# 返回值约定
- GROOVY_CODE / JVM_BYTECODE 逻辑的返回值必须是一个
Map。返回其他类型(包括null、字符串、数字)时,平台会把返回值当作空 Map[:]处理。 - Map 中需要包含哪些键,由各场景约定,例如对象动作读取
execResult、定时任务读取execResult、自定义函数通常返回result。
// 正确:返回 Map
return [execResult: "处理完成"]
// 错误:返回字符串,调用方拿到的是空 Map
return "处理完成"
2
3
4
5
# 实际应用示例
以下是一些具体的业务场景和动态逻辑定义示例:
# 客户信用额度自动调整
业务场景:创建客户时,根据客户的初始信用评级自动设置信用额度。
动态逻辑定义:
- 逻辑类型:
OBJECT_DYNAMIC_HOOK,挂在客户对象的"创建前"客制化上 - 代码:读取
object.creditRating,按评级映射出信用额度后直接赋值给object.creditLimit。创建前客制化对object的修改会随对象一起保存,详见 对象客制化
# 表单联动
业务场景:订单创建表单中,选择「订单类型」后自动联动「交货日期」的显隐与必填。
动态逻辑定义:
- 逻辑类型:
FUNCTION_LOGIC(平台内置的表单 Form Hook 也使用该类型),配置在DynamicForm.formHook上 - 触发字段:
formHookTriggerFields设置为orderType - 代码:根据
changedFields与object的值,返回字段属性 Map。详细用法请参考 表单客制化(Form Hook)
# 动态逻辑类型
系统当前有以下 14 种逻辑类型(菜单 开发定制 > 动态逻辑 > 动态逻辑类型):
| 类型 | 下拉框显示 | 使用场景 |
|---|---|---|
OBJECT_DYNAMIC_HOOK | Object Dynamic Hook | 对象客制化 的核心逻辑 |
DYNAMIC_ACTION_ENABLE_LOGIC | Dynamic Action Enable Logic | 对象动作 的启用逻辑 |
DYNAMIC_ACTION_LOGIC | Dynamic Action Logic | 对象动作的核心逻辑 |
FORM_GROUP_ENABLE_LOGIC | Form Group Enable Logic | 表单字段组的显示逻辑 |
WIZARD_CORE_LOGIC | Wizard Core Logic | 向导的处理逻辑 |
DASHBOARD_WIDGET_ENABLE_LOGIC | Dashboard Widget Enable Logic | 仪表盘小组件的启用逻辑 |
DASHBOARD_WIDGET_CORE_LOGIC | Dashboard Widget Core Logic | 仪表盘小组件的数据逻辑 |
GANTT_RENDER_LOGIC | Gantt row render logic | 甘特图行渲染 |
OBJECT_CLONE_CORE_LOGIC | Clone clone core logic | 对象克隆 |
DYNAMIC_SERVICE_CORE_LOGIC | Dynamic service core logic | 动态服务 的核心逻辑 |
FUNCTION_LOGIC | Function logic | 自定义函数,也用于表单的 Form Hook / Data Hook |
DYNAMIC_TASK_CORE_LOGIC | Dynamic task core logic | 定时任务 的核心逻辑 |
FIELD_DYNAMIC_HOOK | Field Dynamic Hook | 遗留类型,字段级客制化已移除,平台不再执行该类型的逻辑 |
DYNAMIC_ACTION_POST_LOGIC | Dynamic Action Post Logic | 遗留类型,平台不再执行 |
以下分别对动态逻辑在不同业务场景下的应用进行了详细说明:
# 自定义函数
自定义函数是类型为 FUNCTION_LOGIC 的动态逻辑,可以被其他动态逻辑直接调用,用于沉淀可复用的计算或业务规则。
# 定义函数
新建一个动态逻辑,类型选择 Function logic(新建表单见本页开头的截图)。下面的例子定义了一个名为「计算折扣价」的函数:
// 计算折扣价
// 参数:price 原价,discount 折扣率(0.85 表示八五折)
BigDecimal finalPrice = (price as BigDecimal) * (discount as BigDecimal)
return [result: finalPrice.setScale(2, java.math.RoundingMode.HALF_UP)]
2
3
4
调用方传入的参数会作为变量注入函数代码,这里的 price 和 discount 就是调用时传入的参数。函数同样必须返回 Map,否则调用方拿到的是空 Map。
# 调用函数
在任意 GROOVY_CODE 逻辑中,直接使用平台注入的 invoke 闭包调用函数:
// 第一个参数为函数(动态逻辑)的名称,第二个参数为传入的参数 Map
Map res = invoke("计算折扣价", [price: 199.00, discount: 0.85])
// res 为函数返回的 Map,这里是 [result: 169.15]
2
3
在插件代码或无法使用 invoke 变量的地方,也可以通过 Bean 调用,效果相同:
import tech.muyan.BeanHelper
import tech.muyan.api.DynamicFunctionService
Map res = BeanHelper.getBean(DynamicFunctionService).invoke("计算折扣价", [price: 199.00, discount: 0.85]) as Map
2
3
4
下面用一个对象动作演示调用过程(对象动作的配置方法见 对象动作)。先新建一个类型为 Dynamic Action Logic 的动态逻辑「折扣价试算」,作为动作的核心逻辑,调用「计算折扣价」函数并把结果作为动作的执行结果返回:
// 调用自定义函数「计算折扣价」
Map res = invoke("计算折扣价", [price: 199.00, discount: 0.85])
log("函数返回:${res}")
return [execResult: "原价 199.00 元,八五折后为 ${res.result} 元"]
2
3
4

然后新建模式为 CLASS_LEVEL 的对象动作「折扣价试算」,核心逻辑选择上面的逻辑,扩展信息填写 {"resultType": "inContainer"},让执行结果显示在弹窗中(不填时结果以页面顶部的消息提示显示)。动作还必须设置访问权限(accessRequirement),否则不会出现在列表中,该字段在界面上没有,设置方法见 对象动作。最后把动作挂到"用户"的列表表单上,列表上方出现「折扣价试算」按钮:

图中列名"User Groups"没有翻译,是当前版本的界面问题。
点击按钮执行,弹窗显示函数计算出的折后价:

注意
invoke 按名称查找类型为 FUNCTION_LOGIC 的动态逻辑,找不到时报错 ErrorCode: 15001, ErrorMsg: FunctionNotFound, ExtInfo: <函数名>。
平台启动时把全部函数加载到内存,之后新建、修改函数会同步更新。当前版本有两个已知问题:删除函数后,或者把函数的类型改为其他类型后,在后端重启之前,它仍然可以被 invoke 调用。
# 调用外部 HTTP 接口
动态逻辑中可以直接使用 JDK 自带的 java.net.http.HttpClient,或者 Groovy 的 URL API 调用外部系统的 HTTP 接口,不需要额外依赖。1.0 起移除的 LLM 引擎、邮件发送等内置能力,可以用这种方式对接外部服务替代,例如调用大模型服务或邮件服务商提供的 HTTP API。
建议把调用封装成一个 FUNCTION_LOGIC 类型的自定义函数,其他逻辑通过 invoke 调用。下面的函数「调用外部接口」接收两个参数:url 为接口地址,payload 为请求体(Map),payload 为 null 时发送 GET 请求,否则以 JSON 格式发送 POST 请求:
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build()
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url as String))
.timeout(Duration.ofSeconds(10))
.header('Content-Type', 'application/json')
if (payload != null) {
builder.POST(HttpRequest.BodyPublishers.ofString(JsonOutput.toJson(payload)))
} else {
builder.GET()
}
HttpResponse<String> response = client.send(builder.build(), HttpResponse.BodyHandlers.ofString())
if (response.statusCode() >= 300) {
throw new RuntimeException("外部接口返回 ${response.statusCode()}:${response.body()}")
}
// 204 等空响应体不能交给 JsonSlurper 解析,否则会抛出 IllegalArgumentException
String body = response.body()
return [status: response.statusCode(), result: body ? new JsonSlurper().parseText(body) : null]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
在其他动态逻辑中调用。两个参数都要传,GET 请求时 payload 传 null:
Map res = invoke("调用外部接口", [
url : "https://api.example.com/orders",
payload: [orderNo: "SO-20260923-001", amount: 199.00]
])
def data = res.result // 外部接口返回的 JSON,已解析为 Map / List;响应体为空时为 null
2
3
4
5
只需要简单地 GET 一个接口时,也可以用 Groovy 的 URL API:
import groovy.json.JsonSlurper
String text = new URL("https://api.example.com/status").getText(
connectTimeout: 5000,
readTimeout: 10000,
requestProperties: [Accept: 'application/json']
)
def data = new JsonSlurper().parseText(text)
2
3
4
5
6
7
8
注意事项:
- 请求从后端服务所在的机器(或容器)发出,接口地址必须从那里可以访问。用 Docker 部署时,
localhost指的是后端容器自己。 - 一定要设置超时。对象动作、表单 Form Hook 等场景是同步执行的,外部接口响应慢会直接拖慢界面操作。
- 上例中外部接口返回非 2xx 状态码时抛出异常,调用方随之失败,例如对象动作的执行状态为
FAILED。需要容错时,在调用处try/catch。 - 接口密钥不要写在代码里,可以保存在 系统配置 中,用
ConfigHelper.dynamicConfigService.getByKey("配置 key")读取,或者通过环境变量提供。
# 修订历史
动态逻辑会保存修订历史:
- 每次修改
代码字段,版本号加 1,修改前的内容保存为一条历史版本,可在编辑表单的"历史版本"中查看。 - 在历史版本列表中,可以用"最新版本修改"查看某个历史版本与当前版本的差异,用"比较版本"比较选中的两个历史版本,用"设置为最新版本"把某个历史版本恢复为当前代码(回滚)。
注意
当前版本删除动态逻辑是物理删除,回收站 > 动态逻辑 菜单中不会出现被删除的逻辑。已经产生历史版本的逻辑,需要先删除其历史版本才能删除。
# 开发时自动刷新代码
以 JVM 系统属性 -DdynamicLogic.autoRefreshSeedDataFolder=true 启动平台后,平台会读取种子数据目录下 csv 子目录中文件名符合 ^(\d*-)?DynamicLogic(_.*)?\.csv$ 的文件(例如 DynamicLogic.csv、004-DynamicLogic.csv、DynamicLogic_task.csv),文件中必须有 name(*) 和 code(F) 两列。平台监听 code(F) 列引用的源文件,文件修改后自动刷新数据库中对应逻辑的代码,方便本地开发调试。生产环境请勿开启。