更新项目配置,新增设备同步模块,优化WebSocket和Swagger配置,增强SCADA系统的免登录接口,支持数据字典项和登录日志的免登录查询与记录。调整Java编译设置,确保更好的开发体验。

This commit is contained in:
geht
2026-04-28 10:23:58 +08:00
parent bbe46dcf2d
commit 142a0bdaba
1013 changed files with 41858 additions and 28 deletions

View File

@@ -48,7 +48,10 @@ public class Swagger3Config implements WebMvcConfigurer {
"/sys/cas/client/validateLogin",
"/test/jeecgDemo/demo3",
"/sys/thirdLogin/**",
"/sys/user/register"
"/sys/user/register",
"/sys/user/scada/queryUser",
"/sys/dict/scada/queryDictItem",
"/sys/log/scada/addLoginLog"
));
// 预处理通配符模式,提高匹配效率
private static final Set<String> wildcardPatterns = new HashSet<>();
@@ -79,7 +82,18 @@ public class Swagger3Config implements WebMvcConfigurer {
@Bean
public GlobalOpenApiMethodFilter globalOpenApiMethodFilter() {
return method -> method.isAnnotationPresent(Operation.class);
return method -> {
if (method.isAnnotationPresent(Operation.class)) {
return true;
}
RequestMapping classMapping = method.getDeclaringClass().getAnnotation(RequestMapping.class);
if (classMapping != null && classMapping.value().length > 0) {
String classPath = classMapping.value()[0];
// 兼容系统用户管理接口,未加 @Operation 的接口也展示在文档中
return classPath.startsWith("/sys/user");
}
return false;
};
}
@Bean
@@ -89,6 +103,8 @@ public class Swagger3Config implements WebMvcConfigurer {
if (!isExcludedPath(path)) {
operation.addSecurityItem(new SecurityRequirement().addList(CommonConstant.X_ACCESS_TOKEN));
}else{
// 对于免登录接口,显式清空 security覆盖 OpenAPI 全局安全要求
operation.setSecurity(new java.util.ArrayList<>());
log.info("忽略加入 X_ACCESS_TOKEN 的 PATH:" + path);
}
return operation;

View File

@@ -31,7 +31,7 @@ public class WebSocketConfig {
FilterRegistrationBean bean = new FilterRegistrationBean();
bean.setFilter(websocketFilter());
//TODO 临时注释掉测试下线上socket总断的问题
bean.addUrlPatterns("/taskCountSocket/*", "/websocket/*","/eoaSocket/*","/eoaNewChatSocket/*", "/newsWebsocket/*", "/dragChannelSocket/*", "/vxeSocket/*");
bean.addUrlPatterns("/taskCountSocket/*", "/websocket/*","/eoaSocket/*","/eoaNewChatSocket/*", "/newsWebsocket/*", "/dragChannelSocket/*", "/vxeSocket/*", "/ws/*");
return bean;
}

View File

@@ -35,9 +35,15 @@ public class WebsocketFilter implements Filter {
redisUtil = SpringContextUtils.getBean(RedisUtil.class);
}
HttpServletRequest request = (HttpServletRequest)servletRequest;
String requestUri = request.getRequestURI();
// SCADA桌面端固定通道允许免token接入保障工控机实时推送可用
if (isScadaAnonymousEndpoint(requestUri)) {
filterChain.doFilter(servletRequest, servletResponse);
return;
}
String token = request.getHeader(TOKEN_KEY);
log.debug("Websocket连接 Token安全校验Path = {}token:{}", request.getRequestURI(), token);
log.debug("Websocket连接 Token安全校验Path = {}token:{}", requestUri, token);
try {
TokenUtils.verifyToken(token, commonApi, redisUtil);
@@ -51,4 +57,16 @@ public class WebsocketFilter implements Filter {
filterChain.doFilter(servletRequest, servletResponse);
}
private boolean isScadaAnonymousEndpoint(String requestUri) {
if (oConvertUtils.isEmpty(requestUri)) {
return false;
}
String uri = requestUri.toLowerCase();
// 设备同步 STOMP 原生握手(与 jeecg-module-device-sync 的 /ws/device 一致)
if (uri.contains("/ws/device")) {
return true;
}
return uri.endsWith("/websocket/scada-sync");
}
}

View File

@@ -172,6 +172,8 @@ public class ShiroConfig {
//websocket排除
filterChainDefinitionMap.put("/websocket/**", "anon");//系统通知和公告
// 设备同步模块 STOMP工控机统一通道与桌面端 /ws/device 对接)
filterChainDefinitionMap.put("/ws/**", "anon");
filterChainDefinitionMap.put("/newsWebsocket/**", "anon");//CMS模块
filterChainDefinitionMap.put("/vxeSocket/**", "anon");//JVxeTable无痕刷新示例
//App vue3版本查询版本接口

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-boot-parent</artifactId>
<version>3.9.1</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>jeecg-module-device-sync</artifactId>
<name>jeecg-module-device-sync</name>
<description>设备断联续传同步模块</description>
<dependencies>
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-boot-base-core</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,28 @@
package org.jeecg.modules.device.sync.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* 设备同步 WebSocket STOMP 配置。
*/
@Configuration("deviceSyncWebSocketConfig")
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic", "/queue");
registry.setApplicationDestinationPrefixes("/app");
registry.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws/device")
.setAllowedOriginPatterns("*");
}
}

View File

@@ -0,0 +1,80 @@
package org.jeecg.modules.device.sync.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.device.sync.entity.DeviceStatus;
import org.jeecg.modules.device.sync.mapper.DeviceStatusMapper;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
/**
* 设备 WebSocket 控制器。
*/
@Slf4j
@RestController
@RequestMapping("/sys/device")
@RequiredArgsConstructor
public class DeviceWebSocketController {
private final DeviceStatusMapper deviceStatusMapper;
private final SimpMessagingTemplate messagingTemplate;
@MessageMapping("/device/status")
public void receiveDeviceStatus(DeviceStatus payload) {
if (payload == null || payload.getDeviceId() == null || payload.getDeviceId().isBlank()) {
log.warn("设备状态上报参数无效");
return;
}
Date now = new Date();
payload.setLastSeenTime(now);
payload.setUpdateTime(now);
if (payload.getCreatedTime() == null) {
payload.setCreatedTime(now);
}
DeviceStatus exists = deviceStatusMapper.selectOne(new LambdaQueryWrapper<DeviceStatus>()
.eq(DeviceStatus::getDeviceId, payload.getDeviceId())
.last("limit 1"));
if (exists == null) {
deviceStatusMapper.insert(payload);
} else {
payload.setId(exists.getId());
payload.setCreatedTime(exists.getCreatedTime());
deviceStatusMapper.updateById(payload);
}
messagingTemplate.convertAndSend("/topic/device/" + payload.getDeviceId(), payload);
log.debug("设备状态已广播, deviceId={}", payload.getDeviceId());
}
@PostMapping("/command")
public Result<Map<String, Object>> sendCommand(@RequestBody Map<String, Object> request) {
String deviceId = request == null ? null : String.valueOf(request.getOrDefault("deviceId", ""));
if (deviceId == null || deviceId.isBlank()) {
return Result.error("deviceId不能为空");
}
String commandJson = request == null ? "{}" : String.valueOf(request.getOrDefault("commandJson", "{}"));
Map<String, Object> commandPayload = new HashMap<>();
commandPayload.put("deviceId", deviceId);
commandPayload.put("commandJson", commandJson);
commandPayload.put("sentAt", System.currentTimeMillis());
messagingTemplate.convertAndSendToUser(deviceId, "/queue/command", commandPayload);
log.info("下发设备指令成功, deviceId={}", deviceId);
Map<String, Object> result = new HashMap<>();
result.put("deviceId", deviceId);
result.put("queued", true);
return Result.OK(result);
}
}

View File

@@ -0,0 +1,136 @@
package org.jeecg.modules.device.sync.controller;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.device.sync.dto.SyncMessageDto;
import org.jeecg.modules.device.sync.entity.DeviceRegistry;
import org.jeecg.modules.device.sync.entity.DeviceStatus;
import org.jeecg.modules.device.sync.entity.SyncIdempotentLog;
import org.jeecg.modules.device.sync.mapper.DeviceRegistryMapper;
import org.jeecg.modules.device.sync.mapper.DeviceStatusMapper;
import org.jeecg.modules.device.sync.mapper.SyncIdempotentLogMapper;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 数据同步控制器。
*/
@Slf4j
@RestController
@RequestMapping("/sys/sync")
@RequiredArgsConstructor
public class SyncController {
private final SyncIdempotentLogMapper syncIdempotentLogMapper;
private final DeviceStatusMapper deviceStatusMapper;
private final DeviceRegistryMapper deviceRegistryMapper;
@PostMapping("/batch")
@Transactional(rollbackFor = Exception.class)
public Result<Map<String, Integer>> batch(@RequestBody List<SyncMessageDto> messages) {
int received = messages == null ? 0 : messages.size();
int inserted = 0;
if (messages == null || messages.isEmpty()) {
Map<String, Integer> empty = new HashMap<>();
empty.put("received", 0);
empty.put("inserted", 0);
return Result.OK(empty);
}
for (SyncMessageDto message : messages) {
if (message == null || message.getMessageId() == null || message.getMessageId().isBlank()) {
continue;
}
if (syncIdempotentLogMapper.exists(message.getMessageId())) {
continue;
}
routeAndPersist(message);
SyncIdempotentLog logEntity = new SyncIdempotentLog();
logEntity.setMessageId(message.getMessageId());
logEntity.setAggregateType(message.getAggregateType());
logEntity.setAggregateId(message.getAggregateId());
logEntity.setEventType(message.getEventType());
logEntity.setCreatedTime(new Date());
syncIdempotentLogMapper.insert(logEntity);
inserted++;
}
Map<String, Integer> result = new HashMap<>();
result.put("received", received);
result.put("inserted", inserted);
return Result.OK(result);
}
private void routeAndPersist(SyncMessageDto message) {
String aggregateType = message.getAggregateType() == null ? "" : message.getAggregateType().trim().toUpperCase();
switch (aggregateType) {
case "DEVICE_STATUS":
saveDeviceStatus(message);
break;
case "DEVICE_REGISTRY":
saveDeviceRegistry(message);
break;
default:
log.debug("未识别aggregateType按透传记录处理messageId={}, aggregateType={}", message.getMessageId(), aggregateType);
break;
}
}
private void saveDeviceStatus(SyncMessageDto message) {
DeviceStatus incoming = JSON.parseObject(message.getPayload(), DeviceStatus.class);
if (incoming == null || incoming.getDeviceId() == null || incoming.getDeviceId().isBlank()) {
return;
}
Date now = new Date();
incoming.setUpdateTime(now);
if (incoming.getCreatedTime() == null) {
incoming.setCreatedTime(now);
}
DeviceStatus exists = deviceStatusMapper.selectOne(new LambdaQueryWrapper<DeviceStatus>()
.eq(DeviceStatus::getDeviceId, incoming.getDeviceId())
.last("limit 1"));
if (exists == null) {
deviceStatusMapper.insert(incoming);
} else {
incoming.setId(exists.getId());
incoming.setCreatedTime(exists.getCreatedTime());
deviceStatusMapper.updateById(incoming);
}
}
private void saveDeviceRegistry(SyncMessageDto message) {
DeviceRegistry incoming = JSON.parseObject(message.getPayload(), DeviceRegistry.class);
if (incoming == null || incoming.getDeviceId() == null || incoming.getDeviceId().isBlank()) {
return;
}
Date now = new Date();
incoming.setUpdateTime(now);
if (incoming.getCreatedTime() == null) {
incoming.setCreatedTime(now);
}
DeviceRegistry exists = deviceRegistryMapper.selectOne(new LambdaQueryWrapper<DeviceRegistry>()
.eq(DeviceRegistry::getDeviceId, incoming.getDeviceId())
.last("limit 1"));
if (exists == null) {
deviceRegistryMapper.insert(incoming);
} else {
incoming.setId(exists.getId());
incoming.setCreatedTime(exists.getCreatedTime());
deviceRegistryMapper.updateById(incoming);
}
}
}

View File

@@ -0,0 +1,44 @@
package org.jeecg.modules.device.sync.dto;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 同步消息 DTO。
*/
@Data
public class SyncMessageDto implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 消息唯一ID幂等键
*/
private String messageId;
/**
* 聚合类型。
*/
private String aggregateType;
/**
* 聚合ID。
*/
private String aggregateId;
/**
* 事件类型。
*/
private String eventType;
/**
* JSON字符串载荷。
*/
private String payload;
/**
* 事件发生时间。
*/
private Date occurredAt;
}

View File

@@ -0,0 +1,37 @@
package org.jeecg.modules.device.sync.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 设备注册实体。
*/
@Data
@TableName("device_registry")
public class DeviceRegistry implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private String deviceId;
private String deviceName;
private String deviceType;
private Integer tokenVersion;
private Integer enabled;
private Date lastSyncTime;
private Date createdTime;
private Date updateTime;
}

View File

@@ -0,0 +1,33 @@
package org.jeecg.modules.device.sync.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 设备状态实体。
*/
@Data
@TableName("device_status")
public class DeviceStatus implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private String deviceId;
private Integer onlineStatus;
private Date lastSeenTime;
private String statusPayload;
private Date createdTime;
private Date updateTime;
}

View File

@@ -0,0 +1,31 @@
package org.jeecg.modules.device.sync.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.util.Date;
/**
* 同步幂等日志实体。
*/
@Data
@TableName("sync_idempotent_log")
public class SyncIdempotentLog implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.AUTO)
private Long id;
private String messageId;
private String aggregateType;
private String aggregateId;
private String eventType;
private Date createdTime;
}

View File

@@ -0,0 +1,81 @@
package org.jeecg.modules.device.sync.filter;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.RedisUtil;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Date;
/**
* 设备 Token 滑动续签过滤器。
*/
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1)
public class DeviceTokenRefreshFilter extends OncePerRequestFilter {
private static final long DEVICE_TOKEN_TTL_SECONDS = 30L * 24 * 60 * 60;
private static final long DEVICE_TOKEN_EXPIRE_MILLIS = 30L * 24 * 60 * 60 * 1000;
private static final String PREFIX_DEVICE_TOKEN = "prefix_device_token:";
private final RedisUtil redisUtil;
public DeviceTokenRefreshFilter(RedisUtil redisUtil) {
this.redisUtil = redisUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String auth = request.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
String token = auth.substring(7).trim();
try {
DecodedJWT jwt = JWT.decode(token);
String tokenType = jwt.getClaim("tokenType").asString();
if ("device".equalsIgnoreCase(tokenType)) {
String deviceId = resolveDeviceId(jwt);
if (deviceId != null && !deviceId.isBlank()) {
redisUtil.expire(PREFIX_DEVICE_TOKEN + token, DEVICE_TOKEN_TTL_SECONDS);
String refreshed = refreshDeviceToken(deviceId);
redisUtil.set(PREFIX_DEVICE_TOKEN + refreshed, refreshed, DEVICE_TOKEN_TTL_SECONDS);
response.setHeader("X-Refresh-Token", refreshed);
log.debug("设备Token已续签, deviceId={}", deviceId);
}
}
} catch (Exception e) {
log.debug("设备Token续签跳过: {}", e.getMessage());
}
}
filterChain.doFilter(request, response);
}
private String resolveDeviceId(DecodedJWT jwt) {
String deviceId = jwt.getClaim("deviceId").asString();
if (deviceId == null || deviceId.isBlank()) {
deviceId = jwt.getClaim("username").asString();
}
return deviceId;
}
private String refreshDeviceToken(String deviceId) {
Date expiresAt = new Date(System.currentTimeMillis() + DEVICE_TOKEN_EXPIRE_MILLIS);
Algorithm algorithm = Algorithm.HMAC256(deviceId + "_device_secret");
return JWT.create()
.withClaim("username", deviceId)
.withClaim("deviceId", deviceId)
.withClaim("tokenType", "device")
.withExpiresAt(expiresAt)
.sign(algorithm);
}
}

View File

@@ -0,0 +1,9 @@
package org.jeecg.modules.device.sync.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.device.sync.entity.DeviceRegistry;
@Mapper
public interface DeviceRegistryMapper extends BaseMapper<DeviceRegistry> {
}

View File

@@ -0,0 +1,9 @@
package org.jeecg.modules.device.sync.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.device.sync.entity.DeviceStatus;
@Mapper
public interface DeviceStatusMapper extends BaseMapper<DeviceStatus> {
}

View File

@@ -0,0 +1,17 @@
package org.jeecg.modules.device.sync.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.jeecg.modules.device.sync.entity.SyncIdempotentLog;
/**
* 同步幂等日志 Mapper。
*/
@Mapper
public interface SyncIdempotentLogMapper extends BaseMapper<SyncIdempotentLog> {
@Select("SELECT COUNT(1) > 0 FROM sync_idempotent_log WHERE message_id = #{messageId}")
boolean exists(@Param("messageId") String messageId);
}

View File

@@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS sync_idempotent_log (
id BIGINT NOT NULL AUTO_INCREMENT,
message_id VARCHAR(64) NOT NULL COMMENT '消息唯一ID',
aggregate_type VARCHAR(64) NOT NULL COMMENT '聚合类型',
aggregate_id VARCHAR(64) NOT NULL COMMENT '聚合ID',
event_type VARCHAR(64) NOT NULL COMMENT '事件类型',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (id),
UNIQUE KEY uk_sync_idempotent_log_message_id (message_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同步幂等日志';
CREATE TABLE IF NOT EXISTS device_status (
id BIGINT NOT NULL AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL COMMENT '设备ID',
online_status TINYINT NOT NULL DEFAULT 0 COMMENT '在线状态:0离线1在线',
last_seen_time DATETIME NULL COMMENT '最后在线时间',
status_payload JSON NULL COMMENT '状态载荷',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_device_status_device_id (device_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备状态表';
CREATE TABLE IF NOT EXISTS device_registry (
id BIGINT NOT NULL AUTO_INCREMENT,
device_id VARCHAR(64) NOT NULL COMMENT '设备ID',
device_name VARCHAR(128) NULL COMMENT '设备名称',
device_type VARCHAR(64) NULL COMMENT '设备类型',
token_version INT NOT NULL DEFAULT 1 COMMENT '设备令牌版本',
enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用:0否1是',
last_sync_time DATETIME NULL COMMENT '最后同步时间',
created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_device_registry_device_id (device_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备注册表';

View File

@@ -61,6 +61,10 @@
<artifactId>jeecg-boot-module-airag</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>

View File

@@ -10,9 +10,14 @@ import com.alibaba.fastjson.JSONObject;
import org.jeecg.common.base.BaseMap;
import org.jeecg.common.constant.WebsocketConst;
import org.jeecg.common.modules.redis.client.JeecgRedisClient;
import org.jeecg.modules.system.entity.SysLog;
import org.jeecg.modules.system.service.ISysLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.util.oConvertUtils;
import java.util.Date;
/**
* @Author scott
@@ -27,6 +32,13 @@ public class WebSocket {
/**线程安全Map*/
private static ConcurrentHashMap<String, Session> sessionPool = new ConcurrentHashMap<>();
/**
* 当前在线 WebSocket 连接数。
*/
public static int getOnlineCount() {
return sessionPool.size();
}
/**
* Redis触发监听名字
*/
@@ -34,10 +46,15 @@ public class WebSocket {
//避免初次调用出现空指针的情况
private static JeecgRedisClient jeecgRedisClient;
private static ISysLogService sysLogService;
@Autowired
private void setJeecgRedisClient(JeecgRedisClient jeecgRedisClient){
WebSocket.jeecgRedisClient = jeecgRedisClient;
}
@Autowired
private void setSysLogService(ISysLogService sysLogService){
WebSocket.sysLogService = sysLogService;
}
//==========【websocket接受、推送消息等方法 —— 具体服务节点推送ws消息】========================================================================================
@@ -45,7 +62,7 @@ public class WebSocket {
public void onOpen(Session session, @PathParam(value = "userId") String userId) {
try {
sessionPool.put(userId, session);
log.debug("【系统 WebSocket】有新的连接,总数为:" + sessionPool.size());
log.info("【系统 WebSocket】连接建立 userId={}, sessionId={}, online={}", userId, session.getId(), sessionPool.size());
} catch (Exception e) {
}
}
@@ -54,7 +71,7 @@ public class WebSocket {
public void onClose(@PathParam("userId") String userId) {
try {
sessionPool.remove(userId);
log.debug("【系统 WebSocket】连接断开,总数为:" + sessionPool.size());
log.info("【系统 WebSocket】连接断开 userId={}, online={}", userId, sessionPool.size());
} catch (Exception e) {
e.printStackTrace();
}
@@ -67,6 +84,8 @@ public class WebSocket {
* @param message
*/
public void pushMessage(String userId, String message) {
int successCount = 0;
int failCount = 0;
for (Map.Entry<String, Session> item : sessionPool.entrySet()) {
//userId key值= {用户id + "_"+ 登录token的md5串}
//TODO vue2未改key新规则暂时不影响逻辑
@@ -75,29 +94,57 @@ public class WebSocket {
try {
// 代码逻辑说明: websocket报错 https://gitee.com/jeecg/jeecg-boot/issues/I4C0MU
synchronized (session){
log.debug("【系统 WebSocket】推送单人消息:" + message);
session.getBasicRemote().sendText(message);
}
successCount++;
} catch (Exception e) {
failCount++;
log.error(e.getMessage(),e);
}
}
}
// 日志分级策略:业务消息 info心跳消息 debug异常场景 warn/error
if (failCount > 0) {
log.warn("【系统 WebSocket】定向推送存在失败 userKeyContains={}, success={}, fail={}, payload={}",
userId, successCount, failCount, message);
} else if (isHeartbeatOrBlank(message == null ? "" : message.trim())) {
log.debug("【系统 WebSocket】定向心跳推送 userKeyContains={}, success={}, fail={}",
userId, successCount, failCount);
} else {
log.info("【系统 WebSocket】定向业务推送 userKeyContains={}, success={}, fail={}, payload={}",
userId, successCount, failCount, message);
}
}
/**
* ws遍历群发消息
*/
public void pushMessage(String message) {
int successCount = 0;
int failCount = 0;
try {
for (Map.Entry<String, Session> item : sessionPool.entrySet()) {
String sessionKey = item.getKey();
Session session = item.getValue();
try {
item.getValue().getAsyncRemote().sendText(message);
if (session == null || !session.isOpen()) {
failCount++;
log.warn("【系统 WebSocket】连接不可用跳过发送key={}", sessionKey);
continue;
}
// 使用同步发送提升送达确定性,避免 AsyncRemote 在断连边界下静默丢消息
synchronized (session) {
session.getBasicRemote().sendText(message);
}
successCount++;
} catch (Exception e) {
failCount++;
log.warn("【系统 WebSocket】发送失败key={},原因={}", sessionKey, e.getMessage());
log.error(e.getMessage(), e);
}
}
log.debug("【系统 WebSocket】群发消息:" + message);
log.info("【系统 WebSocket】群发完成 success={}, fail={}, online={}, payload={}",
successCount, failCount, sessionPool.size(), message);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
@@ -109,13 +156,25 @@ public class WebSocket {
*/
@OnMessage
public void onMessage(String message, @PathParam(value = "userId") String userId) {
if(!"ping".equals(message) && !WebsocketConst.CMD_CHECK.equals(message)){
log.debug("【系统 WebSocket】收到客户端消息:" + message);
}else{
log.debug("【系统 WebSocket】收到客户端消息:" + message);
// 代码逻辑说明: 【issues/1161】前端websocket因心跳导致监听不起作用---
this.sendMessage(userId, "ping");
String normalized = message == null ? "" : message.trim();
if (isHeartbeatOrBlank(normalized)) {
log.debug("【系统 WebSocket】收到客户端心跳/空消息:{}", normalized);
if ("ping".equalsIgnoreCase(normalized) || WebsocketConst.CMD_CHECK.equalsIgnoreCase(normalized)) {
// 代码逻辑说明: 【issues/1161】前端websocket因心跳导致监听不起作用---
this.sendMessage(userId, "ping");
}
return;
}
if (!isJsonMessage(normalized)) {
log.debug("【系统 WebSocket】忽略非JSON消息:{}", normalized);
return;
}
if (handleScadaLog(normalized, userId)) {
return;
}
log.debug("【系统 WebSocket】收到客户端消息:" + normalized);
// //------------------------------------------------------------------------------
// JSONObject obj = new JSONObject();
@@ -179,6 +238,82 @@ public class WebSocket {
sendMessage(userId, message);
}
}
private boolean handleScadaLog(String message, String userId) {
try {
JSONObject obj = JSONObject.parseObject(message);
if (obj == null) {
return false;
}
String cmd = obj.getString("cmd");
if (!"SCADA_LOGIN_LOG".equals(cmd) && !"SCADA_LOG".equals(cmd)) {
return false;
}
if (sysLogService == null) {
return true;
}
String category = obj.getString("category");
String account = obj.getString("account");
Boolean success = obj.getBoolean("success");
String msg = obj.getString("message");
String clientIp = obj.getString("clientIp");
String clientType = normalizeClientType(obj.getString("clientType"));
String exception = obj.getString("exception");
Integer logType = obj.getInteger("logType");
Integer operateType = obj.getInteger("operateType");
String method = obj.getString("method");
String requestUrl = obj.getString("requestUrl");
SysLog logEntity = new SysLog();
logEntity.setCreateTime(new Date());
logEntity.setLogType(logType == null ? ("EXCEPTION".equalsIgnoreCase(category) ? 2 : 1) : logType);
logEntity.setOperateType(operateType == null ? ("LOGIN".equalsIgnoreCase(category) ? 1 : 5) : operateType);
logEntity.setRequestType("WS");
logEntity.setRequestUrl(oConvertUtils.isEmpty(requestUrl) ? ("/websocket/" + userId) : requestUrl);
logEntity.setMethod(oConvertUtils.isEmpty(method) ? "SCADA_LOG" : method);
logEntity.setUsername(account);
logEntity.setUserid(account);
logEntity.setIp(oConvertUtils.isEmpty(clientIp) ? "desktop" : clientIp);
logEntity.setClientType(clientType);
if ("LOGIN".equalsIgnoreCase(category) || "SCADA_LOGIN_LOG".equals(cmd)) {
logEntity.setLogContent(String.format("桌面端登录%s%s", Boolean.TRUE.equals(success) ? "成功" : "失败", oConvertUtils.isEmpty(msg) ? "" : msg));
} else if ("EXCEPTION".equalsIgnoreCase(category)) {
String exceptionMsg = oConvertUtils.isEmpty(exception) ? "" : (" 异常: " + exception);
logEntity.setLogContent(String.format("桌面端异常日志:%s%s", oConvertUtils.isEmpty(msg) ? "" : msg, exceptionMsg));
} else {
logEntity.setLogContent(String.format("桌面端操作日志:%s", oConvertUtils.isEmpty(msg) ? "" : msg));
}
logEntity.setRequestParam(message);
sysLogService.save(logEntity);
return true;
} catch (Exception e) {
log.warn("处理 SCADA 登录日志消息失败: {}", e.getMessage());
return false;
}
}
private boolean isHeartbeatOrBlank(String message) {
return oConvertUtils.isEmpty(message)
|| "ping".equalsIgnoreCase(message)
|| "pong".equalsIgnoreCase(message)
|| WebsocketConst.CMD_CHECK.equalsIgnoreCase(message);
}
private boolean isJsonMessage(String message) {
return !oConvertUtils.isEmpty(message) && message.startsWith("{");
}
private String normalizeClientType(String rawClientType) {
if (oConvertUtils.isEmpty(rawClientType)) {
return "gkj";
}
String value = rawClientType.trim().toLowerCase();
if ("desktop".equals(value) || "pc".equals(value) || "windows".equals(value)) {
return "gkj";
}
return value;
}
//=======【采用redis发布订阅模式——推送消息】==========================================================================================
}

View File

@@ -40,6 +40,7 @@ import org.jeecgframework.poi.excel.entity.ExportParams;
import org.jeecgframework.poi.excel.entity.ImportParams;
import org.jeecgframework.poi.excel.entity.enmus.ExcelType;
import org.jeecgframework.poi.excel.view.JeecgEntityExcelView;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
@@ -153,6 +154,108 @@ public class SysDictController {
return Result.ok(res);
}
/**
* SCADA 系统专用:免登录查询数据字典项。
* 支持按 dictCode / dictName / itemText / itemValue 条件过滤,支持增量同步。
*/
@GetMapping("/scada/queryDictItem")
@Operation(summary = "SCADA-免登录查询数据字典项")
public Result<List<Map<String, Object>>> scadaQueryDictItem(
@RequestParam(name = "dictCode", required = false) String dictCode,
@RequestParam(name = "dictName", required = false) String dictName,
@RequestParam(name = "itemText", required = false) String itemText,
@RequestParam(name = "itemValue", required = false) String itemValue,
@RequestParam(name = "status", required = false) Integer status,
@RequestParam(name = "updatedAfter", required = false) String updatedAfter,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "500") Integer pageSize) {
if (pageNo == null || pageNo < 1) {
pageNo = 1;
}
if (pageSize == null || pageSize < 1) {
pageSize = 500;
}
pageSize = Math.min(pageSize, 1000);
LambdaQueryWrapper<SysDictItem> itemWrapper = new LambdaQueryWrapper<>();
itemWrapper.like(oConvertUtils.isNotEmpty(itemText), SysDictItem::getItemText, itemText);
itemWrapper.like(oConvertUtils.isNotEmpty(itemValue), SysDictItem::getItemValue, itemValue);
itemWrapper.eq(status != null, SysDictItem::getStatus, status);
if (oConvertUtils.isNotEmpty(updatedAfter)) {
try {
Date parsedSyncTime;
try {
parsedSyncTime = DateUtils.parseDate(updatedAfter, "yyyy-MM-dd HH:mm:ss");
} catch (Exception firstEx) {
parsedSyncTime = DateUtils.parseDate(updatedAfter, "yyyy-MM-dd'T'HH:mm:ss");
}
final Date syncTime = parsedSyncTime;
itemWrapper.and(w -> w.ge(SysDictItem::getUpdateTime, syncTime).or().ge(SysDictItem::getCreateTime, syncTime));
} catch (Exception e) {
return Result.error("updatedAfter格式错误支持yyyy-MM-dd HH:mm:ss 或 yyyy-MM-dd'T'HH:mm:ss");
}
}
// 若传入字典头过滤条件,先按字典头筛出 dictId 再查询字典项
if (oConvertUtils.isNotEmpty(dictCode) || oConvertUtils.isNotEmpty(dictName)) {
LambdaQueryWrapper<SysDict> dictFilter = new LambdaQueryWrapper<>();
dictFilter.eq(SysDict::getDelFlag, CommonConstant.DEL_FLAG_0);
dictFilter.like(oConvertUtils.isNotEmpty(dictCode), SysDict::getDictCode, dictCode);
dictFilter.like(oConvertUtils.isNotEmpty(dictName), SysDict::getDictName, dictName);
List<SysDict> filteredDicts = sysDictService.list(dictFilter);
if (oConvertUtils.isEmpty(filteredDicts)) {
return Result.ok(new ArrayList<>());
}
List<String> filteredDictIds = filteredDicts.stream().map(SysDict::getId).collect(java.util.stream.Collectors.toList());
itemWrapper.in(SysDictItem::getDictId, filteredDictIds);
}
itemWrapper.orderByAsc(SysDictItem::getSortOrder);
itemWrapper.orderByDesc(SysDictItem::getCreateTime);
IPage<SysDictItem> itemPage = sysDictItemService.page(new Page<>(pageNo, pageSize), itemWrapper);
List<SysDictItem> itemList = itemPage.getRecords();
if (oConvertUtils.isEmpty(itemList)) {
return Result.ok(new ArrayList<>());
}
List<String> dictIds = itemList.stream().map(SysDictItem::getDictId).distinct().collect(java.util.stream.Collectors.toList());
LambdaQueryWrapper<SysDict> dictQuery = new LambdaQueryWrapper<>();
dictQuery.in(SysDict::getId, dictIds);
dictQuery.eq(SysDict::getDelFlag, CommonConstant.DEL_FLAG_0);
List<SysDict> dictList = sysDictService.list(dictQuery);
Map<String, SysDict> dictMap = dictList.stream().collect(java.util.stream.Collectors.toMap(SysDict::getId, d -> d));
List<Map<String, Object>> resultList = new ArrayList<>();
for (SysDictItem item : itemList) {
SysDict dict = dictMap.get(item.getDictId());
if (dict == null) {
continue;
}
Map<String, Object> row = new LinkedHashMap<>(24);
row.put("id", item.getId());
row.put("dictId", item.getDictId());
row.put("dictName", dict.getDictName());
row.put("dictCode", dict.getDictCode());
row.put("dictType", dict.getType());
row.put("dictDescription", dict.getDescription());
row.put("itemText", item.getItemText());
row.put("itemValue", item.getItemValue());
row.put("itemDescription", item.getDescription());
row.put("sortOrder", item.getSortOrder());
row.put("status", item.getStatus());
row.put("itemColor", item.getItemColor());
row.put("createBy", item.getCreateBy());
row.put("createTime", item.getCreateTime());
row.put("updateBy", item.getUpdateBy());
row.put("updateTime", item.getUpdateTime());
resultList.add(row);
}
return Result.ok(resultList);
}
/**
* 获取字典数据
* @param dictCode

View File

@@ -8,11 +8,11 @@ import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.IpUtils;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.config.JeecgBaseConfig;
import org.jeecg.modules.system.entity.SysLog;
@@ -23,6 +23,8 @@ import org.jeecgframework.poi.excel.entity.ExportParams;
import org.jeecgframework.poi.excel.view.JeecgEntityExcelView;
import org.jeecgframework.poi.handler.inter.IExcelExportServerEnhanced;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
@@ -32,6 +34,7 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.ModelAndView;
@@ -51,6 +54,77 @@ public class SysLogController extends JeecgController<SysLog, ISysLogService> {
@Autowired
private ISysLogService sysLogService;
@PostMapping("/scada/addLoginLog")
@Operation(summary = "SCADA-免登录写入登录日志")
public Result<String> scadaAddLoginLog(@RequestBody java.util.Map<String, Object> payload, HttpServletRequest request) {
payload.put("category", "LOGIN");
return scadaAddLog(payload, request);
}
@PostMapping("/scada/addLog")
@Operation(summary = "SCADA-免登录写入通用日志")
public Result<String> scadaAddLog(@RequestBody java.util.Map<String, Object> payload, HttpServletRequest request) {
try {
String account = oConvertUtils.getString(payload.get("account"));
String message = oConvertUtils.getString(payload.get("message"));
String category = oConvertUtils.getString(payload.get("category"));
boolean success = Boolean.parseBoolean(oConvertUtils.getString(payload.get("success")));
String clientType = normalizeClientType(oConvertUtils.getString(payload.get("clientType")));
String exception = oConvertUtils.getString(payload.get("exception"));
int logType = oConvertUtils.getInt(payload.get("logType"), "EXCEPTION".equalsIgnoreCase(category) ? 2 : 1);
int operateType = oConvertUtils.getInt(payload.get("operateType"), "LOGIN".equalsIgnoreCase(category) ? 1 : 5);
String method = oConvertUtils.getString(payload.get("method"));
if (oConvertUtils.isEmpty(method)) {
method = "LOGIN".equalsIgnoreCase(category) ? "LOGIN" : "SCADA_LOG";
}
String requestUrl = oConvertUtils.getString(payload.get("requestUrl"));
if (oConvertUtils.isEmpty(requestUrl)) {
requestUrl = "/sys/log/scada/addLog";
}
SysLog logEntity = new SysLog();
logEntity.setCreateTime(new java.util.Date());
logEntity.setLogType(logType);
logEntity.setOperateType(operateType);
logEntity.setRequestType("WS/HTTP");
logEntity.setRequestUrl(requestUrl);
logEntity.setMethod(method);
logEntity.setUsername(account);
logEntity.setUserid(account);
logEntity.setIp(IpUtils.getIpAddr(request));
logEntity.setClientType(clientType);
logEntity.setLogContent(buildLogContent(category, success, message, exception));
logEntity.setRequestParam(com.alibaba.fastjson.JSON.toJSONString(payload));
sysLogService.save(logEntity);
return Result.OK("ok");
} catch (Exception e) {
log.error("SCADA写入日志失败", e);
return Result.error("写入失败:" + e.getMessage());
}
}
private String buildLogContent(String category, boolean success, String message, String exception) {
if ("LOGIN".equalsIgnoreCase(category)) {
return String.format("桌面端登录%s%s", success ? "成功" : "失败", oConvertUtils.isEmpty(message) ? "" : message);
}
if ("EXCEPTION".equalsIgnoreCase(category)) {
String exceptionMsg = oConvertUtils.isEmpty(exception) ? "" : (" 异常: " + exception);
return String.format("桌面端异常日志:%s%s", oConvertUtils.isEmpty(message) ? "" : message, exceptionMsg);
}
return String.format("桌面端操作日志:%s", oConvertUtils.isEmpty(message) ? "" : message);
}
private String normalizeClientType(String rawClientType) {
if (oConvertUtils.isEmpty(rawClientType)) {
return "gkj";
}
String value = rawClientType.trim().toLowerCase();
if ("desktop".equals(value) || "pc".equals(value) || "windows".equals(value)) {
return "gkj";
}
return value;
}
/**
* for [issues/8699]AutoPoi在使用@ExcelEntity当设置show=true并且该项为null时报错
*/

View File

@@ -9,8 +9,11 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.time.DateUtils;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.apache.shiro.authz.annotation.RequiresRoles;
@@ -22,6 +25,7 @@ import org.jeecg.common.constant.CacheConstant;
import org.jeecg.common.constant.CommonConstant;
import org.jeecg.common.constant.PasswordConstant;
import org.jeecg.common.constant.SymbolConstant;
import org.jeecg.common.constant.WebsocketConst;
import org.jeecg.common.modules.redis.client.JeecgRedisClient;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.common.system.util.JwtUtil;
@@ -35,11 +39,13 @@ import org.jeecg.modules.system.excelstyle.ExcelExportSysUserStyle;
import org.jeecg.modules.system.model.DepartIdModel;
import org.jeecg.modules.system.model.SysUserSysDepPostModel;
import org.jeecg.modules.system.model.SysUserSysDepartModel;
import org.jeecg.modules.message.websocket.WebSocket;
import org.jeecg.modules.system.service.*;
import org.jeecg.modules.system.util.ImportSysUserCache;
import org.jeecg.modules.system.vo.SysDepartUsersVO;
import org.jeecg.modules.system.vo.SysUserExportVo;
import org.jeecg.modules.system.vo.SysUserRoleVO;
import org.jeecg.modules.system.vo.SysUserTenantVo;
import org.jeecg.modules.system.vo.lowapp.DepartAndUserInfo;
import org.jeecg.modules.system.vo.lowapp.UpdateDepartInfo;
import org.jeecgframework.poi.excel.def.NormalExcelConstants;
@@ -48,6 +54,7 @@ import org.jeecgframework.poi.excel.entity.enmus.ExcelType;
import org.jeecgframework.poi.excel.view.JeecgEntityExcelView;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
@@ -70,6 +77,7 @@ import java.util.stream.Collectors;
@Slf4j
@RestController
@RequestMapping("/sys/user")
@Tag(name = "用户管理")
public class SysUserController {
@Autowired
@@ -109,6 +117,66 @@ public class SysUserController {
private JeecgRedisClient jeecgRedisClient;
@Autowired
private JeecgBaseConfig jeecgBaseConfig;
@Autowired
private WebSocket webSocket;
/** 设备同步模块 STOMP与桌面端统一通道 /topic/sync/jeecg-users无 device-sync 模块时为 null */
@Autowired(required = false)
private SimpMessagingTemplate simpMessagingTemplate;
private void notifyScadaUserChanged(String action, String userId) {
try {
JSONObject payload = new JSONObject();
payload.put(WebsocketConst.MSG_CMD, "SCADA_USER_CHANGED");
payload.put("action", action);
payload.put("userId", userId);
payload.put("timestamp", System.currentTimeMillis());
log.info("SCADA用户变更通知开始 action={}, userId={}, onlineWs={}", action, userId, WebSocket.getOnlineCount());
// 固定工控机通道定向推送(桌面端连接 /websocket/scada-sync
webSocket.pushMessage("scada-sync", payload.toJSONString());
// 单机直推避免仅依赖Redis链路导致消息偶发丢失
webSocket.pushMessage(payload.toJSONString());
// 集群转发通过Redis发布订阅推送到其它节点在线客户端
webSocket.sendMessage(payload.toJSONString());
pushUserChangeToStompTopic(payload.toJSONString());
log.info("SCADA用户变更通知完成 action={}, userId={}, payload={}", action, userId, payload.toJSONString());
} catch (Exception e) {
log.warn("推送SCADA用户变更消息失败 action={}, userId={}", action, userId, e);
}
}
private void pushUserChangeToStompTopic(String jsonPayload) {
if (simpMessagingTemplate == null) {
return;
}
try {
simpMessagingTemplate.convertAndSend("/topic/sync/jeecg-users", jsonPayload);
} catch (Exception e) {
log.debug("STOMP 广播用户变更跳过device-sync 未启用或 Broker 未就绪): {}", e.getMessage());
}
}
private void notifyScadaUsersChanged(String action, String ids) {
try {
JSONObject payload = new JSONObject();
payload.put(WebsocketConst.MSG_CMD, "SCADA_USER_CHANGED");
payload.put("action", action);
payload.put("userIds", ids);
payload.put("timestamp", System.currentTimeMillis());
log.info("SCADA批量用户变更通知开始 action={}, ids={}, onlineWs={}", action, ids, WebSocket.getOnlineCount());
// 固定工控机通道定向推送(桌面端连接 /websocket/scada-sync
webSocket.pushMessage("scada-sync", payload.toJSONString());
// 单机直推避免仅依赖Redis链路导致消息偶发丢失
webSocket.pushMessage(payload.toJSONString());
// 集群转发通过Redis发布订阅推送到其它节点在线客户端
webSocket.sendMessage(payload.toJSONString());
pushUserChangeToStompTopic(payload.toJSONString());
log.info("SCADA批量用户变更通知完成 action={}, ids={}, payload={}", action, ids, payload.toJSONString());
} catch (Exception e) {
log.warn("推送SCADA批量用户变更消息失败 action={}, ids={}", action, ids, e);
}
}
/**
* 获取租户下用户数据(支持租户隔离)
@@ -119,6 +187,7 @@ public class SysUserController {
* @return
*/
@PermissionData(pageComponent = "system/UserList")
@Operation(summary = "用户管理-分页查询")
@RequestMapping(value = "/list", method = RequestMethod.GET)
public Result<IPage<SysUser>> queryPageList(SysUser user,@RequestParam(name="pageNo", defaultValue="1") Integer pageNo,
@RequestParam(name="pageSize", defaultValue="10") Integer pageSize,HttpServletRequest req) {
@@ -156,6 +225,7 @@ public class SysUserController {
}
@RequiresPermissions("system:user:add")
@Operation(summary = "用户管理-新增用户")
@RequestMapping(value = "/add", method = RequestMethod.POST)
public Result<SysUser> add(@RequestBody JSONObject jsonObject) {
Result<SysUser> result = new Result<SysUser>();
@@ -178,6 +248,7 @@ public class SysUserController {
String relTenantIds = jsonObject.getString("relTenantIds");
sysUserService.saveUser(user, selectedRoles, selectedDeparts, relTenantIds, false);
baseCommonService.addLog("添加用户username " +user.getUsername() ,CommonConstant.LOG_TYPE_2, 2);
notifyScadaUserChanged("add", user.getId());
result.success("添加成功!");
} catch (Exception e) {
log.error(e.getMessage(), e);
@@ -187,6 +258,7 @@ public class SysUserController {
}
@RequiresPermissions("system:user:edit")
@Operation(summary = "用户管理-编辑用户")
@RequestMapping(value = "/edit", method = {RequestMethod.PUT,RequestMethod.POST})
public Result<SysUser> edit(@RequestBody JSONObject jsonObject) {
Result<SysUser> result = new Result<SysUser>();
@@ -215,6 +287,7 @@ public class SysUserController {
//update-begin---author:wangshuai---date:2025-11-12---for:【JHHB-776】用户编辑应该从数据库查出老数据页面传递什么字段把这些字段覆盖数据库查询结果再更新---
oConvertUtils.copyNonNullFields(user, sysUser);
sysUserService.editUser(sysUser, roles, departs, relTenantIds, updateFromPage);
notifyScadaUserChanged("edit", sysUser.getId());
//update-end---author:wangshuai---date:2025-11-12---for:【JHHB-776】用户编辑应该从数据库查出老数据页面传递什么字段把这些字段覆盖数据库查询结果再更新---
result.success("修改成功!");
}
@@ -253,6 +326,7 @@ public class SysUserController {
String relTenantIds = jsonObject.getString("relTenantIds");
sysUserService.saveUser(user, selectedRoles, selectedDeparts, relTenantIds, true);
baseCommonService.addLog("添加用户username " + user.getUsername(), CommonConstant.LOG_TYPE_2, 2);
notifyScadaUserChanged("addTenantUser", user.getId());
result.success("添加成功!");
} catch (Exception e) {
log.error(e.getMessage(), e);
@@ -265,11 +339,13 @@ public class SysUserController {
* 删除用户
*/
@RequiresPermissions("system:user:delete")
@Operation(summary = "用户管理-删除用户")
@RequestMapping(value = "/delete", method = RequestMethod.DELETE)
public Result<?> delete(@RequestParam(name="id",required=true) String id) {
baseCommonService.addLog("删除用户id " +id ,CommonConstant.LOG_TYPE_2, 3);
List<String> userNameList = sysUserService.userIdToUsername(Arrays.asList(id));
this.sysUserService.deleteUser(id);
notifyScadaUserChanged("delete", id);
if (!userNameList.isEmpty()) {
String joinedString = String.join(",", userNameList);
@@ -281,11 +357,13 @@ public class SysUserController {
* 批量删除用户
*/
@RequiresPermissions("system:user:deleteBatch")
@Operation(summary = "用户管理-批量删除用户")
@RequestMapping(value = "/deleteBatch", method = RequestMethod.DELETE)
public Result<?> deleteBatch(@RequestParam(name="ids",required=true) String ids) {
baseCommonService.addLog("批量删除用户, ids " +ids ,CommonConstant.LOG_TYPE_2, 3);
List<String> userNameList = sysUserService.userIdToUsername(Arrays.asList(ids.split(",")));
this.sysUserService.deleteBatchUsers(ids);
notifyScadaUsersChanged("deleteBatch", ids);
// 用户变更,触发同步工作流
if (!userNameList.isEmpty()) {
@@ -312,6 +390,7 @@ public class SysUserController {
if(oConvertUtils.isNotEmpty(id)) {
// 代码逻辑说明: [QQYUN-5577]用户列表-冻结用户,再解冻之后,用户还是无法登陆,有缓存问题 #5066------------
sysUserService.updateStatus(id,status);
notifyScadaUserChanged("status", id);
}
}
} catch (Exception e) {
@@ -329,6 +408,7 @@ public class SysUserController {
*/
@RequiresRoles({"admin"})
@RequiresPermissions("system:user:resetPassword")
@Operation(summary = "用户管理-重置密码")
@RequestMapping(value = "/resetPassword", method = RequestMethod.PUT)
public Result<SysUser> resetPassword(@RequestParam(name = "usernames") String usernames) {
Result<SysUser> result = new Result<SysUser>();
@@ -344,6 +424,7 @@ public class SysUserController {
}
@RequiresPermissions("system:user:queryById")
@Operation(summary = "用户管理-根据ID查询")
@RequestMapping(value = "/queryById", method = RequestMethod.GET)
public Result<SysUser> queryById(@RequestParam(name = "id", required = true) String id) {
Result<SysUser> result = new Result<SysUser>();
@@ -384,6 +465,7 @@ public class SysUserController {
* @return
*/
@RequestMapping(value = "/checkOnlyUser", method = RequestMethod.GET)
@Operation(summary = "用户管理-校验账号唯一")
public Result<Boolean> checkOnlyUser(SysUser sysUser) {
Result<Boolean> result = new Result<>();
//如果此参数为false则程序发生异常
@@ -411,6 +493,7 @@ public class SysUserController {
* 修改密码
*/
@RequiresPermissions("system:user:changepwd")
@Operation(summary = "用户管理-修改密码")
@RequestMapping(value = "/changePassword", method = RequestMethod.PUT)
public Result<?> changePassword(@RequestBody SysUser sysUser, HttpServletRequest request) {
//-------------------------------------------------------------------------------------
@@ -432,6 +515,107 @@ public class SysUserController {
return sysUserService.changePassword(sysUser);
}
/**
* SCADA 系统专用:免登录查询用户信息。
* 支持通过 username / workNo / phone 任一条件查询,返回基础信息(不返回敏感字段)。
*/
@GetMapping("/scada/queryUser")
@Operation(summary = "SCADA-免登录查询用户信息")
public Result<List<Map<String, Object>>> scadaQueryUser(@RequestParam(name = "username", required = false) String username,
@RequestParam(name = "workNo", required = false) String workNo,
@RequestParam(name = "phone", required = false) String phone,
@RequestParam(name = "updatedAfter", required = false) String updatedAfter,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "200") Integer pageSize,
@RequestParam(name = "includeDetail", defaultValue = "false") Boolean includeDetail) {
if (pageNo == null || pageNo < 1) {
pageNo = 1;
}
if (pageSize == null || pageSize < 1) {
pageSize = 200;
}
// 控制单次最大拉取量,避免接口被大查询拖慢
pageSize = Math.min(pageSize, 1000);
LambdaQueryWrapper<SysUser> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(SysUser::getDelFlag, CommonConstant.DEL_FLAG_0);
queryWrapper.eq(SysUser::getStatus, Integer.valueOf(CommonConstant.STATUS_1));
queryWrapper.eq(oConvertUtils.isNotEmpty(username), SysUser::getUsername, username);
queryWrapper.eq(oConvertUtils.isNotEmpty(workNo), SysUser::getWorkNo, workNo);
queryWrapper.eq(oConvertUtils.isNotEmpty(phone), SysUser::getPhone, phone);
if (oConvertUtils.isNotEmpty(updatedAfter)) {
try {
Date syncTime = DateUtils.parseDate(updatedAfter, new String[]{"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss"});
queryWrapper.and(w -> w.ge(SysUser::getUpdateTime, syncTime).or().ge(SysUser::getCreateTime, syncTime));
} catch (Exception e) {
return Result.error("updatedAfter格式错误支持yyyy-MM-dd HH:mm:ss 或 yyyy-MM-dd'T'HH:mm:ss");
}
}
queryWrapper.orderByAsc(SysUser::getSort);
queryWrapper.orderByDesc(SysUser::getCreateTime);
IPage<SysUser> userPage = sysUserService.page(new Page<>(pageNo, pageSize), queryWrapper);
List<SysUser> userList = userPage.getRecords();
List<Map<String, Object>> resultList = new ArrayList<>();
for (SysUser user : userList) {
List<Map<String, Object>> departList = new ArrayList<>();
List<Map<String, Object>> companyList = new ArrayList<>();
List<Map<String, Object>> tenantList = new ArrayList<>();
if (Boolean.TRUE.equals(includeDetail)) {
List<SysDepart> userDeparts = sysDepartService.queryUserDeparts(user.getId());
for (SysDepart depart : userDeparts) {
Map<String, Object> departInfo = new LinkedHashMap<>(8);
departInfo.put("id", depart.getId());
departInfo.put("departName", depart.getDepartName());
departInfo.put("orgCode", depart.getOrgCode());
departInfo.put("orgCategory", depart.getOrgCategory());
departList.add(departInfo);
// 机构类别1公司、4子公司
if ("1".equals(depart.getOrgCategory()) || "4".equals(depart.getOrgCategory())) {
companyList.add(departInfo);
}
}
List<SysUserTenantVo> userTenants = userTenantService.getTenantListByUserId(user.getId(), Collections.singletonList(CommonConstant.STATUS_1));
for (SysUserTenantVo tenantVo : userTenants) {
Map<String, Object> tenantInfo = new LinkedHashMap<>(8);
tenantInfo.put("tenantId", tenantVo.getRelTenantIds());
tenantInfo.put("tenantName", tenantVo.getName());
tenantInfo.put("tenantUserStatus", tenantVo.getUserTenantStatus());
tenantInfo.put("tenantUserId", tenantVo.getTenantUserId());
tenantInfo.put("tenantAdmin", tenantVo.getTenantAdmin());
tenantList.add(tenantInfo);
}
}
Map<String, Object> userInfo = new LinkedHashMap<>(16);
userInfo.put("id", user.getId());
userInfo.put("username", user.getUsername());
userInfo.put("realname", user.getRealname());
// SysUser无独立nickname字段SCADA同步端约定用realname兼容昵称展示
userInfo.put("nickname", user.getRealname());
userInfo.put("password", user.getPassword());
userInfo.put("salt", user.getSalt());
userInfo.put("workNo", user.getWorkNo());
userInfo.put("phone", user.getPhone());
userInfo.put("email", user.getEmail());
userInfo.put("orgCode", user.getOrgCode());
userInfo.put("post", user.getPost());
userInfo.put("status", user.getStatus());
userInfo.put("createTime", user.getCreateTime());
userInfo.put("updateTime", user.getUpdateTime());
userInfo.put("lastPwdUpdateTime", user.getLastPwdUpdateTime());
userInfo.put("departIds", user.getDepartIds());
userInfo.put("departmentList", departList);
userInfo.put("companyList", companyList);
userInfo.put("loginTenantId", user.getLoginTenantId());
userInfo.put("tenantList", tenantList);
resultList.add(userInfo);
}
return Result.ok(resultList);
}
/**
* 查询指定用户和部门关联的数据
*

View File

@@ -36,6 +36,12 @@
<artifactId>jeecg-module-xslmes</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<!-- 设备断联续传同步模块 -->
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-module-device-sync</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<!-- flyway 数据库自动升级 -->
<dependency>

View File

@@ -22,7 +22,10 @@ import java.util.Map;
* 报错提醒: 未集成mongo报错可以打开启动类上面的注释 exclude={MongoAutoConfiguration.class}
*/
@Slf4j
@SpringBootApplication(exclude = MongoAutoConfiguration.class)
@SpringBootApplication(
exclude = MongoAutoConfiguration.class,
scanBasePackages = {"org.jeecg"}
)
@ImportAutoConfiguration(JustAuthAutoConfiguration.class) // spring boot 3.x justauth 兼容性处理
public class JeecgSystemApplication extends SpringBootServletInitializer {
@@ -32,6 +35,7 @@ public class JeecgSystemApplication extends SpringBootServletInitializer {
}
public static void main(String[] args) throws UnknownHostException {
SpringApplication app = new SpringApplication(JeecgSystemApplication.class);
Map<String, Object> defaultProperties = new HashMap<>();
defaultProperties.put("management.health.elasticsearch.enabled", false);

View File

@@ -233,7 +233,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
# 短信发送方式 aliyun阿里云短信 tencent腾讯云短信
smsSendType: aliyun
#阿里云oss存储和大鱼短信秘钥配置

View File

@@ -199,7 +199,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
#阿里云oss存储和大鱼短信秘钥配置
oss:
accessKey: ??

View File

@@ -222,7 +222,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
#阿里云oss存储和大鱼短信秘钥配置
oss:
accessKey: ??

View File

@@ -219,7 +219,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
#阿里云oss存储和大鱼短信秘钥配置
oss:
accessKey: ??

View File

@@ -213,7 +213,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
#阿里云oss存储和大鱼短信秘钥配置
oss:
accessKey: ??

View File

@@ -226,7 +226,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
#阿里云oss存储和大鱼短信秘钥配置
oss:
accessKey: ??

View File

@@ -226,7 +226,7 @@ jeecg:
#webapp文件路径
webapp: /opt/jeecg-boot/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/api/getUserInfo
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/api/getUserInfo,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
# 短信发送方式 aliyun阿里云短信 tencent腾讯云短信
smsSendType: aliyun
#阿里云oss存储和大鱼短信秘钥配置

View File

@@ -208,7 +208,7 @@ jeecg:
#webapp文件路径
webapp: /opt/webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
#阿里云oss存储和大鱼短信秘钥配置
oss:
accessKey: ??

View File

@@ -227,7 +227,7 @@ jeecg:
#webapp文件路径
webapp: D://opt//webapp
shiro:
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**
excludeUrls: /test/jeecgDemo/demo3,/test/jeecgDemo/redisDemo/**,/bigscreen/category/**,/bigscreen/visual/**,/bigscreen/map/**,/jmreport/bigscreen2/**,/sys/user/scada/queryUser,/sys/dict/scada/queryDictItem,/sys/log/scada/addLoginLog
# 短信发送方式 aliyun阿里云短信 tencent腾讯云短信
smsSendType: aliyun
#阿里云oss存储和大鱼短信秘钥配置

View File

@@ -88,6 +88,7 @@
<module>jeecg-boot-base-core</module>
<module>jeecg-module-system</module>
<module>jeecg-boot-module</module>
<module>jeecg-module-device-sync</module>
</modules>
<repositories>