# 动态逻辑

本系统支持使用 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 调试:

  1. 在插件中实现接口 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");
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
  1. 新建动态逻辑,执行引擎选 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 "处理完成"
1
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)]
1
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]
1
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
1
2
3
4

下面用一个对象动作演示调用过程(对象动作的配置方法见 对象动作)。先新建一个类型为 Dynamic Action Logic 的动态逻辑「折扣价试算」,作为动作的核心逻辑,调用「计算折扣价」函数并把结果作为动作的执行结果返回:

// 调用自定义函数「计算折扣价」
Map res = invoke("计算折扣价", [price: 199.00, discount: 0.85])
log("函数返回:${res}")
return [execResult: "原价 199.00 元,八五折后为 ${res.result} 元"]
1
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]
1
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
1
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)
1
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) 列引用的源文件,文件修改后自动刷新数据库中对应逻辑的代码,方便本地开发调试。生产环境请勿开启。

Last Updated: 2026/9/24 14:27:35