更新项目配置,新增设备同步模块,优化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

@@ -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='设备注册表';