diff --git a/.gitignore b/.gitignore
index 3e4edcd..ebd4761 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,6 +42,7 @@ build/
.jlsp/
.arts/
luceneKnowledge
+**/*.lic
# v1
/easyflow-ui-react
\ No newline at end of file
diff --git a/easyflow-modules/easyflow-module-autoconfig/pom.xml b/easyflow-modules/easyflow-module-autoconfig/pom.xml
index 002cde0..d73bc4b 100644
--- a/easyflow-modules/easyflow-module-autoconfig/pom.xml
+++ b/easyflow-modules/easyflow-module-autoconfig/pom.xml
@@ -16,5 +16,11 @@
tech.easyflow
easyflow-common-web
+
+ junit
+ junit
+ ${junit.version}
+ test
+
diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseBootstrapValidator.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseBootstrapValidator.java
new file mode 100644
index 0000000..2f6403d
--- /dev/null
+++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseBootstrapValidator.java
@@ -0,0 +1,95 @@
+package tech.easyflow.autoconfig.license;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.context.EnvironmentAware;
+import org.springframework.core.Ordered;
+import org.springframework.core.PriorityOrdered;
+import org.springframework.core.env.Environment;
+import org.springframework.core.io.DefaultResourceLoader;
+import org.springframework.stereotype.Component;
+
+/**
+ * EasyFlow 启动前的 license 强制校验器。
+ */
+@Component
+public class EasyflowLicenseBootstrapValidator implements BeanFactoryPostProcessor, EnvironmentAware, PriorityOrdered {
+
+ private static final Logger LOG = LoggerFactory.getLogger(EasyflowLicenseBootstrapValidator.class);
+
+ private final EasyflowLicenseVerifier easyflowLicenseVerifier;
+ private String location;
+
+ /**
+ * 构造启动前校验器。
+ */
+ public EasyflowLicenseBootstrapValidator() {
+ this(new EasyflowLicenseVerifier(new DefaultResourceLoader(), new MachineIdentityCollector()));
+ }
+
+ /**
+ * 构造启动前校验器。
+ *
+ * @param easyflowLicenseVerifier license 校验器
+ */
+ EasyflowLicenseBootstrapValidator(EasyflowLicenseVerifier easyflowLicenseVerifier) {
+ this.easyflowLicenseVerifier = easyflowLicenseVerifier;
+ }
+
+ /**
+ * 从环境中读取 license 配置。
+ *
+ * @param environment Spring 环境
+ */
+ @Override
+ public void setEnvironment(Environment environment) {
+ this.location = environment.getProperty("easyflow.license.location");
+ }
+
+ /**
+ * 在 BeanFactory 初始化阶段执行 license 校验。
+ *
+ * @param beanFactory BeanFactory
+ * @throws BeansException 校验失败
+ */
+ @Override
+ public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
+ try {
+ EasyflowLicenseVerificationResult result = easyflowLicenseVerifier.verify(location);
+ LOG.info("license 校验通过: location={}, licenseId={}, keyId={}, licenseType={}, expiresAt={}",
+ result.location(),
+ result.licenseId(),
+ result.keyId(),
+ result.licenseType(),
+ result.expiresAt());
+ } catch (EasyflowLicenseException ex) {
+ if (ex.isMissing()) {
+ LOG.error("lic 文件不存在: location={}, reason={}",
+ ex.getLocation(),
+ ex.getMessage(),
+ ex);
+ } else {
+ LOG.error("license 文件校验失败: location={}, licenseId={}, keyId={}, reason={}",
+ ex.getLocation(),
+ ex.getLicenseId(),
+ ex.getKeyId(),
+ ex.getMessage(),
+ ex);
+ }
+ throw new IllegalStateException(ex.getMessage(), ex);
+ }
+ }
+
+ /**
+ * 以最高优先级执行,先于其他启动扩展。
+ *
+ * @return 执行顺序
+ */
+ @Override
+ public int getOrder() {
+ return Ordered.HIGHEST_PRECEDENCE;
+ }
+}
diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseException.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseException.java
new file mode 100644
index 0000000..cdbe737
--- /dev/null
+++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseException.java
@@ -0,0 +1,142 @@
+package tech.easyflow.autoconfig.license;
+
+/**
+ * EasyFlow license 校验异常。
+ */
+public class EasyflowLicenseException extends RuntimeException {
+
+ private final Reason reason;
+ private final String location;
+ private final String licenseId;
+ private final String keyId;
+
+ /**
+ * 构造 license 校验异常。
+ *
+ * @param reason 异常类型
+ * @param message 异常消息
+ * @param location license 位置
+ * @param licenseId licenseId
+ * @param keyId keyId
+ * @param cause 原始异常
+ */
+ public EasyflowLicenseException(Reason reason,
+ String message,
+ String location,
+ String licenseId,
+ String keyId,
+ Throwable cause) {
+ super(message, cause);
+ this.reason = reason;
+ this.location = location;
+ this.licenseId = licenseId;
+ this.keyId = keyId;
+ }
+
+ /**
+ * 创建缺失类异常。
+ *
+ * @param message 异常消息
+ * @param location license 位置
+ * @return 异常实例
+ */
+ public static EasyflowLicenseException missing(String message, String location) {
+ return new EasyflowLicenseException(Reason.MISSING, message, location, null, null, null);
+ }
+
+ /**
+ * 创建缺失类异常。
+ *
+ * @param message 异常消息
+ * @param location license 位置
+ * @param cause 原始异常
+ * @return 异常实例
+ */
+ public static EasyflowLicenseException missing(String message, String location, Throwable cause) {
+ return new EasyflowLicenseException(Reason.MISSING, message, location, null, null, cause);
+ }
+
+ /**
+ * 创建校验失败异常。
+ *
+ * @param message 异常消息
+ * @param location license 位置
+ * @param licenseId licenseId
+ * @param keyId keyId
+ * @return 异常实例
+ */
+ public static EasyflowLicenseException invalid(String message, String location, String licenseId, String keyId) {
+ return new EasyflowLicenseException(Reason.INVALID, message, location, licenseId, keyId, null);
+ }
+
+ /**
+ * 创建校验失败异常。
+ *
+ * @param message 异常消息
+ * @param location license 位置
+ * @param licenseId licenseId
+ * @param keyId keyId
+ * @param cause 原始异常
+ * @return 异常实例
+ */
+ public static EasyflowLicenseException invalid(String message,
+ String location,
+ String licenseId,
+ String keyId,
+ Throwable cause) {
+ return new EasyflowLicenseException(Reason.INVALID, message, location, licenseId, keyId, cause);
+ }
+
+ /**
+ * 获取异常类型。
+ *
+ * @return 异常类型
+ */
+ public Reason getReason() {
+ return reason;
+ }
+
+ /**
+ * 获取 license 位置。
+ *
+ * @return license 位置
+ */
+ public String getLocation() {
+ return location;
+ }
+
+ /**
+ * 获取 licenseId。
+ *
+ * @return licenseId
+ */
+ public String getLicenseId() {
+ return licenseId;
+ }
+
+ /**
+ * 获取 keyId。
+ *
+ * @return keyId
+ */
+ public String getKeyId() {
+ return keyId;
+ }
+
+ /**
+ * 判断是否为缺失类异常。
+ *
+ * @return 是否缺失
+ */
+ public boolean isMissing() {
+ return reason == Reason.MISSING;
+ }
+
+ /**
+ * License 异常类型。
+ */
+ public enum Reason {
+ MISSING,
+ INVALID
+ }
+}
diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseProperties.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseProperties.java
new file mode 100644
index 0000000..056d96c
--- /dev/null
+++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseProperties.java
@@ -0,0 +1,35 @@
+package tech.easyflow.autoconfig.license;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * EasyFlow license 配置属性。
+ */
+@Configuration
+@ConfigurationProperties(prefix = "easyflow.license")
+public class EasyflowLicenseProperties {
+
+ /**
+ * License 资源位置,支持 classpath: 与 file: 形式。
+ */
+ private String location;
+
+ /**
+ * 获取 license 资源位置。
+ *
+ * @return license 资源位置
+ */
+ public String getLocation() {
+ return location;
+ }
+
+ /**
+ * 设置 license 资源位置。
+ *
+ * @param location license 资源位置
+ */
+ public void setLocation(String location) {
+ this.location = location;
+ }
+}
diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerificationResult.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerificationResult.java
new file mode 100644
index 0000000..1f92925
--- /dev/null
+++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerificationResult.java
@@ -0,0 +1,17 @@
+package tech.easyflow.autoconfig.license;
+
+/**
+ * License 校验通过后的关键信息。
+ *
+ * @param location license 位置
+ * @param licenseId licenseId
+ * @param keyId keyId
+ * @param licenseType license 类型
+ * @param expiresAt 过期时间
+ */
+public record EasyflowLicenseVerificationResult(String location,
+ String licenseId,
+ String keyId,
+ String licenseType,
+ String expiresAt) {
+}
diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerifier.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerifier.java
new file mode 100644
index 0000000..9e95ee6
--- /dev/null
+++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/EasyflowLicenseVerifier.java
@@ -0,0 +1,444 @@
+package tech.easyflow.autoconfig.license;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.util.StringUtils;
+import tech.easyflow.common.util.HashUtil;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.spec.X509EncodedKeySpec;
+import java.time.Clock;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+/**
+ * EasyFlow license 校验器。
+ */
+public class EasyflowLicenseVerifier {
+
+ static final String LICENSE_FILE_PREFIX = "ELIC1.";
+ static final String PRODUCT_NAME = "EasyFlow";
+ static final String SIGNATURE_ALGORITHM = "Ed25519";
+ static final String FINGERPRINT_ALGORITHM = "sha256(machineId|productUuid|sortedMacs)";
+ static final String DEFAULT_PUBLIC_KEY_LOCATION = "classpath:easy-license-public.pem";
+
+ private static final Set SUPPORTED_LICENSE_TYPES = Set.of("TRIAL", "STANDARD", "DEV");
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private final ResourceLoader resourceLoader;
+ private final MachineIdentityCollector machineIdentityCollector;
+ private final Clock clock;
+ private final String publicKeyLocation;
+
+ /**
+ * 构造校验器。
+ *
+ * @param resourceLoader Spring 资源加载器
+ * @param machineIdentityCollector 机器信息采集器
+ */
+ public EasyflowLicenseVerifier(ResourceLoader resourceLoader, MachineIdentityCollector machineIdentityCollector) {
+ this(resourceLoader, machineIdentityCollector, Clock.systemUTC(), DEFAULT_PUBLIC_KEY_LOCATION);
+ }
+
+ /**
+ * 构造校验器。
+ *
+ * @param resourceLoader Spring 资源加载器
+ * @param machineIdentityCollector 机器信息采集器
+ * @param clock 时间时钟
+ * @param publicKeyLocation 公钥位置
+ */
+ EasyflowLicenseVerifier(ResourceLoader resourceLoader,
+ MachineIdentityCollector machineIdentityCollector,
+ Clock clock,
+ String publicKeyLocation) {
+ this.resourceLoader = resourceLoader;
+ this.machineIdentityCollector = machineIdentityCollector;
+ this.clock = clock;
+ this.publicKeyLocation = publicKeyLocation;
+ }
+
+ /**
+ * 校验指定位置的 license。
+ *
+ * @param location license 位置
+ * @return 校验结果
+ */
+ public EasyflowLicenseVerificationResult verify(String location) {
+ if (!StringUtils.hasText(location)) {
+ throw EasyflowLicenseException.missing("未配置 easyflow.license.location", location);
+ }
+
+ try {
+ ResolvedLicenseResource resolved = resolveLicenseResource(location);
+ String content = readText(resolved.resource());
+ return verifyContent(resolved.actualLocation(), content);
+ } catch (EasyflowLicenseException ex) {
+ throw ex;
+ } catch (IOException e) {
+ throw EasyflowLicenseException.missing("lic 文件读取失败", location, e);
+ }
+ }
+
+ /**
+ * 校验 license 文本内容。
+ *
+ * @param location license 位置
+ * @param content license 文本
+ * @return 校验结果
+ */
+ EasyflowLicenseVerificationResult verifyContent(String location, String content) {
+ String normalizedContent = normalizeRequired(content, "licenseContent", location, null, null).trim();
+ if (!normalizedContent.startsWith(LICENSE_FILE_PREFIX)) {
+ throw EasyflowLicenseException.invalid("license 前缀非法", location, null, null);
+ }
+
+ JsonNode envelopeNode = decodeEnvelope(normalizedContent, location);
+ String keyId = requireText(envelopeNode, "keyId", location, null, null);
+ JsonNode payloadNode = requireNode(envelopeNode, "payload", location, null, keyId);
+ String licenseId = requireText(payloadNode, "licenseId", location, null, keyId);
+
+ validateEnvelope(envelopeNode, location, licenseId, keyId);
+ validatePayload(payloadNode, location, licenseId, keyId);
+ verifySignature(payloadNode, requireText(envelopeNode, "signature", location, licenseId, keyId), location, licenseId, keyId);
+ verifyFingerprint(payloadNode, location, licenseId, keyId);
+
+ String licenseType = requireText(payloadNode, "licenseType", location, licenseId, keyId);
+ String expiresAt = payloadNode.path("expiresAt").isNull() ? null : payloadNode.path("expiresAt").asText(null);
+ return new EasyflowLicenseVerificationResult(location, licenseId, keyId, licenseType, expiresAt);
+ }
+
+ /**
+ * 构建机器指纹材料。
+ *
+ * @param machineId machineId
+ * @param productUuid productUuid
+ * @param macAddresses MAC 地址文本
+ * @return 指纹材料
+ */
+ static String buildMachineFingerprintMaterial(String machineId, String productUuid, String macAddresses) {
+ String normalizedMachineId = normalizeMachineId(machineId);
+ String normalizedProductUuid = normalizeProductUuid(productUuid);
+ List normalizedMacs = normalizeMacAddresses(macAddresses);
+ return "machineId=" + normalizedMachineId
+ + "|productUuid=" + normalizedProductUuid
+ + "|sortedMacs=" + String.join(",", normalizedMacs);
+ }
+
+ /**
+ * 计算机器指纹。
+ *
+ * @param identity 机器信息
+ * @return 机器指纹
+ */
+ static String computeMachineFingerprint(MachineIdentity identity) {
+ return HashUtil.sha256(buildMachineFingerprintMaterial(identity.machineId(), identity.productUuid(), identity.macAddresses()));
+ }
+
+ /**
+ * 归一化 MAC 地址文本列表。
+ *
+ * @param macAddresses MAC 地址文本
+ * @return 归一化后的 MAC 地址列表
+ */
+ static List normalizeMacAddresses(String macAddresses) {
+ String normalized = normalizeRequired(macAddresses, "macAddresses", null, null, null);
+ String[] parts = normalized.split("[,\\n]");
+ LinkedHashSet distinct = new LinkedHashSet<>();
+ for (String part : parts) {
+ if (StringUtils.hasText(part)) {
+ distinct.add(normalizeMacAddress(part));
+ }
+ }
+ if (distinct.isEmpty()) {
+ throw new IllegalArgumentException("macAddresses 不能为空");
+ }
+ List sorted = new ArrayList<>(distinct);
+ sorted.sort(Comparator.naturalOrder());
+ return sorted;
+ }
+
+ /**
+ * 归一化单个 MAC 地址。
+ *
+ * @param macAddress MAC 地址
+ * @return 归一化后的 MAC 地址
+ */
+ static String normalizeMacAddress(String macAddress) {
+ String compact = normalizeRequired(macAddress, "macAddress", null, null, null)
+ .toLowerCase(Locale.ROOT)
+ .replaceAll("[^a-f0-9]", "");
+ if (compact.length() != 12) {
+ throw new IllegalArgumentException("MAC 地址非法: " + macAddress);
+ }
+
+ StringBuilder builder = new StringBuilder(17);
+ for (int index = 0; index < compact.length(); index += 2) {
+ if (index > 0) {
+ builder.append(':');
+ }
+ builder.append(compact, index, index + 2);
+ }
+ return builder.toString();
+ }
+
+ /**
+ * 归一化 machineId。
+ *
+ * @param machineId machineId
+ * @return 归一化结果
+ */
+ static String normalizeMachineId(String machineId) {
+ return normalizeRequired(machineId, "machineId", null, null, null).toLowerCase(Locale.ROOT);
+ }
+
+ /**
+ * 归一化 productUuid。
+ *
+ * @param productUuid productUuid
+ * @return 归一化结果
+ */
+ static String normalizeProductUuid(String productUuid) {
+ return normalizeRequired(productUuid, "productUuid", null, null, null).toLowerCase(Locale.ROOT);
+ }
+
+ private JsonNode decodeEnvelope(String content, String location) {
+ try {
+ String encoded = content.substring(LICENSE_FILE_PREFIX.length());
+ byte[] decodedBytes = Base64.getUrlDecoder().decode(encoded);
+ return OBJECT_MAPPER.readTree(decodedBytes);
+ } catch (IllegalArgumentException | IOException e) {
+ throw EasyflowLicenseException.invalid("license 内容解码失败", location, null, null, e);
+ }
+ }
+
+ private void validateEnvelope(JsonNode envelopeNode, String location, String licenseId, String keyId) {
+ int formatVersion = envelopeNode.path("formatVersion").asInt(Integer.MIN_VALUE);
+ if (formatVersion != 1) {
+ throw EasyflowLicenseException.invalid("formatVersion 非法", location, licenseId, keyId);
+ }
+ String algorithm = requireText(envelopeNode, "alg", location, licenseId, keyId);
+ if (!SIGNATURE_ALGORITHM.equals(algorithm)) {
+ throw EasyflowLicenseException.invalid("签名算法非法", location, licenseId, keyId);
+ }
+ requireText(envelopeNode, "signature", location, licenseId, keyId);
+ }
+
+ private void validatePayload(JsonNode payloadNode, String location, String licenseId, String keyId) {
+ String product = requireText(payloadNode, "product", location, licenseId, keyId);
+ if (!PRODUCT_NAME.equals(product)) {
+ throw EasyflowLicenseException.invalid("product 非法", location, licenseId, keyId);
+ }
+
+ String licenseType = requireText(payloadNode, "licenseType", location, licenseId, keyId);
+ if (!SUPPORTED_LICENSE_TYPES.contains(licenseType)) {
+ throw EasyflowLicenseException.invalid("licenseType 非法", location, licenseId, keyId);
+ }
+
+ String fingerprintAlgorithm = requireText(payloadNode, "fingerprintAlgorithm", location, licenseId, keyId);
+ if (!FINGERPRINT_ALGORITHM.equals(fingerprintAlgorithm)) {
+ throw EasyflowLicenseException.invalid("fingerprintAlgorithm 非法", location, licenseId, keyId);
+ }
+
+ requireText(payloadNode, "customerName", location, licenseId, keyId);
+ requireText(payloadNode, "machineFingerprint", location, licenseId, keyId);
+
+ parseInstant(requireText(payloadNode, "issuedAt", location, licenseId, keyId), "issuedAt", location, licenseId, keyId);
+
+ JsonNode expiresAtNode = payloadNode.path("expiresAt");
+ if ("DEV".equals(licenseType)) {
+ if (!expiresAtNode.isMissingNode() && !expiresAtNode.isNull()) {
+ throw EasyflowLicenseException.invalid("DEV license 不允许 expiresAt", location, licenseId, keyId);
+ }
+ return;
+ }
+
+ if (expiresAtNode.isMissingNode() || expiresAtNode.isNull()) {
+ throw EasyflowLicenseException.invalid("非 DEV license 必须提供 expiresAt", location, licenseId, keyId);
+ }
+
+ Instant expiresAt = parseInstant(expiresAtNode.asText(), "expiresAt", location, licenseId, keyId);
+ if (Instant.now(clock).isAfter(expiresAt)) {
+ throw EasyflowLicenseException.invalid("license 已过期", location, licenseId, keyId);
+ }
+ }
+
+ private void verifySignature(JsonNode payloadNode, String signatureValue, String location, String licenseId, String keyId) {
+ try {
+ PublicKey publicKey = loadPublicKey();
+ Signature signature = Signature.getInstance(SIGNATURE_ALGORITHM);
+ signature.initVerify(publicKey);
+ signature.update(LicenseCanonicalJsonSerializer.serialize(payloadNode).getBytes(StandardCharsets.UTF_8));
+ boolean valid = signature.verify(Base64.getDecoder().decode(signatureValue));
+ if (!valid) {
+ throw EasyflowLicenseException.invalid("license 签名校验失败", location, licenseId, keyId);
+ }
+ } catch (EasyflowLicenseException ex) {
+ throw ex;
+ } catch (GeneralSecurityException | IOException | IllegalArgumentException e) {
+ throw EasyflowLicenseException.invalid("license 签名校验失败", location, licenseId, keyId, e);
+ }
+ }
+
+ private void verifyFingerprint(JsonNode payloadNode, String location, String licenseId, String keyId) {
+ String expectedFingerprint = requireText(payloadNode, "machineFingerprint", location, licenseId, keyId);
+ MachineIdentity identity;
+ try {
+ identity = machineIdentityCollector.collect();
+ } catch (Exception e) {
+ throw EasyflowLicenseException.invalid("机器参数采集失败: " + e.getMessage(), location, licenseId, keyId, e);
+ }
+
+ String actualFingerprint;
+ try {
+ actualFingerprint = computeMachineFingerprint(identity);
+ } catch (IllegalArgumentException e) {
+ throw EasyflowLicenseException.invalid("机器指纹计算失败: " + e.getMessage(), location, licenseId, keyId, e);
+ }
+
+ if (!expectedFingerprint.equals(actualFingerprint)) {
+ throw EasyflowLicenseException.invalid("license 机器指纹不匹配", location, licenseId, keyId);
+ }
+ }
+
+ private PublicKey loadPublicKey() throws GeneralSecurityException, IOException {
+ Resource publicKeyResource = resourceLoader.getResource(publicKeyLocation);
+ if (!publicKeyResource.exists() || !publicKeyResource.isReadable()) {
+ throw new IOException("公钥资源不存在或不可读: " + publicKeyLocation);
+ }
+ String pem = readText(publicKeyResource);
+ String normalizedPem = pem
+ .replace("-----BEGIN PUBLIC KEY-----", "")
+ .replace("-----END PUBLIC KEY-----", "")
+ .replaceAll("\\s+", "");
+ byte[] keyBytes = Base64.getDecoder().decode(normalizedPem);
+ return KeyFactory.getInstance(SIGNATURE_ALGORITHM).generatePublic(new X509EncodedKeySpec(keyBytes));
+ }
+
+ private String readText(Resource resource) throws IOException {
+ try (InputStream inputStream = resource.getInputStream()) {
+ return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8).trim();
+ }
+ }
+
+ private ResolvedLicenseResource resolveLicenseResource(String location) {
+ String normalizedLocation = location.trim();
+ if (normalizedLocation.startsWith("file:")) {
+ return resolveFileLicenseResource(normalizedLocation, normalizedLocation.substring("file:".length()));
+ }
+
+ Resource resource = resourceLoader.getResource(normalizedLocation);
+ if (!resource.exists() || !resource.isReadable()) {
+ throw EasyflowLicenseException.missing("lic 文件不存在", normalizedLocation);
+ }
+ return new ResolvedLicenseResource(resource, normalizedLocation);
+ }
+
+ private ResolvedLicenseResource resolveFileLicenseResource(String originalLocation, String rawPath) {
+ Path candidatePath = Paths.get(rawPath).toAbsolutePath().normalize();
+
+ if (Files.isDirectory(candidatePath)) {
+ return resolveSingleLicFile(candidatePath, originalLocation);
+ }
+
+ if (Files.exists(candidatePath) && Files.isRegularFile(candidatePath) && Files.isReadable(candidatePath)) {
+ return new ResolvedLicenseResource(resourceLoader.getResource(candidatePath.toUri().toString()), candidatePath.toUri().toString());
+ }
+
+ if (candidatePath.getFileName() != null
+ && candidatePath.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".lic")) {
+ Path parent = candidatePath.getParent();
+ if (parent != null && Files.isDirectory(parent)) {
+ return resolveSingleLicFile(parent, originalLocation);
+ }
+ }
+
+ throw EasyflowLicenseException.missing("lic 文件不存在", originalLocation);
+ }
+
+ private ResolvedLicenseResource resolveSingleLicFile(Path directory, String originalLocation) {
+ try (Stream stream = Files.list(directory)) {
+ List licFiles = stream
+ .filter(Files::isRegularFile)
+ .filter(Files::isReadable)
+ .filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".lic"))
+ .sorted()
+ .collect(Collectors.toList());
+
+ if (licFiles.isEmpty()) {
+ throw EasyflowLicenseException.missing("lic 文件不存在", originalLocation);
+ }
+ if (licFiles.size() > 1) {
+ throw EasyflowLicenseException.missing("检测到多个 .lic 文件,无法确定应使用哪个文件", originalLocation);
+ }
+
+ Path licFile = licFiles.get(0);
+ return new ResolvedLicenseResource(resourceLoader.getResource(licFile.toUri().toString()), licFile.toUri().toString());
+ } catch (IOException e) {
+ throw EasyflowLicenseException.missing("lic 文件读取失败", originalLocation, e);
+ }
+ }
+
+ private static String requireText(JsonNode node, String field, String location, String licenseId, String keyId) {
+ JsonNode value = node.path(field);
+ if (value.isMissingNode() || value.isNull()) {
+ throw EasyflowLicenseException.invalid(field + " 缺失", location, licenseId, keyId);
+ }
+ return normalizeRequired(value.asText(), field, location, licenseId, keyId);
+ }
+
+ private static JsonNode requireNode(JsonNode node, String field, String location, String licenseId, String keyId) {
+ JsonNode value = node.path(field);
+ if (value.isMissingNode() || value.isNull()) {
+ throw EasyflowLicenseException.invalid(field + " 缺失", location, licenseId, keyId);
+ }
+ return value;
+ }
+
+ private static Instant parseInstant(String value, String field, String location, String licenseId, String keyId) {
+ try {
+ return Instant.parse(value);
+ } catch (Exception e) {
+ throw EasyflowLicenseException.invalid(field + " 时间格式非法", location, licenseId, keyId, e);
+ }
+ }
+
+ private static String normalizeRequired(String value, String field, String location, String licenseId, String keyId) {
+ String normalized = value == null ? "" : value.trim();
+ if (!StringUtils.hasText(normalized)) {
+ if (location != null || licenseId != null || keyId != null) {
+ throw EasyflowLicenseException.invalid(field + " 不能为空", location, licenseId, keyId);
+ }
+ throw new IllegalArgumentException(field + " 不能为空");
+ }
+ return normalized;
+ }
+
+ /**
+ * 已解析的 license 资源。
+ *
+ * @param resource Spring 资源
+ * @param actualLocation 实际使用的位置
+ */
+ private record ResolvedLicenseResource(Resource resource, String actualLocation) {
+ }
+}
diff --git a/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/LicenseCanonicalJsonSerializer.java b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/LicenseCanonicalJsonSerializer.java
new file mode 100644
index 0000000..be332d6
--- /dev/null
+++ b/easyflow-modules/easyflow-module-autoconfig/src/main/java/tech/easyflow/autoconfig/license/LicenseCanonicalJsonSerializer.java
@@ -0,0 +1,81 @@
+package tech.easyflow.autoconfig.license;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * License payload 的 canonical JSON 序列化器。
+ */
+public final class LicenseCanonicalJsonSerializer {
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ private LicenseCanonicalJsonSerializer() {
+ }
+
+ /**
+ * 将 JSON 节点序列化为 canonical JSON 字符串。
+ *
+ * @param node JSON 节点
+ * @return canonical JSON
+ */
+ public static String serialize(JsonNode node) {
+ try {
+ return OBJECT_MAPPER.writeValueAsString(sortNode(node));
+ } catch (JsonProcessingException e) {
+ throw new IllegalArgumentException("无法序列化 canonical JSON", e);
+ }
+ }
+
+ /**
+ * 递归排序 JSON 节点。
+ *
+ * @param node JSON 节点
+ * @return 排序后的对象
+ */
+ static Object sortNode(JsonNode node) {
+ if (node == null || node.isNull()) {
+ return null;
+ }
+ if (node.isObject()) {
+ TreeMap sorted = new TreeMap<>();
+ ObjectNode objectNode = (ObjectNode) node;
+ Iterator> fields = objectNode.fields();
+ while (fields.hasNext()) {
+ Map.Entry entry = fields.next();
+ sorted.put(entry.getKey(), sortNode(entry.getValue()));
+ }
+ return sorted;
+ }
+ if (node.isArray()) {
+ List