add groovy script

This commit is contained in:
Timi
2026-09-04 20:38:17 +08:00
parent df16ac3c8e
commit 41f692aad9
53 changed files with 1708 additions and 541 deletions
@@ -0,0 +1,9 @@
package com.imyeyu.api.modules.common.bean;
/// Groovy 脚本执行结果
///
/// @param <T> 脚本返回值类型
/// @author Codex
/// @since 2026-09-04
public record GroovyScriptExecutionResult<T>(String scriptId, String versionId, Integer version, String checksum, T result) {
}
@@ -48,6 +48,8 @@ public class Attachment extends UUIDEntity {
GAO_ORDER_FULFILLMENT,
GAO_SALES_ORDER,
TEMP_FILE,
MIRROR
@@ -0,0 +1,42 @@
package com.imyeyu.api.modules.common.entity;
import com.imyeyu.spring.entity.UUIDEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/// Groovy 脚本
///
/// @author Codex
/// @since 2026-09-04
@Data
@EqualsAndHashCode(callSuper = true)
public class GroovyScript extends UUIDEntity {
///
///
/// @author 夜雨
/// @since 2026/9/4 14:54
public enum BizType {
/// GAO 积分规则
GAO_POINT_RULE
}
/// 业务归属
private BizType bizType;
/// 业务内唯一代码
private String code;
/// 名称
private String name;
/// 说明
private String description;
/// true 为允许执行已发布版本
private Boolean enabled;
/// 当前发布版本 ID
private String publishedVersionId;
}
@@ -0,0 +1,26 @@
package com.imyeyu.api.modules.common.entity;
import com.imyeyu.spring.entity.UUIDEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
/// Groovy 脚本版本
///
/// @author Codex
/// @since 2026-09-04
@Data
@EqualsAndHashCode(callSuper = true)
public class GroovyScriptVersion extends UUIDEntity {
/// 脚本 ID
private String scriptId;
/// 版本号
private Integer version;
/// 脚本内容
private String content;
/// 脚本内容 SHA-256
private String checksum;
}
@@ -0,0 +1,11 @@
package com.imyeyu.api.modules.common.mapper;
import com.imyeyu.api.modules.common.entity.GroovyScript;
import com.imyeyu.spring.mapper.BaseMapper;
/// Groovy 脚本 Mapper
///
/// @author Codex
/// @since 2026-09-04
public interface GroovyScriptMapper extends BaseMapper<GroovyScript, String> {
}
@@ -0,0 +1,11 @@
package com.imyeyu.api.modules.common.mapper;
import com.imyeyu.api.modules.common.entity.GroovyScriptVersion;
import com.imyeyu.spring.mapper.BaseMapper;
/// Groovy 脚本版本 Mapper
///
/// @author Codex
/// @since 2026-09-04
public interface GroovyScriptVersionMapper extends BaseMapper<GroovyScriptVersion, String> {
}
@@ -0,0 +1,120 @@
package com.imyeyu.api.modules.common.service;
import com.imyeyu.api.modules.common.entity.GroovyScript;
import com.imyeyu.api.modules.common.entity.GroovyScriptVersion;
import com.imyeyu.api.modules.common.bean.GroovyScriptExecutionResult;
import com.imyeyu.spring.service.BaseService;
import java.util.List;
import java.util.Map;
/// Groovy 脚本服务
///
/// 脚本仅限可信管理员维护,不能作为不可信用户代码的执行环境
///
/// @author Codex
/// @since 2026-09-04
public interface GroovyScriptService extends BaseService<GroovyScript, String> {
/// 按业务归属和代码查询脚本
///
/// @param bizType 业务归属
/// @param code 业务内唯一代码
/// @return 脚本,不存在时返回 null
GroovyScript getByCode(GroovyScript.BizType bizType, String code);
/// 创建脚本元数据
///
/// 新建脚本默认停用,且没有已发布版本
///
/// @param script 脚本元数据
void create(GroovyScript script);
/// 更新脚本元数据
///
/// 业务归属和代码是脚本稳定标识,创建后不可变更
///
/// @param script 脚本元数据
void update(GroovyScript script);
/// 设置脚本启用状态
///
/// @param id 脚本 ID
/// @param enabled true 为允许执行已发布版本
void setEnabled(String id, boolean enabled);
/// 创建不可变的脚本版本
///
/// @param scriptId 脚本 ID
/// @param content 脚本内容
/// @return 新版本
GroovyScriptVersion createVersion(String scriptId, String content);
/// 查询脚本的全部版本
///
/// @param scriptId 脚本 ID
/// @return 按版本号升序的版本列表
List<GroovyScriptVersion> listVersion(String scriptId);
/// 发布指定版本
///
/// @param scriptId 脚本 ID
/// @param versionId 脚本版本 ID
void publish(String scriptId, String versionId);
/// 取消当前发布版本
///
/// @param scriptId 脚本 ID
void unpublish(String scriptId);
/// 删除脚本
///
/// @param id 脚本 ID
void delete(String id);
/// 执行已发布且启用的脚本
///
/// @param bizType 业务归属
/// @param code 业务内唯一代码
/// @param bindings 传给脚本的变量
/// @return 脚本返回值
Object execute(GroovyScript.BizType bizType, String code, Map<String, Object> bindings);
/// 执行已发布且启用的脚本并返回实际执行版本
///
/// @param bizType 业务归属
/// @param code 业务内唯一代码
/// @param bindings 传给脚本的变量
/// @param resultType 返回值类型
/// @param <T> 返回值类型
/// @return 脚本执行结果
<T> GroovyScriptExecutionResult<T> executePublished(GroovyScript.BizType bizType, String code, Map<String, Object> bindings, Class<T> resultType);
/// 按指定历史版本执行脚本
///
/// 用于按积分发生时的脚本版本复算,脚本当前是否发布或启用不影响执行;脚本返回 null 表示未命中,属合法结果
///
/// @param versionId 脚本版本 ID
/// @param bindings 传给脚本的变量
/// @param resultType 返回值类型
/// @param <T> 返回值类型
/// @return 脚本执行结果
<T> GroovyScriptExecutionResult<T> executeVersion(String versionId, Map<String, Object> bindings, Class<T> resultType);
/// 执行已发布且启用的脚本并验证返回值类型
///
/// @param bizType 业务归属
/// @param code 业务内唯一代码
/// @param bindings 传给脚本的变量
/// @param resultType 返回值类型
/// @param <T> 返回值类型
/// @return 脚本返回值
<T> T execute(GroovyScript.BizType bizType, String code, Map<String, Object> bindings, Class<T> resultType);
/// 清除脚本的本地编译缓存
///
/// 多实例部署时,其他节点在收到脚本变更通知后调用此方法
///
/// @param scriptId 脚本 ID
void invalidate(String scriptId);
}
@@ -0,0 +1,293 @@
package com.imyeyu.api.modules.common.service.implement;
import com.imyeyu.api.config.dbsource.TimiServerDBConfig;
import com.imyeyu.api.modules.common.entity.GroovyScript;
import com.imyeyu.api.modules.common.entity.GroovyScriptVersion;
import com.imyeyu.api.modules.common.bean.GroovyScriptExecutionResult;
import com.imyeyu.api.modules.common.mapper.GroovyScriptMapper;
import com.imyeyu.api.modules.common.mapper.GroovyScriptVersionMapper;
import com.imyeyu.api.modules.common.service.GroovyScriptService;
import com.imyeyu.java.bean.timi.TimiCode;
import com.imyeyu.java.bean.timi.TimiException;
import com.imyeyu.spring.mapper.BaseMapper;
import com.imyeyu.spring.service.AbstractEntityService;
import groovy.lang.Binding;
import groovy.lang.GroovyClassLoader;
import groovy.lang.Script;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/// Groovy 脚本服务实现
///
/// 每个已编译版本使用独立类加载器,缓存失效后可释放对应类及其元数据
///
/// @author Codex
/// @since 2026-09-04
@Slf4j
@Service
@RequiredArgsConstructor
public class GroovyScriptServiceImplement extends AbstractEntityService<GroovyScript, String> implements GroovyScriptService {
private final GroovyScriptMapper mapper;
private final GroovyScriptVersionMapper versionMapper;
private final Map<String, CompiledScript> compiledScriptMap = new ConcurrentHashMap<>();
@Override
protected BaseMapper<GroovyScript, String> mapper() {
return mapper;
}
@Override
public GroovyScript getByCode(GroovyScript.BizType bizType, String code) {
GroovyScript example = new GroovyScript();
example.setBizType(bizType);
example.setCode(code);
return mapper.selectByExample(example);
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void create(GroovyScript script) {
TimiException.required(script, "未找到脚本");
TimiException.required(script.getBizType(), "未找到脚本业务归属");
TimiException.required(script.getCode(), "未找到脚本代码");
TimiException.required(script.getName(), "未找到脚本名称");
TimiException.requiredNull(getByCode(script.getBizType(), script.getCode()), "Groovy 脚本已存在");
script.setEnabled(false);
script.setPublishedVersionId(null);
super.create(script);
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void update(GroovyScript script) {
TimiException.required(script, "未找到脚本");
TimiException.required(script.getId(), "未找到脚本 ID");
TimiException.required(script.getName(), "未找到脚本名称");
GroovyScript dbScript = get(script.getId());
TimiException.required(dbScript, "未找到脚本");
dbScript.setName(script.getName());
dbScript.setDescription(script.getDescription());
mapper.update(dbScript);
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void setEnabled(String id, boolean enabled) {
GroovyScript script = requireScript(id);
script.setEnabled(enabled);
mapper.update(script);
invalidateAfterCommit(script.getId());
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public GroovyScriptVersion createVersion(String scriptId, String content) {
requireScript(scriptId);
TimiException.required(content, "未找到脚本内容");
TimiException.requiredTrue(!content.isBlank(), "脚本内容不能为空");
GroovyScriptVersion version = new GroovyScriptVersion();
version.setScriptId(scriptId);
version.setVersion(nextVersion(scriptId));
version.setContent(content);
version.setChecksum(checksum(content));
versionMapper.insert(version);
return version;
}
@Override
public List<GroovyScriptVersion> listVersion(String scriptId) {
TimiException.required(scriptId, "未找到脚本 ID");
GroovyScriptVersion example = new GroovyScriptVersion();
example.setScriptId(scriptId);
List<GroovyScriptVersion> versionList = new ArrayList<>(versionMapper.selectAllByExample(example));
versionList.sort(Comparator.comparing(GroovyScriptVersion::getVersion));
return versionList;
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void publish(String scriptId, String versionId) {
GroovyScript script = requireScript(scriptId);
GroovyScriptVersion version = requireVersion(versionId);
TimiException.requiredTrue(script.getId().equals(version.getScriptId()), "脚本版本不属于当前脚本");
script.setPublishedVersionId(version.getId());
mapper.update(script);
invalidateAfterCommit(script.getId());
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void unpublish(String scriptId) {
GroovyScript script = requireScript(scriptId);
script.setPublishedVersionId(null);
mapper.update(script);
invalidateAfterCommit(script.getId());
}
@Transactional(TimiServerDBConfig.ROLLBACKER)
@Override
public void delete(String id) {
requireScript(id);
super.delete(id);
invalidateAfterCommit(id);
}
@Override
public Object execute(GroovyScript.BizType bizType, String code, Map<String, Object> bindings) {
return executePublished(bizType, code, bindings, Object.class).result();
}
@Override
public <T> GroovyScriptExecutionResult<T> executePublished(GroovyScript.BizType bizType, String code, Map<String, Object> bindings, Class<T> resultType) {
TimiException.required(bizType, "未找到脚本业务归属");
TimiException.required(code, "未找到脚本代码");
TimiException.required(resultType, "未找到返回值类型");
GroovyScript script = getByCode(bizType, code);
TimiException.required(script, "未找到 Groovy 脚本");
TimiException.requiredTrue(Boolean.TRUE.equals(script.getEnabled()), "Groovy 脚本已停用");
TimiException.required(script.getPublishedVersionId(), "Groovy 脚本尚未发布");
GroovyScriptExecutionResult<T> result = executeVersion(script.getPublishedVersionId(), bindings, resultType);
TimiException.requiredTrue(script.getId().equals(result.scriptId()), "Groovy 脚本已发布版本无效");
return result;
}
@Override
public <T> T execute(GroovyScript.BizType bizType, String code, Map<String, Object> bindings, Class<T> resultType) {
return executePublished(bizType, code, bindings, resultType).result();
}
@Override
public <T> GroovyScriptExecutionResult<T> executeVersion(String versionId, Map<String, Object> bindings, Class<T> resultType) {
TimiException.required(resultType, "未找到返回值类型");
GroovyScriptVersion version = requireVersion(versionId);
CompiledScript compiledScript = compiledScriptMap.computeIfAbsent(cacheKey(version), key -> compile(version.getScriptId(), version));
try {
Object result = compiledScript.execute(bindings);
TimiException.requiredTrue(result == null || resultType.isInstance(result), "Groovy 脚本返回值类型不匹配");
return new GroovyScriptExecutionResult<>(version.getScriptId(), version.getId(), version.getVersion(), version.getChecksum(), resultType.cast(result));
} catch (TimiException e) {
throw e;
} catch (RuntimeException e) {
log.warn("Groovy 脚本执行失败,scriptId={}, versionId={}", version.getScriptId(), version.getId(), e);
throw new TimiException(TimiCode.ERROR, "Groovy 脚本执行失败");
}
}
@Override
public void invalidate(String scriptId) {
if (scriptId == null) {
return;
}
compiledScriptMap.entrySet().removeIf(entry -> {
if (!scriptId.equals(entry.getValue().scriptId())) {
return false;
}
entry.getValue().close();
return true;
});
}
private GroovyScript requireScript(String id) {
TimiException.required(id, "未找到脚本 ID");
GroovyScript script = get(id);
TimiException.required(script, "未找到脚本");
return script;
}
private GroovyScriptVersion requireVersion(String id) {
TimiException.required(id, "未找到脚本版本 ID");
GroovyScriptVersion version = versionMapper.select(id);
TimiException.required(version, "未找到脚本版本");
return version;
}
private int nextVersion(String scriptId) {
return listVersion(scriptId).stream()
.map(GroovyScriptVersion::getVersion)
.max(Integer::compareTo)
.orElse(0) + 1;
}
private String cacheKey(GroovyScriptVersion version) {
return "%s:%s".formatted(version.getId(), version.getChecksum());
}
private CompiledScript compile(String scriptId, GroovyScriptVersion version) {
GroovyClassLoader classLoader = new GroovyClassLoader(getClass().getClassLoader());
try {
Class<?> type = classLoader.parseClass(version.getContent(), "GroovyScript_%s_%d.groovy".formatted(version.getId().replace('-', '_'), version.getVersion()));
return new CompiledScript(scriptId, type.asSubclass(Script.class), classLoader);
} catch (RuntimeException e) {
close(classLoader);
log.warn("Groovy 脚本编译失败,scriptId={}, versionId={}", scriptId, version.getId(), e);
throw new TimiException(TimiCode.ERROR, "Groovy 脚本编译失败");
}
}
private String checksum(String content) {
try {
return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(content.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("JVM 缺少 SHA-256 算法", e);
}
}
private void invalidateAfterCommit(String scriptId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
invalidate(scriptId);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
invalidate(scriptId);
}
});
}
private void close(GroovyClassLoader classLoader) {
try {
classLoader.close();
} catch (IOException e) {
log.warn("关闭 Groovy 脚本类加载器失败", e);
}
}
private record CompiledScript(String scriptId, Class<? extends Script> type, GroovyClassLoader classLoader) {
private Object execute(Map<String, Object> bindings) {
try {
Script script = type.getDeclaredConstructor().newInstance();
script.setBinding(new Binding(bindings == null ? Map.of() : new HashMap<>(bindings)));
return script.run();
} catch (ReflectiveOperationException e) {
throw new IllegalStateException("Groovy 脚本实例化失败", e);
}
}
private void close() {
try {
classLoader.close();
} catch (IOException ignored) {
}
}
}
}