集群问题处理

This commit is contained in:
geht
2026-06-09 18:26:31 +08:00
parent 5b8bd2797a
commit de48bd2324
19 changed files with 626 additions and 17 deletions

View File

@@ -12,6 +12,8 @@ rebel.xml
## backend ## backend
**/target **/target
**/logs **/logs
# 开发者本机钉钉 Stream 接收配置(从 application-dev-local.yml.example 复制)
**/application-dev-local.yml
## front ## front
**/*.lock **/*.lock

View File

@@ -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/components/MesXslApprovalTraceDrawer.vue
jeecgboot-vue3/src/views/xslmes/approval/integration/MesXslApprovalTrace.data.ts 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选主单节点建连+存活监控 ----- -- 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/DingTalkStreamProperties.java
jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamLeaderElection.java jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/dingtalk/stream/DingTalkStreamLeaderElection.java

View File

@@ -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<Map<String, Object>> nodeInfo() {
Map<String, Object> 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开发】第三方配置页展示本机节点信息-----------
}

View File

@@ -61,9 +61,15 @@ public class DingApprovalReconcileScheduler {
@Autowired @Autowired
private DingBpmsEventProcessor eventProcessor; private DingBpmsEventProcessor eventProcessor;
@Autowired
private DingTalkStreamNodeConfigService nodeConfigService;
//update-begin---author:GHT ---date:20260609 for【钉钉Stream补偿扫描】漏推回调自动修复----- //update-begin---author:GHT ---date:20260609 for【钉钉Stream补偿扫描】漏推回调自动修复-----
@Scheduled(fixedDelay = SWEEP_INTERVAL_MS) @Scheduled(fixedDelay = SWEEP_INTERVAL_MS)
public void reconcile() { public void reconcile() {
if (!nodeConfigService.isThisNodeReceiver()) {
return;
}
long sweepStart = System.currentTimeMillis(); long sweepStart = System.currentTimeMillis();
Date cutoff = new Date(sweepStart - MIN_AGE_MS); Date cutoff = new Date(sweepStart - MIN_AGE_MS);

View File

@@ -40,7 +40,7 @@ public class DingTalkStreamClient implements SmartLifecycle {
private DingStreamCallbackLogHelper callbackLogHelper; private DingStreamCallbackLogHelper callbackLogHelper;
@Autowired @Autowired
private DingTalkStreamProperties streamProperties; private DingTalkStreamNodeConfigService nodeConfigService;
@Autowired @Autowired
private DingTalkStreamLeaderElection leaderElection; private DingTalkStreamLeaderElection leaderElection;
@@ -69,7 +69,7 @@ public class DingTalkStreamClient implements SmartLifecycle {
public void stop() { public void stop() {
running = false; running = false;
stopStreamClient(); stopStreamClient();
if (streamProperties.isClusterMode()) { if (nodeConfigService.isClusterMode()) {
leaderElection.release(); leaderElection.release();
} }
log.info("{} Stream 客户端已停止", LOG_TAG); log.info("{} Stream 客户端已停止", LOG_TAG);
@@ -85,12 +85,21 @@ public class DingTalkStreamClient implements SmartLifecycle {
//update-begin---author:GHT ---date:20260609 for【钉钉Stream集群】Redis选主+心跳重连生命周期管理----- //update-begin---author:GHT ---date:20260609 for【钉钉Stream集群】Redis选主+心跳重连生命周期管理-----
private void initSdkClient() { 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(); String[] creds = resolveCredentials();
if (creds == null) { if (creds == null) {
return; return;
} }
if (!streamProperties.isClusterMode()) { if (!nodeConfigService.isClusterMode()) {
log.info("{} 单实例模式cluster-mode=false本节点直接建立 Stream 连接", LOG_TAG); log.info("{} 单实例模式cluster-mode=false本节点直接建立 Stream 连接", LOG_TAG);
try { try {
startStreamClient(creds); startStreamClient(creds);
@@ -116,7 +125,7 @@ public class DingTalkStreamClient implements SmartLifecycle {
try { try {
if (streamActive) { if (streamActive) {
if (leaderElection.renew()) { if (leaderElection.renew()) {
sleepQuietly(streamProperties.getLeaderRenewIntervalMs()); sleepQuietly(nodeConfigService.getLeaderRenewIntervalMs());
continue; continue;
} }
log.warn("{} Leader 锁续期失败,断开 Stream 并降级为 Follower instanceId={} currentLeader={}", log.warn("{} Leader 锁续期失败,断开 Stream 并降级为 Follower instanceId={} currentLeader={}",
@@ -124,25 +133,25 @@ public class DingTalkStreamClient implements SmartLifecycle {
stopStreamClient(); stopStreamClient();
streamActive = false; streamActive = false;
leaderElection.release(); leaderElection.release();
sleepQuietly(streamProperties.getFollowerRetryIntervalMs()); sleepQuietly(nodeConfigService.getFollowerRetryIntervalMs());
continue; continue;
} }
if (leaderElection.tryAcquire()) { if (leaderElection.tryAcquire()) {
startStreamClient(creds); startStreamClient(creds);
streamActive = true; streamActive = true;
sleepQuietly(streamProperties.getLeaderRenewIntervalMs()); sleepQuietly(nodeConfigService.getLeaderRenewIntervalMs());
} else { } else {
log.debug("{} 本节点为 Follower等待 Leader 释放锁 instanceId={} currentLeader={}", log.debug("{} 本节点为 Follower等待 Leader 释放锁 instanceId={} currentLeader={}",
LOG_TAG, leaderElection.instanceId(), leaderElection.currentHolder()); LOG_TAG, leaderElection.instanceId(), leaderElection.currentHolder());
sleepQuietly(streamProperties.getFollowerRetryIntervalMs()); sleepQuietly(nodeConfigService.getFollowerRetryIntervalMs());
} }
} catch (Exception e) { } catch (Exception e) {
log.error("{} Stream 集群生命周期异常: {}", LOG_TAG, e.getMessage(), e); log.error("{} Stream 集群生命周期异常: {}", LOG_TAG, e.getMessage(), e);
stopStreamClient(); stopStreamClient();
streamActive = false; streamActive = false;
leaderElection.release(); leaderElection.release();
sleepQuietly(streamProperties.getFollowerRetryIntervalMs()); sleepQuietly(nodeConfigService.getFollowerRetryIntervalMs());
} }
} }

View File

@@ -15,7 +15,7 @@ public class DingTalkStreamHealthMonitor {
private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG; private static final String LOG_TAG = DingTalkStreamSdkRunner.LOG_TAG;
@Autowired @Autowired
private DingTalkStreamProperties properties; private DingTalkStreamNodeConfigService nodeConfigService;
@Autowired @Autowired
private DingTalkStreamLeaderElection leaderElection; private DingTalkStreamLeaderElection leaderElection;
@@ -23,10 +23,15 @@ public class DingTalkStreamHealthMonitor {
//update-begin---author:GHT ---date:20260609 for【钉钉Stream监控】定时输出存活与心跳状态----------- //update-begin---author:GHT ---date:20260609 for【钉钉Stream监控】定时输出存活与心跳状态-----------
@Scheduled(fixedDelayString = "${jeecg.xslmes.dingtalk.stream.health-log-interval-ms:60000}") @Scheduled(fixedDelayString = "${jeecg.xslmes.dingtalk.stream.health-log-interval-ms:60000}")
public void reportHealth() { public void reportHealth() {
if (!nodeConfigService.isThisNodeReceiver()) {
log.debug("{} Stream存活状态 role=DISABLED host={}(本节点未配置为钉钉回调接收机)",
LOG_TAG, nodeConfigService.resolveLocalHostName());
return;
}
DingTalkStreamSdkRunner.ConnectionSnapshot snap = DingTalkStreamSdkRunner.snapshot(); DingTalkStreamSdkRunner.ConnectionSnapshot snap = DingTalkStreamSdkRunner.snapshot();
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
boolean clusterMode = properties.isClusterMode(); boolean clusterMode = nodeConfigService.isClusterMode();
String role; String role;
String leaderHolder; String leaderHolder;
if (!clusterMode) { if (!clusterMode) {
@@ -51,9 +56,9 @@ public class DingTalkStreamHealthMonitor {
if ("LEADER".equals(role) && snap.streamRunning() if ("LEADER".equals(role) && snap.streamRunning()
&& snap.lastEventAtMs() > 0 && snap.lastEventAtMs() > 0
&& (now - snap.lastEventAtMs()) > properties.getIdleWarnSeconds() * 1000L) { && (now - snap.lastEventAtMs()) > nodeConfigService.getIdleWarnSeconds() * 1000L) {
log.warn("{} Stream长时间无推送可能业务空闲或连接异常idleSec={} thresholdSec={}", log.warn("{} Stream长时间无推送可能业务空闲或连接异常idleSec={} thresholdSec={}",
LOG_TAG, lastEventAgoSec, properties.getIdleWarnSeconds()); LOG_TAG, lastEventAgoSec, nodeConfigService.getIdleWarnSeconds());
} }
} }
//update-end---author:GHT ---date:20260609 for【钉钉Stream监控】定时输出存活与心跳状态----------- //update-end---author:GHT ---date:20260609 for【钉钉Stream监控】定时输出存活与心跳状态-----------

View File

@@ -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 兜底)。
* <p>
* 配置来源:第三方应用「钉钉集成」页面保存的 {@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<String> 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<String> ips = new ArrayList<>(yamlProperties.getDesignatedIps());
List<String> 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<String> 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<String> allowedHosts) {
String host = resolveLocalHostName();
for (String allowed : allowedHosts) {
if (allowed.equalsIgnoreCase(host)) {
return true;
}
}
return false;
}
private boolean matchesIp(List<String> allowedIps) {
List<String> localIps = resolveLocalIpAddresses();
for (String allowed : allowedIps) {
for (String localIp : localIps) {
if (allowed.equals(localIp)) {
return true;
}
}
}
return false;
}
/**
* 供第三方配置页展示:本机网络信息与当前是否接收 Stream。
*/
public java.util.Map<String, Object> buildNodeInfoSnapshot() {
ResolvedConfig config = resolveConfig();
java.util.Map<String, Object> 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<String> designatedIps;
private final List<String> designatedHosts;
private final boolean clusterMode;
private ResolvedConfig(boolean receiverEnabled, List<String> designatedIps,
List<String> 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控制接收节点-----------
}

View File

@@ -1,9 +1,14 @@
package org.jeecg.modules.xslmes.dingtalk.stream; package org.jeecg.modules.xslmes.dingtalk.stream;
import lombok.Data; import lombok.Data;
import org.jeecg.common.util.oConvertUtils;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.net.InetAddress;
import java.util.ArrayList;
import java.util.List;
/** /**
* 钉钉 Stream 集群与监控配置。 * 钉钉 Stream 集群与监控配置。
*/ */
@@ -12,6 +17,24 @@ import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "jeecg.xslmes.dingtalk.stream") @ConfigurationProperties(prefix = "jeecg.xslmes.dingtalk.stream")
public class DingTalkStreamProperties { public class DingTalkStreamProperties {
/**
* 本节点是否参与钉钉 Stream 接收(含补偿扫描)。
* 共享 dev 环境建议默认 false开发者在本机 application-dev-local.yml 中设为 true。
*/
private boolean receiverEnabled = true;
/**
* 允许接收钉钉回调的主机名白名单(不区分大小写,可选)。
* 与 designated-ips 二选一或同时配置:满足任一即视为本机接收节点。
*/
private List<String> designatedHosts = new ArrayList<>();
/**
* 允许接收钉钉回调的本机 IP 白名单(推荐,比主机名更稳定)。
* 匹配本机网卡 IPv4/IPv6 地址,与 designated-hosts 满足其一即可。
*/
private List<String> designatedIps = new ArrayList<>();
/** /**
* 集群模式true 时通过 Redis 选主,仅 Leader 节点建立 Stream 长连接。 * 集群模式true 时通过 Redis 选主,仅 Leader 节点建立 Stream 长连接。
* 多实例生产环境务必保持 true单实例可设为 false 简化部署。 * 多实例生产环境务必保持 true单实例可设为 false 简化部署。
@@ -29,4 +52,88 @@ public class DingTalkStreamProperties {
/** 无事件空闲告警阈值Leader 且连接中超过该时间无推送则 warn */ /** 无事件空闲告警阈值Leader 且连接中超过该时间无推送则 warn */
private long idleWarnSeconds = 1800L; 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<String> 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<String> resolveLocalIpAddresses() {
List<String> ips = new ArrayList<>();
try {
java.util.Enumeration<java.net.NetworkInterface> interfaces = java.net.NetworkInterface.getNetworkInterfaces();
while (interfaces != null && interfaces.hasMoreElements()) {
java.net.NetworkInterface ni = interfaces.nextElement();
java.util.Enumeration<InetAddress> 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开发】本机白名单仅指定电脑接收回调-----------
} }

View File

@@ -32,8 +32,12 @@ import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.URL; import java.net.URL;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Enumeration;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;

View File

@@ -72,6 +72,17 @@ public class SysThirdAppConfig {
private Integer streamEnabled; private Integer streamEnabled;
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】Stream事件推送主配置标识----- //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") @Excel(name = "创建日期", width = 20, format = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss")

View File

@@ -1228,6 +1228,29 @@ public class ThirdAppDingtalkServiceImpl implements IThirdAppService {
} }
//update-end---author:GHT ---date:20260604 for【钉钉Stream回调】获取钉钉应用凭证Stream模式专用----- //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<SysThirdAppConfig> all = configMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<SysThirdAppConfig>()
.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绕过租户检查----- //update-begin---author:GHT ---date:20260604 for【钉钉Stream回调】后台线程专用AccessToken绕过租户检查-----
/** /**
* 后台线程专用:获取钉钉 AccessToken不依赖 TenantContext。 * 后台线程专用:获取钉钉 AccessToken不依赖 TenantContext。

View File

@@ -0,0 +1,21 @@
# 复制为 application-dev-local.yml同目录仅本机生效勿提交 Git
# 作用:只有你这台电脑接收钉钉 Stream 回调与补偿扫描,其他共用 dev 库的机器不会抢消息
#
# 1. 复制application-dev-local.yml.example -> application-dev-local.yml
# 2. 查本机 IPPowerShell: 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

View File

@@ -20,6 +20,9 @@ management:
include: metrics,httpexchanges,jeecghttptrace include: metrics,httpexchanges,jeecghttptrace
spring: spring:
config:
# 开发者本机覆盖配置(不提交 Git用于指定仅本机接收钉钉 Stream 回调
import: optional:classpath:application-dev-local.yml
# main: # main:
# # 启动加速 (建议开发环境开启后flyway自动升级失效) # # 启动加速 (建议开发环境开启后flyway自动升级失效)
# lazy-initialization: true # lazy-initialization: true
@@ -214,6 +217,11 @@ jeecg:
xslmes: xslmes:
dingtalk: dingtalk:
stream: stream:
# 共享 dev 默认关闭;仅本机在 application-dev-local.yml 开启(见 application-dev-local.yml.example
receiver-enabled: false
# 白名单二选一或同时配置(满足其一即可);推荐 designated-ips比主机名稳定
designated-hosts: []
designated-ips: []
# 多实例部署务必 trueRedis 选主,仅 Leader 建 Stream 长连接 # 多实例部署务必 trueRedis 选主,仅 Leader 建 Stream 长连接
cluster-mode: true cluster-mode: true
leader-renew-interval-ms: 10000 leader-renew-interval-ms: 10000

View File

@@ -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;

View File

@@ -12,6 +12,7 @@ enum Api {
deleteThirdAccount = '/sys/thirdApp/deleteThirdAccount', deleteThirdAccount = '/sys/thirdApp/deleteThirdAccount',
deleteThirdAppConfig = '/sys/thirdApp/deleteThirdAppConfig', deleteThirdAppConfig = '/sys/thirdApp/deleteThirdAppConfig',
testKingdeeConnect = '/sys/thirdApp/testKingdeeConnect', testKingdeeConnect = '/sys/thirdApp/testKingdeeConnect',
getDingTalkStreamNodeInfo = '/xslmes/dingtalk/stream/nodeInfo',
} }
/** /**
@@ -87,3 +88,10 @@ export const deleteThirdAppConfig = (params, handleSuccess) => {
export const testKingdeeConnect = (params) => { export const testKingdeeConnect = (params) => {
return defHttp.get({ url: Api.testKingdeeConnect, params }, { isTransformResponse: false }); return defHttp.get({ url: Api.testKingdeeConnect, params }, { isTransformResponse: false });
}; };
/**
* 获取本机钉钉 Stream 节点信息主机名、IP、是否接收
*/
export const getDingTalkStreamNodeInfo = () => {
return defHttp.get({ url: Api.getDingTalkStreamNodeInfo });
};

View File

@@ -77,7 +77,57 @@ export const thirdAppFormSchema: FormSchema[] = [
}, },
helpMessage: '开启后,此应用的 AppKey/AppSecret 将用于接收钉钉 Stream 事件推送(审批结果等)。同一企业只需开启一个', helpMessage: '开启后,此应用的 AppKey/AppSecret 将用于接收钉钉 Stream 事件推送(审批结果等)。同一企业只需开启一个',
defaultValue: 0, 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', label: '租户id',
field: 'tenantId', field: 'tenantId',
component: 'Input', component: 'Input',

View File

@@ -1,5 +1,13 @@
<template> <template>
<BasicModal @register="registerModal" :width="800" :title="title" @ok="handleSubmit"> <BasicModal @register="registerModal" :width="860" :title="title" @ok="handleSubmit">
<a-alert
v-if="showStreamNodeHint"
type="info"
show-icon
style="margin-bottom: 12px"
:message="streamNodeHintTitle"
:description="streamNodeHintDesc"
/>
<BasicForm @register="registerForm" /> <BasicForm @register="registerForm" />
</BasicModal> </BasicModal>
</template> </template>
@@ -9,12 +17,15 @@
import { BasicModal, useModalInner } from '/@/components/Modal'; import { BasicModal, useModalInner } from '/@/components/Modal';
import { useForm, BasicForm } from '/@/components/Form'; import { useForm, BasicForm } from '/@/components/Form';
import { thirdAppFormSchema } from './ThirdApp.data'; import { thirdAppFormSchema } from './ThirdApp.data';
import { getThirdConfigByTenantId, saveOrUpdateThirdConfig } from './ThirdApp.api'; import { getThirdConfigByTenantId, saveOrUpdateThirdConfig, getDingTalkStreamNodeInfo } from './ThirdApp.api';
export default defineComponent({ export default defineComponent({
name: 'ThirdAppConfigModal', name: 'ThirdAppConfigModal',
components: { BasicModal, BasicForm }, components: { BasicModal, BasicForm },
setup(props, { emit }) { setup(props, { emit }) {
const title = ref<string>('钉钉配置'); const title = ref<string>('钉钉配置');
const showStreamNodeHint = ref(false);
const streamNodeHintTitle = ref('本机 Stream 节点信息');
const streamNodeHintDesc = ref('');
//表单配置 //表单配置
const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({ const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
schemas: thirdAppFormSchema, schemas: thirdAppFormSchema,
@@ -25,8 +36,23 @@
//表单赋值 //表单赋值
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => { const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
setModalProps({ confirmLoading: true }); setModalProps({ confirmLoading: true });
showStreamNodeHint.value = false;
streamNodeHintDesc.value = '';
if (data.thirdType == 'dingtalk') { if (data.thirdType == 'dingtalk') {
title.value = '钉钉配置'; 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') { } else if (data.thirdType == 'kingdee') {
title.value = '金蝶配置'; title.value = '金蝶配置';
} else { } else {
@@ -60,6 +86,9 @@
return { return {
title, title,
showStreamNodeHint,
streamNodeHintTitle,
streamNodeHintDesc,
registerForm, registerForm,
registerModal, registerModal,
handleSubmit, handleSubmit,

View File

@@ -53,6 +53,49 @@
</a-tag> </a-tag>
</div> </div>
</div> </div>
<template v-if="appConfigData.streamEnabled === 1">
<div class="flex-flow">
<div class="base-title">接收限制</div>
<div class="base-message" style="display:flex;align-items:center;min-height:50px;">
<a-tag :color="appConfigData.streamReceiverEnabled === 1 ? 'orange' : 'default'">
{{ appConfigData.streamReceiverEnabled === 1 ? '仅白名单节点' : '不限制节点' }}
</a-tag>
</div>
</div>
<div class="flex-flow" v-if="appConfigData.streamReceiverEnabled === 1">
<div class="base-title">允许IP</div>
<div class="base-message" style="min-height:50px;line-height:1.6;padding-top:12px;">
{{ appConfigData.streamDesignatedIps || '(未配置,所有节点均可接收)' }}
</div>
</div>
<div class="flex-flow">
<div class="base-title">集群选主</div>
<div class="base-message" style="display:flex;align-items:center;height:50px;">
<a-tag :color="appConfigData.streamClusterMode === 1 ? 'blue' : 'default'">
{{
appConfigData.streamClusterMode === 1
? 'Redis选主已开启'
: appConfigData.streamClusterMode === 0
? '单节点直连'
: '沿用YAML默认'
}}
</a-tag>
</div>
</div>
<div class="flex-flow" v-if="streamNodeInfo.hostName">
<div class="base-title">本机信息</div>
<div class="base-message stream-node-info">
<div>主机名{{ streamNodeInfo.hostName }}</div>
<div>本机IP{{ (streamNodeInfo.localIps || []).join(', ') }}</div>
<div>
本机是否接收
<a-tag :color="streamNodeInfo.thisNodeReceiver ? 'green' : 'default'" style="margin-left:4px">
{{ streamNodeInfo.thisNodeReceiver ? '是' : '否' }}
</a-tag>
</div>
</div>
</div>
</template>
<div style="margin-top: 20px; width: 100%; text-align: right"> <div style="margin-top: 20px; width: 100%; text-align: right">
<a-button @click="dingEditClick">编辑</a-button> <a-button @click="dingEditClick">编辑</a-button>
<a-button v-if="appConfigData.id" @click="cancelBindClick" danger style="margin-left: 10px">取消绑定</a-button> <a-button v-if="appConfigData.id" @click="cancelBindClick" danger style="margin-left: 10px">取消绑定</a-button>
@@ -85,7 +128,12 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, h, inject, onMounted, reactive, ref, watch } from 'vue'; import { defineComponent, h, inject, onMounted, reactive, ref, watch } from 'vue';
import { getThirdConfigByTenantId, syncDingTalkDepartUserToLocal, deleteThirdAppConfig } from './ThirdApp.api'; import {
getThirdConfigByTenantId,
syncDingTalkDepartUserToLocal,
deleteThirdAppConfig,
getDingTalkStreamNodeInfo,
} from './ThirdApp.api';
import { useModal } from '/@/components/Modal'; import { useModal } from '/@/components/Modal';
import ThirdAppConfigModal from './ThirdAppConfigModal.vue'; import ThirdAppConfigModal from './ThirdAppConfigModal.vue';
import { Modal } from 'ant-design-vue'; import { Modal } from 'ant-design-vue';
@@ -109,6 +157,7 @@
clientId: '', clientId: '',
clientSecret: '', clientSecret: '',
}); });
const streamNodeInfo = ref<any>({});
//企业微信钉钉配置modal //企业微信钉钉配置modal
const [registerAppConfigModal, { openModal }] = useModal(); const [registerAppConfigModal, { openModal }] = useModal();
@@ -132,7 +181,12 @@
if (values) { if (values) {
appConfigData.value = values; appConfigData.value = values;
} else { } else {
appConfigData.value = ""; appConfigData.value = '';
}
try {
streamNodeInfo.value = (await getDingTalkStreamNodeInfo()) || {};
} catch (e) {
streamNodeInfo.value = {};
} }
} }
@@ -252,6 +306,7 @@
return { return {
appConfigData, appConfigData,
streamNodeInfo,
collapseActiveKey, collapseActiveKey,
registerAppConfigModal, registerAppConfigModal,
dingEditClick, dingEditClick,
@@ -331,4 +386,10 @@
top: 2px top: 2px
} }
:deep(.ant-collapse-borderless >.ant-collapse-item:last-child) {border-bottom-width:1px;} :deep(.ant-collapse-borderless >.ant-collapse-item:last-child) {border-bottom-width:1px;}
.stream-node-info {
line-height: 1.8;
padding-top: 10px;
font-size: 13px;
color: @text-color-secondary;
}
</style> </style>

View File

@@ -19,9 +19,17 @@
"settings": { "settings": {
"java.compile.nullAnalysis.mode": "automatic", "java.compile.nullAnalysis.mode": "automatic",
"java.import.maven.enabled": true, "java.import.maven.enabled": true,
"java.import.maven.recursive": true,
"java.configuration.updateBuildConfiguration": "automatic", "java.configuration.updateBuildConfiguration": "automatic",
"java.autobuild.enabled": true, "java.autobuild.enabled": true,
"java.import.maven.offline.enabled": false, "java.import.maven.offline.enabled": false,
"java.configuration.runtimes": [
{
"name": "JavaSE-17",
"path": "C:\\Program Files\\Java\\jdk-17",
"default": true
}
],
"java.configuration.maven.notCoveredPluginExecutionSeverity": "ignore", "java.configuration.maven.notCoveredPluginExecutionSeverity": "ignore",
"java.jdt.ls.java.home": "C:\\Program Files\\Java\\jdk-17", "java.jdt.ls.java.home": "C:\\Program Files\\Java\\jdk-17",
"java.jdt.ls.vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx4G -Xms100m -Xlog:disable", "java.jdt.ls.vmargs": "-XX:+UseParallelGC -XX:GCTimeRatio=4 -XX:AdaptiveSizePolicyWeight=90 -Dsun.zip.disableMemoryMapping=true -Xmx4G -Xms100m -Xlog:disable",