diff --git a/jeecg-boot/.gitignore b/jeecg-boot/.gitignore index ddfb170..5f7604f 100644 --- a/jeecg-boot/.gitignore +++ b/jeecg-boot/.gitignore @@ -12,6 +12,8 @@ rebel.xml ## backend **/target **/logs +# 开发者本机钉钉 Stream 接收配置(从 application-dev-local.yml.example 复制) +**/application-dev-local.yml ## front **/*.lock diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 index d229d78..ded251e 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/doc/代码修改日志 @@ -859,6 +859,26 @@ jeecgboot-vue3/src/views/xslmes/approval/integration/components/DingApprovalFore jeecgboot-vue3/src/views/xslmes/approval/integration/components/MesXslApprovalTraceDrawer.vue jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTrace.data.ts +-- author:GHT---date:20260609--for: 【钉钉Stream开发】第三方配置页可视化Stream接收节点 ----- +jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_145__sys_third_app_config_stream_node.sql +jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysThirdAppConfig.java +jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamNodeConfigService.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/DingTalkStreamConfigController.java +jeecgboot-vue3/src/views/system/appconfig/ThirdApp.data.ts +jeecgboot-vue3/src/views/system/appconfig/ThirdApp.api.ts +jeecgboot-vue3/src/views/system/appconfig/ThirdAppConfigModal.vue +jeecgboot-vue3/src/views/system/appconfig/ThirdAppDingTalkConfigForm.vue + +-- author:GHT---date:20260609--for: 【钉钉Stream开发】本机白名单仅指定电脑接收回调 ----- +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingApprovalReconcileScheduler.java +jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamHealthMonitor.java +jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml +jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev-local.yml.example +jeecg-boot/.gitignore + -- author:GHT---date:20260609--for: 【钉钉Stream集群】Redis选主单节点建连+存活监控 ----- jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.java jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamLeaderElection.java diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/DingTalkStreamConfigController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/DingTalkStreamConfigController.java new file mode 100644 index 0000000..fd367eb --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/controller/DingTalkStreamConfigController.java @@ -0,0 +1,39 @@ +package org.jeecg.modules.xslmes.dingtalk.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.jeecg.common.api.vo.Result; +import org.jeecg.modules.xslmes.dingtalk.stream.DingTalkStreamNodeConfigService; +import org.jeecg.modules.xslmes.dingtalk.stream.DingTalkStreamSdkRunner; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 钉钉 Stream 节点配置辅助接口(供「第三方配置-钉钉集成」页面展示本机信息)。 + */ +@Tag(name = "钉钉Stream配置") +@RestController +@RequestMapping("/xslmes/dingtalk/stream") +public class DingTalkStreamConfigController { + + @Autowired + private DingTalkStreamNodeConfigService nodeConfigService; + + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream开发】第三方配置页展示本机节点信息----------- + @Operation(summary = "获取本机 Stream 节点信息") + @GetMapping("/nodeInfo") + public Result> nodeInfo() { + Map data = new LinkedHashMap<>(nodeConfigService.buildNodeInfoSnapshot()); + DingTalkStreamSdkRunner.ConnectionSnapshot snap = DingTalkStreamSdkRunner.snapshot(); + data.put("streamRunning", snap.streamRunning()); + data.put("totalEvents", snap.totalEventCount()); + data.put("reconnectCount", snap.reconnectCount()); + return Result.OK(data); + } + //update-end---author:GHT ---date:20260609 for:【钉钉Stream开发】第三方配置页展示本机节点信息----------- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingApprovalReconcileScheduler.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingApprovalReconcileScheduler.java index 55d4d24..4b5377a 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingApprovalReconcileScheduler.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingApprovalReconcileScheduler.java @@ -61,9 +61,15 @@ public class DingApprovalReconcileScheduler { @Autowired private DingBpmsEventProcessor eventProcessor; + @Autowired + private DingTalkStreamNodeConfigService nodeConfigService; + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream补偿扫描】漏推回调自动修复----- @Scheduled(fixedDelay = SWEEP_INTERVAL_MS) public void reconcile() { + if (!nodeConfigService.isThisNodeReceiver()) { + return; + } long sweepStart = System.currentTimeMillis(); Date cutoff = new Date(sweepStart - MIN_AGE_MS); diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java index e950b76..f1c184a 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamClient.java @@ -40,7 +40,7 @@ public class DingTalkStreamClient implements SmartLifecycle { private DingStreamCallbackLogHelper callbackLogHelper; @Autowired - private DingTalkStreamProperties streamProperties; + private DingTalkStreamNodeConfigService nodeConfigService; @Autowired private DingTalkStreamLeaderElection leaderElection; @@ -69,7 +69,7 @@ public class DingTalkStreamClient implements SmartLifecycle { public void stop() { running = false; stopStreamClient(); - if (streamProperties.isClusterMode()) { + if (nodeConfigService.isClusterMode()) { leaderElection.release(); } log.info("{} Stream 客户端已停止", LOG_TAG); @@ -85,12 +85,21 @@ public class DingTalkStreamClient implements SmartLifecycle { //update-begin---author:GHT ---date:20260609 for:【钉钉Stream集群】Redis选主+心跳重连生命周期管理----- private void initSdkClient() { + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream开发】非接收节点跳过建连----------- + if (!nodeConfigService.isThisNodeReceiver()) { + log.info("{} 本节点未启用钉钉 Stream 接收 host={} localIps={}," + + "请在【系统管理-第三方配置-钉钉集成】配置 Stream 接收节点白名单", + LOG_TAG, nodeConfigService.resolveLocalHostName(), nodeConfigService.resolveLocalIpAddresses()); + return; + } + //update-end---author:GHT ---date:20260609 for:【钉钉Stream开发】非接收节点跳过建连----------- + String[] creds = resolveCredentials(); if (creds == null) { return; } - if (!streamProperties.isClusterMode()) { + if (!nodeConfigService.isClusterMode()) { log.info("{} 单实例模式(cluster-mode=false),本节点直接建立 Stream 连接", LOG_TAG); try { startStreamClient(creds); @@ -116,7 +125,7 @@ public class DingTalkStreamClient implements SmartLifecycle { try { if (streamActive) { if (leaderElection.renew()) { - sleepQuietly(streamProperties.getLeaderRenewIntervalMs()); + sleepQuietly(nodeConfigService.getLeaderRenewIntervalMs()); continue; } log.warn("{} Leader 锁续期失败,断开 Stream 并降级为 Follower instanceId={} currentLeader={}", @@ -124,25 +133,25 @@ public class DingTalkStreamClient implements SmartLifecycle { stopStreamClient(); streamActive = false; leaderElection.release(); - sleepQuietly(streamProperties.getFollowerRetryIntervalMs()); + sleepQuietly(nodeConfigService.getFollowerRetryIntervalMs()); continue; } if (leaderElection.tryAcquire()) { startStreamClient(creds); streamActive = true; - sleepQuietly(streamProperties.getLeaderRenewIntervalMs()); + sleepQuietly(nodeConfigService.getLeaderRenewIntervalMs()); } else { log.debug("{} 本节点为 Follower,等待 Leader 释放锁 instanceId={} currentLeader={}", LOG_TAG, leaderElection.instanceId(), leaderElection.currentHolder()); - sleepQuietly(streamProperties.getFollowerRetryIntervalMs()); + sleepQuietly(nodeConfigService.getFollowerRetryIntervalMs()); } } catch (Exception e) { log.error("{} Stream 集群生命周期异常: {}", LOG_TAG, e.getMessage(), e); stopStreamClient(); streamActive = false; leaderElection.release(); - sleepQuietly(streamProperties.getFollowerRetryIntervalMs()); + sleepQuietly(nodeConfigService.getFollowerRetryIntervalMs()); } } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamHealthMonitor.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamHealthMonitor.java index 97632f6..5fbc65d 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamHealthMonitor.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamHealthMonitor.java @@ -15,7 +15,7 @@ public class DingTalkStreamHealthMonitor { private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG; @Autowired - private DingTalkStreamProperties properties; + private DingTalkStreamNodeConfigService nodeConfigService; @Autowired private DingTalkStreamLeaderElection leaderElection; @@ -23,10 +23,15 @@ public class DingTalkStreamHealthMonitor { //update-begin---author:GHT ---date:20260609 for:【钉钉Stream监控】定时输出存活与心跳状态----------- @Scheduled(fixedDelayString = "${jeecg.xslmes.dingtalk.stream.health-log-interval-ms:60000}") public void reportHealth() { + if (!nodeConfigService.isThisNodeReceiver()) { + log.debug("{} Stream存活状态 role=DISABLED host={}(本节点未配置为钉钉回调接收机)", + LOG_TAG, nodeConfigService.resolveLocalHostName()); + return; + } DingTalkStreamSdkRunner.ConnectionSnapshot snap = DingTalkStreamSdkRunner.snapshot(); long now = System.currentTimeMillis(); - boolean clusterMode = properties.isClusterMode(); + boolean clusterMode = nodeConfigService.isClusterMode(); String role; String leaderHolder; if (!clusterMode) { @@ -51,9 +56,9 @@ public class DingTalkStreamHealthMonitor { if ("LEADER".equals(role) && snap.streamRunning() && snap.lastEventAtMs() > 0 - && (now - snap.lastEventAtMs()) > properties.getIdleWarnSeconds() * 1000L) { + && (now - snap.lastEventAtMs()) > nodeConfigService.getIdleWarnSeconds() * 1000L) { log.warn("{} Stream长时间无推送(可能业务空闲或连接异常)idleSec={} thresholdSec={}", - LOG_TAG, lastEventAgoSec, properties.getIdleWarnSeconds()); + LOG_TAG, lastEventAgoSec, nodeConfigService.getIdleWarnSeconds()); } } //update-end---author:GHT ---date:20260609 for:【钉钉Stream监控】定时输出存活与心跳状态----------- diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamNodeConfigService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamNodeConfigService.java new file mode 100644 index 0000000..7b25912 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamNodeConfigService.java @@ -0,0 +1,192 @@ +package org.jeecg.modules.xslmes.dingtalk.stream; + +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.system.entity.SysThirdAppConfig; +import org.jeecg.modules.system.service.impl.ThirdAppDingtalkServiceImpl; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 钉钉 Stream 接收节点运行时配置(DB 优先,YAML 兜底)。 + *

+ * 配置来源:第三方应用「钉钉集成」页面保存的 {@link SysThirdAppConfig}(stream_enabled=1 的记录)。 + */ +@Slf4j +@Service +public class DingTalkStreamNodeConfigService { + + private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG; + + @Autowired + private ThirdAppDingtalkServiceImpl dingtalkService; + + @Autowired + private DingTalkStreamProperties yamlProperties; + + private volatile ResolvedConfig cachedConfig; + private volatile long cachedAtMs; + + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream开发】DB配置优先于YAML控制接收节点----------- + public boolean isThisNodeReceiver() { + ResolvedConfig config = resolveConfig(); + if (!config.receiverEnabled) { + return false; + } + boolean hasHostRule = !config.designatedHosts.isEmpty(); + boolean hasIpRule = !config.designatedIps.isEmpty(); + if (!hasHostRule && !hasIpRule) { + return true; + } + if (hasHostRule && matchesHost(config.designatedHosts)) { + return true; + } + return hasIpRule && matchesIp(config.designatedIps); + } + + public boolean isClusterMode() { + return resolveConfig().clusterMode; + } + + public long getLeaderRenewIntervalMs() { + return yamlProperties.getLeaderRenewIntervalMs(); + } + + public long getFollowerRetryIntervalMs() { + return yamlProperties.getFollowerRetryIntervalMs(); + } + + public long getIdleWarnSeconds() { + return yamlProperties.getIdleWarnSeconds(); + } + + public String resolveLocalHostName() { + return yamlProperties.resolveLocalHostName(); + } + + public List resolveLocalIpAddresses() { + return yamlProperties.resolveLocalIpAddresses(); + } + + /** 配置变更后由定时任务自动刷新;保存第三方配置后最多 30 秒内生效 */ + @Scheduled(fixedDelay = 30_000L) + public void refreshCache() { + cachedConfig = null; + } + + private ResolvedConfig resolveConfig() { + long now = System.currentTimeMillis(); + if (cachedConfig != null && now - cachedAtMs < 25_000L) { + return cachedConfig; + } + synchronized (this) { + if (cachedConfig != null && now - cachedAtMs < 25_000L) { + return cachedConfig; + } + cachedConfig = loadFromDbAndYaml(); + cachedAtMs = System.currentTimeMillis(); + return cachedConfig; + } + } + + private ResolvedConfig loadFromDbAndYaml() { + SysThirdAppConfig db = dingtalkService.getStreamMasterConfig(); + boolean receiverEnabled = yamlProperties.isReceiverEnabled(); + List ips = new ArrayList<>(yamlProperties.getDesignatedIps()); + List hosts = new ArrayList<>(yamlProperties.getDesignatedHosts()); + boolean clusterMode = yamlProperties.isClusterMode(); + String source = "YAML"; + + if (db != null) { + if (db.getStreamReceiverEnabled() != null) { + receiverEnabled = db.getStreamReceiverEnabled() == 1; + } + // null 表示未在页面配置,沿用 YAML;空字符串表示明确清空白名单 + if (db.getStreamDesignatedIps() != null) { + ips = splitCsv(db.getStreamDesignatedIps()); + } + if (db.getStreamDesignatedHosts() != null) { + hosts = splitCsv(db.getStreamDesignatedHosts()); + } + if (db.getStreamClusterMode() != null) { + clusterMode = db.getStreamClusterMode() == 1; + } + source = "DB"; + } + + log.debug("{} Stream节点配置已加载 source={} receiverEnabled={} ips={} hosts={} clusterMode={}", + LOG_TAG, source, receiverEnabled, ips, hosts, clusterMode); + return new ResolvedConfig(receiverEnabled, ips, hosts, clusterMode); + } + + private List splitCsv(String raw) { + if (oConvertUtils.isEmpty(raw)) { + return Collections.emptyList(); + } + return Arrays.stream(raw.split("[,;\\s]+")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + + private boolean matchesHost(List allowedHosts) { + String host = resolveLocalHostName(); + for (String allowed : allowedHosts) { + if (allowed.equalsIgnoreCase(host)) { + return true; + } + } + return false; + } + + private boolean matchesIp(List allowedIps) { + List localIps = resolveLocalIpAddresses(); + for (String allowed : allowedIps) { + for (String localIp : localIps) { + if (allowed.equals(localIp)) { + return true; + } + } + } + return false; + } + + /** + * 供第三方配置页展示:本机网络信息与当前是否接收 Stream。 + */ + public java.util.Map buildNodeInfoSnapshot() { + ResolvedConfig config = resolveConfig(); + java.util.Map map = new java.util.LinkedHashMap<>(); + map.put("hostName", resolveLocalHostName()); + map.put("localIps", resolveLocalIpAddresses()); + map.put("thisNodeReceiver", isThisNodeReceiver()); + map.put("receiverEnabled", config.receiverEnabled); + map.put("designatedIps", config.designatedIps); + map.put("designatedHosts", config.designatedHosts); + map.put("clusterMode", config.clusterMode); + return map; + } + + private static final class ResolvedConfig { + private final boolean receiverEnabled; + private final List designatedIps; + private final List designatedHosts; + private final boolean clusterMode; + + private ResolvedConfig(boolean receiverEnabled, List designatedIps, + List designatedHosts, boolean clusterMode) { + this.receiverEnabled = receiverEnabled; + this.designatedIps = designatedIps != null ? designatedIps : Collections.emptyList(); + this.designatedHosts = designatedHosts != null ? designatedHosts : Collections.emptyList(); + this.clusterMode = clusterMode; + } + } + //update-end---author:GHT ---date:20260609 for:【钉钉Stream开发】DB配置优先于YAML控制接收节点----------- +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.java index 5b2450b..783c596 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamProperties.java @@ -1,9 +1,14 @@ package org.jeecg.modules.xslmes.dingtalk.stream; import lombok.Data; +import org.jeecg.common.util.oConvertUtils; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; + /** * 钉钉 Stream 集群与监控配置。 */ @@ -12,6 +17,24 @@ import org.springframework.stereotype.Component; @ConfigurationProperties(prefix = "jeecg.xslmes.dingtalk.stream") public class DingTalkStreamProperties { + /** + * 本节点是否参与钉钉 Stream 接收(含补偿扫描)。 + * 共享 dev 环境建议默认 false,开发者在本机 application-dev-local.yml 中设为 true。 + */ + private boolean receiverEnabled = true; + + /** + * 允许接收钉钉回调的主机名白名单(不区分大小写,可选)。 + * 与 designated-ips 二选一或同时配置:满足任一即视为本机接收节点。 + */ + private List designatedHosts = new ArrayList<>(); + + /** + * 允许接收钉钉回调的本机 IP 白名单(推荐,比主机名更稳定)。 + * 匹配本机网卡 IPv4/IPv6 地址,与 designated-hosts 满足其一即可。 + */ + private List designatedIps = new ArrayList<>(); + /** * 集群模式:true 时通过 Redis 选主,仅 Leader 节点建立 Stream 长连接。 * 多实例生产环境务必保持 true;单实例可设为 false 简化部署。 @@ -29,4 +52,88 @@ public class DingTalkStreamProperties { /** 无事件空闲告警阈值(秒),Leader 且连接中超过该时间无推送则 warn */ private long idleWarnSeconds = 1800L; + + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream开发】本机白名单仅指定电脑接收回调----------- + /** + * 当前节点是否应接收钉钉 Stream 推送并执行补偿扫描。 + */ + public boolean isThisNodeReceiver() { + if (!receiverEnabled) { + return false; + } + boolean hasHostRule = designatedHosts != null && !designatedHosts.isEmpty(); + boolean hasIpRule = designatedIps != null && !designatedIps.isEmpty(); + if (!hasHostRule && !hasIpRule) { + return true; + } + if (hasHostRule && matchesDesignatedHost()) { + return true; + } + return hasIpRule && matchesDesignatedIp(); + } + + private boolean matchesDesignatedHost() { + String host = resolveLocalHostName(); + for (String allowed : designatedHosts) { + if (oConvertUtils.isNotEmpty(allowed) && allowed.equalsIgnoreCase(host)) { + return true; + } + } + return false; + } + + private boolean matchesDesignatedIp() { + List localIps = resolveLocalIpAddresses(); + for (String allowed : designatedIps) { + if (oConvertUtils.isEmpty(allowed)) { + continue; + } + for (String localIp : localIps) { + if (allowed.equals(localIp)) { + return true; + } + } + } + return false; + } + + public String resolveLocalHostName() { + try { + return InetAddress.getLocalHost().getHostName(); + } catch (Exception e) { + return "unknown-host"; + } + } + + /** + * 采集本机所有网卡 IP(含 127.0.0.1),供白名单匹配与启动日志展示。 + */ + public List resolveLocalIpAddresses() { + List ips = new ArrayList<>(); + try { + java.util.Enumeration interfaces = java.net.NetworkInterface.getNetworkInterfaces(); + while (interfaces != null && interfaces.hasMoreElements()) { + java.net.NetworkInterface ni = interfaces.nextElement(); + java.util.Enumeration addresses = ni.getInetAddresses(); + while (addresses.hasMoreElements()) { + String ip = addresses.nextElement().getHostAddress(); + if (oConvertUtils.isNotEmpty(ip) && !ips.contains(ip)) { + ips.add(ip); + } + } + } + } catch (Exception ignored) { + // 回退到 LocalHost + } + try { + String localIp = InetAddress.getLocalHost().getHostAddress(); + if (oConvertUtils.isNotEmpty(localIp) && !ips.contains(localIp)) { + ips.add(localIp); + } + } catch (Exception ignored) { + // 忽略 + } + return ips; + } + //update-end---author:GHT ---date:20260609 for:【钉钉Stream开发】本机白名单仅指定电脑接收回调----------- } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/ThirdAppController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/ThirdAppController.java index b3017e0..dda0f4c 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/ThirdAppController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/ThirdAppController.java @@ -32,8 +32,12 @@ import org.springframework.web.bind.annotation.*; import jakarta.servlet.http.HttpServletRequest; import java.net.HttpURLConnection; +import java.net.InetAddress; +import java.net.NetworkInterface; import java.net.URL; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Map; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysThirdAppConfig.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysThirdAppConfig.java index 2e24cb0..da1a9b8 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysThirdAppConfig.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysThirdAppConfig.java @@ -72,6 +72,17 @@ public class SysThirdAppConfig { private Integer streamEnabled; //update-end---author:GHT ---date:20260604 for:【钉钉Stream回调】Stream事件推送主配置标识----- + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream开发】第三方配置页Stream接收节点可视化----------- + @Schema(description = "是否限制仅指定节点接收Stream(0-否,1-是)") + private Integer streamReceiverEnabled; + @Schema(description = "允许接收Stream的IP白名单,逗号分隔") + private String streamDesignatedIps; + @Schema(description = "允许接收Stream的主机名白名单,逗号分隔") + private String streamDesignatedHosts; + @Schema(description = "Stream集群Redis选主(0-否,1-是)") + private Integer streamClusterMode; + //update-end---author:GHT ---date:20260609 for:【钉钉Stream开发】第三方配置页Stream接收节点可视化----------- + /**创建日期*/ @Excel(name = "创建日期", width = 20, format = "yyyy-MM-dd HH:mm:ss") @JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss") diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java index c4f3df6..19e2d61 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java @@ -1228,6 +1228,29 @@ public class ThirdAppDingtalkServiceImpl implements IThirdAppService { } //update-end---author:GHT ---date:20260604 for:【钉钉Stream回调】获取钉钉应用凭证(Stream模式专用)----- + //update-begin---author:GHT ---date:20260609 for:【钉钉Stream开发】获取Stream主配置(含节点白名单)----------- + /** + * 获取 Stream 主配置记录(stream_enabled=1 优先)。 + */ + public SysThirdAppConfig getStreamMasterConfig() { + java.util.List all = configMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(SysThirdAppConfig::getThirdType, THIRD_TYPE) + .eq(SysThirdAppConfig::getStatus, 1) + .orderByDesc(SysThirdAppConfig::getStreamEnabled) + .orderByDesc(SysThirdAppConfig::getTenantId)); + if (all == null || all.isEmpty()) { + return null; + } + for (SysThirdAppConfig c : all) { + if (c.getStreamEnabled() != null && c.getStreamEnabled() == 1) { + return c; + } + } + return all.get(0); + } + //update-end---author:GHT ---date:20260609 for:【钉钉Stream开发】获取Stream主配置(含节点白名单)----------- + //update-begin---author:GHT ---date:20260604 for:【钉钉Stream回调】后台线程专用AccessToken(绕过租户检查)----- /** * 后台线程专用:获取钉钉 AccessToken,不依赖 TenantContext。 diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev-local.yml.example b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev-local.yml.example new file mode 100644 index 0000000..3ca457b --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev-local.yml.example @@ -0,0 +1,21 @@ +# 复制为 application-dev-local.yml(同目录),仅本机生效,勿提交 Git +# 作用:只有你这台电脑接收钉钉 Stream 回调与补偿扫描,其他共用 dev 库的机器不会抢消息 +# +# 1. 复制:application-dev-local.yml.example -> application-dev-local.yml +# 2. 查本机 IP(PowerShell: ipconfig,看 IPv4 地址) +# 3. 将 designated-ips 改成你的 IP(推荐,比主机名稳定) +# 4. 重启 JeecgBoot +# +# 启动后若未匹配,日志会打印 localIps=... 便于核对 + +jeecg: + xslmes: + dingtalk: + stream: + receiver-enabled: true + # 推荐:仅 IP 白名单(二选一或同时配置,满足其一即可) + designated-ips: + - 192.168.1.100 + # 可选:主机名白名单(hostname 命令查看) + # designated-hosts: + # - LAPTOP-9LEM1NNJ diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml index 6eafae9..ee936a3 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml @@ -20,6 +20,9 @@ management: include: metrics,httpexchanges,jeecghttptrace spring: + config: + # 开发者本机覆盖配置(不提交 Git),用于指定仅本机接收钉钉 Stream 回调 + import: optional:classpath:application-dev-local.yml # main: # # 启动加速 (建议开发环境,开启后flyway自动升级失效) # lazy-initialization: true @@ -214,6 +217,11 @@ jeecg: xslmes: dingtalk: stream: + # 共享 dev 默认关闭;仅本机在 application-dev-local.yml 开启(见 application-dev-local.yml.example) + receiver-enabled: false + # 白名单二选一或同时配置(满足其一即可);推荐 designated-ips,比主机名稳定 + designated-hosts: [] + designated-ips: [] # 多实例部署务必 true:Redis 选主,仅 Leader 建 Stream 长连接 cluster-mode: true leader-renew-interval-ms: 10000 diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_145__sys_third_app_config_stream_node.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_145__sys_third_app_config_stream_node.sql new file mode 100644 index 0000000..0ec3f4c --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_145__sys_third_app_config_stream_node.sql @@ -0,0 +1,6 @@ +-- GHT 20260609 【钉钉Stream开发】第三方配置页可视化:Stream 接收节点白名单 +ALTER TABLE sys_third_app_config + ADD COLUMN stream_receiver_enabled TINYINT(1) NULL DEFAULT NULL COMMENT '是否限制仅指定节点接收Stream(0-否,1-是,NULL-沿用YAML)' AFTER stream_enabled, + ADD COLUMN stream_designated_ips VARCHAR(1000) NULL DEFAULT NULL COMMENT '允许接收Stream的IP白名单(逗号分隔)' AFTER stream_receiver_enabled, + ADD COLUMN stream_designated_hosts VARCHAR(500) NULL DEFAULT NULL COMMENT '允许接收Stream的主机名白名单(逗号分隔)' AFTER stream_designated_ips, + ADD COLUMN stream_cluster_mode TINYINT(1) NULL DEFAULT NULL COMMENT 'Stream集群Redis选主(0-否,1-是,NULL-沿用YAML)' AFTER stream_designated_hosts; diff --git a/jeecgboot-vue3/src/views/system/appconfig/ThirdApp.api.ts b/jeecgboot-vue3/src/views/system/appconfig/ThirdApp.api.ts index c79f9b4..c035233 100644 --- a/jeecgboot-vue3/src/views/system/appconfig/ThirdApp.api.ts +++ b/jeecgboot-vue3/src/views/system/appconfig/ThirdApp.api.ts @@ -12,6 +12,7 @@ enum Api { deleteThirdAccount = '/sys/thirdApp/deleteThirdAccount', deleteThirdAppConfig = '/sys/thirdApp/deleteThirdAppConfig', testKingdeeConnect = '/sys/thirdApp/testKingdeeConnect', + getDingTalkStreamNodeInfo = '/xslmes/dingtalk/stream/nodeInfo', } /** @@ -86,4 +87,11 @@ export const deleteThirdAppConfig = (params, handleSuccess) => { */ export const testKingdeeConnect = (params) => { return defHttp.get({ url: Api.testKingdeeConnect, params }, { isTransformResponse: false }); +}; + +/** + * 获取本机钉钉 Stream 节点信息(主机名、IP、是否接收) + */ +export const getDingTalkStreamNodeInfo = () => { + return defHttp.get({ url: Api.getDingTalkStreamNodeInfo }); }; \ No newline at end of file diff --git a/jeecgboot-vue3/src/views/system/appconfig/ThirdApp.data.ts b/jeecgboot-vue3/src/views/system/appconfig/ThirdApp.data.ts index 731ddb6..42fb46a 100644 --- a/jeecgboot-vue3/src/views/system/appconfig/ThirdApp.data.ts +++ b/jeecgboot-vue3/src/views/system/appconfig/ThirdApp.data.ts @@ -77,7 +77,57 @@ export const thirdAppFormSchema: FormSchema[] = [ }, helpMessage: '开启后,此应用的 AppKey/AppSecret 将用于接收钉钉 Stream 事件推送(审批结果等)。同一企业只需开启一个', defaultValue: 0, - },{ + }, + { + label: '限制接收节点', + field: 'streamReceiverEnabled', + component: 'Switch', + ifShow: ({ values }) => values.thirdType === 'dingtalk' && values.streamEnabled === 1, + componentProps: { + checkedChildren: '已限制', + checkedValue: 1, + unCheckedChildren: '不限制', + unCheckedValue: 0, + }, + helpMessage: '开启后,仅下方白名单内的服务器会建立 Stream 连接并处理审批回调(推荐用 IP)', + defaultValue: 0, + }, + { + label: '允许接收的IP', + field: 'streamDesignatedIps', + component: 'InputTextArea', + ifShow: ({ values }) => + values.thirdType === 'dingtalk' && values.streamEnabled === 1 && values.streamReceiverEnabled === 1, + componentProps: { + rows: 2, + placeholder: '多个 IP 用逗号分隔,例如 192.168.1.74(可参考页面展示的本机 IP)', + }, + }, + { + label: '允许接收的主机名', + field: 'streamDesignatedHosts', + component: 'Input', + ifShow: ({ values }) => + values.thirdType === 'dingtalk' && values.streamEnabled === 1 && values.streamReceiverEnabled === 1, + componentProps: { + placeholder: '可选,多个用逗号分隔(不如 IP 稳定)', + }, + }, + { + label: '集群Redis选主', + field: 'streamClusterMode', + component: 'Switch', + ifShow: ({ values }) => values.thirdType === 'dingtalk' && values.streamEnabled === 1, + componentProps: { + checkedChildren: '开启', + checkedValue: 1, + unCheckedChildren: '关闭', + unCheckedValue: 0, + }, + helpMessage: '多实例共用同一 AppKey 时务必开启,避免多台服务器同时建连抢消息', + defaultValue: 1, + }, + { label: '租户id', field: 'tenantId', component: 'Input', diff --git a/jeecgboot-vue3/src/views/system/appconfig/ThirdAppConfigModal.vue b/jeecgboot-vue3/src/views/system/appconfig/ThirdAppConfigModal.vue index e0137b0..ea23c41 100644 --- a/jeecgboot-vue3/src/views/system/appconfig/ThirdAppConfigModal.vue +++ b/jeecgboot-vue3/src/views/system/appconfig/ThirdAppConfigModal.vue @@ -1,5 +1,13 @@ @@ -9,12 +17,15 @@ import { BasicModal, useModalInner } from '/@/components/Modal'; import { useForm, BasicForm } from '/@/components/Form'; import { thirdAppFormSchema } from './ThirdApp.data'; - import { getThirdConfigByTenantId, saveOrUpdateThirdConfig } from './ThirdApp.api'; + import { getThirdConfigByTenantId, saveOrUpdateThirdConfig, getDingTalkStreamNodeInfo } from './ThirdApp.api'; export default defineComponent({ name: 'ThirdAppConfigModal', components: { BasicModal, BasicForm }, setup(props, { emit }) { const title = ref('钉钉配置'); + const showStreamNodeHint = ref(false); + const streamNodeHintTitle = ref('本机 Stream 节点信息'); + const streamNodeHintDesc = ref(''); //表单配置 const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({ schemas: thirdAppFormSchema, @@ -25,8 +36,23 @@ //表单赋值 const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => { setModalProps({ confirmLoading: true }); + showStreamNodeHint.value = false; + streamNodeHintDesc.value = ''; if (data.thirdType == 'dingtalk') { title.value = '钉钉配置'; + try { + const nodeInfo = await getDingTalkStreamNodeInfo(); + if (nodeInfo) { + showStreamNodeHint.value = true; + const ips = Array.isArray(nodeInfo.localIps) ? nodeInfo.localIps.join(', ') : ''; + const receiverText = nodeInfo.thisNodeReceiver ? '是(本机将接收回调)' : '否(本机不接收回调)'; + streamNodeHintDesc.value = + `主机名:${nodeInfo.hostName || '-'}\n本机IP:${ips || '-'}\n当前是否接收:${receiverText}\n` + + `提示:开启「限制接收节点」后,请将本机局域网 IP 填入「允许接收的IP」`; + } + } catch (e) { + // 接口不可用时忽略 + } } else if (data.thirdType == 'kingdee') { title.value = '金蝶配置'; } else { @@ -60,6 +86,9 @@ return { title, + showStreamNodeHint, + streamNodeHintTitle, + streamNodeHintDesc, registerForm, registerModal, handleSubmit, diff --git a/jeecgboot-vue3/src/views/system/appconfig/ThirdAppDingTalkConfigForm.vue b/jeecgboot-vue3/src/views/system/appconfig/ThirdAppDingTalkConfigForm.vue index 11e1da6..1699adb 100644 --- a/jeecgboot-vue3/src/views/system/appconfig/ThirdAppDingTalkConfigForm.vue +++ b/jeecgboot-vue3/src/views/system/appconfig/ThirdAppDingTalkConfigForm.vue @@ -53,6 +53,49 @@ +

编辑 取消绑定 @@ -85,7 +128,12 @@