From 6a2024aaddfcc82c968b17c51d78fe8f8b11a6c9 Mon Sep 17 00:00:00 2001 From: "han\\hanst" Date: Thu, 23 Oct 2025 19:39:00 +0800 Subject: [PATCH] =?UTF-8?q?=E7=9C=8B=E6=9D=BFwebsocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 +- .../java/com/gaotao/config/ShiroConfig.java | 1 + .../com/gaotao/config/WebSocketConfig.java | 68 ++++ .../modules/dashboard/dao/DashboardDao.java | 39 ++ .../service/DashboardWebSocketService.java | 206 ++++++++++ .../dashboard/task/DashboardPushTask.java | 352 ++++++++++++++++++ .../mapper/dashboard/DashboardDao.xml | 31 ++ 7 files changed, 699 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/gaotao/config/WebSocketConfig.java create mode 100644 src/main/java/com/gaotao/modules/dashboard/dao/DashboardDao.java create mode 100644 src/main/java/com/gaotao/modules/dashboard/service/DashboardWebSocketService.java create mode 100644 src/main/java/com/gaotao/modules/dashboard/task/DashboardPushTask.java create mode 100644 src/main/resources/mapper/dashboard/DashboardDao.xml diff --git a/build.gradle b/build.gradle index 434d09f..3270328 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-freemarker' implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // WebSocket 支持 + implementation 'org.springframework.boot:spring-boot-starter-websocket' //域控管理 implementation 'org.springframework.boot:spring-boot-starter-data-ldap' //邮件的包 @@ -132,7 +134,3 @@ sourceSets { } } } - -jar { - enabled = false -} diff --git a/src/main/java/com/gaotao/config/ShiroConfig.java b/src/main/java/com/gaotao/config/ShiroConfig.java index 8a77576..b7bc184 100644 --- a/src/main/java/com/gaotao/config/ShiroConfig.java +++ b/src/main/java/com/gaotao/config/ShiroConfig.java @@ -51,6 +51,7 @@ public class ShiroConfig { filterMap.put("/api/wms/**", "anon");//wcs、rcs反馈信息 filterMap.put("/api/agv/**", "anon");//agv反馈信息 filterMap.put("/api/dashboard/**", "anon");//看板接口 + filterMap.put("/ws/dashboard/**", "anon");//看板接口 filterMap.put("/swagger/**", "anon"); filterMap.put("/v2/api-docs", "anon"); filterMap.put("/swagger-ui.html", "anon"); diff --git a/src/main/java/com/gaotao/config/WebSocketConfig.java b/src/main/java/com/gaotao/config/WebSocketConfig.java new file mode 100644 index 0000000..e3d9a2f --- /dev/null +++ b/src/main/java/com/gaotao/config/WebSocketConfig.java @@ -0,0 +1,68 @@ +package com.gaotao.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 配置类 + * + *

功能说明:

+ * + * + *

订阅主题说明:

+ * + * + * @author System + * @since 2025-01-23 + */ +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + /** + * 配置消息代理 + * + * @param config 消息代理注册器 + */ + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + // 启用简单消息代理,用于向客户端推送消息 + // /topic - 公共广播(一对多) + // /queue - 点对点(一对一) + config.enableSimpleBroker("/topic", "/queue"); + + // 客户端发送消息的目的地前缀 + config.setApplicationDestinationPrefixes("/app"); + + // 点对点消息的用户前缀 + config.setUserDestinationPrefix("/user"); + } + + /** + * 注册STOMP端点 + * + * @param registry STOMP端点注册器 + */ + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + // 注册WebSocket端点:/ws/dashboard + // 前端通过此端点建立WebSocket连接 + registry.addEndpoint("/ws/dashboard") + .setAllowedOriginPatterns("*") // 允许所有跨域访问 + .withSockJS(); // 启用SockJS降级支持(当浏览器不支持WebSocket时) + } +} + diff --git a/src/main/java/com/gaotao/modules/dashboard/dao/DashboardDao.java b/src/main/java/com/gaotao/modules/dashboard/dao/DashboardDao.java new file mode 100644 index 0000000..0f13359 --- /dev/null +++ b/src/main/java/com/gaotao/modules/dashboard/dao/DashboardDao.java @@ -0,0 +1,39 @@ +package com.gaotao.modules.dashboard.dao; + +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; +import java.util.Map; + +/** + * 看板数据访问接口 + * + *

功能说明:从数据库视图获取看板实时数据

+ * + *

数据来源:

+ * + * + * @author System + * @since 2025-01-23 + */ +@Mapper +public interface DashboardDao { + + /** + * 查询分切区助力臂数据 + * + * @return 助力臂区实时数据 + */ + List> querySlittingAssistArmData(); + + /** + * 查询分切区入库数据 + * + * @return 分切入库区实时数据 + */ + List> querySlittingInboundData(); +} + diff --git a/src/main/java/com/gaotao/modules/dashboard/service/DashboardWebSocketService.java b/src/main/java/com/gaotao/modules/dashboard/service/DashboardWebSocketService.java new file mode 100644 index 0000000..71e3472 --- /dev/null +++ b/src/main/java/com/gaotao/modules/dashboard/service/DashboardWebSocketService.java @@ -0,0 +1,206 @@ +package com.gaotao.modules.dashboard.service; + +import com.gaotao.common.utils.R; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * 看板WebSocket推送服务 + * + *

功能说明:

+ *
    + *
  • 向订阅的前端看板推送实时数据
  • + *
  • 支持按看板类型定向推送
  • + *
  • 支持广播推送到所有看板
  • + *
+ * + *

订阅主题列表:

+ *
    + *
  • /topic/dashboard/manual-picking - 人工拣选看板
  • + *
  • /topic/dashboard/robot-picking - 机械臂拣选看板
  • + *
  • /topic/dashboard/slitting-board - 分切区看板
  • + *
  • /topic/dashboard/inventory-board - 库存分析看板
  • + *
+ * + * @author System + * @since 2025-01-23 + */ +@Slf4j +@Service +public class DashboardWebSocketService { + + @Autowired + private SimpMessagingTemplate messagingTemplate; + + /** + * 推送人工拣选看板数据 + * + * @param data 看板数据 + */ + public void pushManualPickingData(Map data) { + log.debug("推送人工拣选看板数据"); + try { + messagingTemplate.convertAndSend("/topic/dashboard/manual-picking", + R.ok().put("data", data)); + } catch (Exception e) { + log.error("推送人工拣选看板数据失败: {}", e.getMessage(), e); + } + } + + /** + * 推送机械臂拣选看板数据 + * + * @param data 看板数据 + */ + public void pushRobotPickingData(Map data) { + log.debug("推送机械臂拣选看板数据: 周转箱{}条, 原材{}条", + data.get("containerList") != null ? ((java.util.List)data.get("containerList")).size() : 0, + data.get("materialList") != null ? ((java.util.List)data.get("materialList")).size() : 0); + try { + messagingTemplate.convertAndSend("/topic/dashboard/robot-picking", + R.ok().put("data", data)); + } catch (Exception e) { + log.error("推送机械臂拣选看板数据失败: {}", e.getMessage(), e); + } + } + + /** + * 推送分切区看板数据 + * + * @param data 看板数据 + */ + public void pushSlittingBoardData(Map data) { + log.debug("推送分切区看板数据"); + try { + messagingTemplate.convertAndSend("/topic/dashboard/slitting-board", + R.ok().put("data", data)); + } catch (Exception e) { + log.error("推送分切区看板数据失败: {}", e.getMessage(), e); + } + } + + /** + * 推送库存分析看板数据 + * + * @param data 看板数据 + */ + public void pushInventoryBoardData(Map data) { + log.debug("推送库存分析看板数据"); + try { + messagingTemplate.convertAndSend("/topic/dashboard/inventory-board", + R.ok().put("data", data)); + } catch (Exception e) { + log.error("推送库存分析看板数据失败: {}", e.getMessage(), e); + } + } + + /** + * 推送成品入库出库区看板数据 + * + * @param data 看板数据 + */ + public void pushFinishedProductBoardData(Map data) { + log.debug("推送成品入库出库区看板数据"); + try { + messagingTemplate.convertAndSend("/topic/dashboard/finished-product-board", + R.ok().put("data", data)); + } catch (Exception e) { + log.error("推送成品入库出库区看板数据失败: {}", e.getMessage(), e); + } + } + + /** + * 推送原材收货区看板数据 + * + * @param data 看板数据 + */ + public void pushMaterialReceivingBoardData(Map data) { + log.debug("推送原材收货区看板数据"); + try { + messagingTemplate.convertAndSend("/topic/dashboard/material-receiving-board", + R.ok().put("data", data)); + } catch (Exception e) { + log.error("推送原材收货区看板数据失败: {}", e.getMessage(), e); + } + } + + /** + * 推送缓存区看板数据 + * + * @param data 看板数据 + */ + public void pushBufferBoardData(Map data) { + log.debug("推送缓存区看板数据"); + try { + messagingTemplate.convertAndSend("/topic/dashboard/buffer-board", + R.ok().put("data", data)); + } catch (Exception e) { + log.error("推送缓存区看板数据失败: {}", e.getMessage(), e); + } + } + + /** + * 推送车间AGV放料区看板数据 + * + * @param data 看板数据 + */ + public void pushWorkshopFeedingBoardData(Map data) { + log.debug("推送车间AGV放料区看板数据"); + try { + messagingTemplate.convertAndSend("/topic/dashboard/workshop-feeding-board", + R.ok().put("data", data)); + } catch (Exception e) { + log.error("推送车间AGV放料区看板数据失败: {}", e.getMessage(), e); + } + } + + /** + * 推送异常处理区看板数据 + * + * @param data 看板数据 + */ + public void pushExceptionBoardData(Map data) { + log.debug("推送异常处理区看板数据"); + try { + messagingTemplate.convertAndSend("/topic/dashboard/exception-board", + R.ok().put("data", data)); + } catch (Exception e) { + log.error("推送异常处理区看板数据失败: {}", e.getMessage(), e); + } + } + + /** + * 广播推送到所有看板 + * + * @param data 看板数据 + */ + public void broadcastToAllDashboards(Map data) { + log.info("广播推送到所有看板"); + try { + messagingTemplate.convertAndSend("/topic/dashboard/broadcast", + R.ok().put("data", data)); + } catch (Exception e) { + log.error("广播推送到所有看板失败: {}", e.getMessage(), e); + } + } + + /** + * 推送到指定主题 + * + * @param topic 主题名称 + * @param data 数据 + */ + public void pushToTopic(String topic, Object data) { + log.debug("推送数据到主题: {}", topic); + try { + messagingTemplate.convertAndSend(topic, R.ok().put("data", data)); + } catch (Exception e) { + log.error("推送数据到主题{}失败: {}", topic, e.getMessage(), e); + } + } +} + diff --git a/src/main/java/com/gaotao/modules/dashboard/task/DashboardPushTask.java b/src/main/java/com/gaotao/modules/dashboard/task/DashboardPushTask.java new file mode 100644 index 0000000..723af8b --- /dev/null +++ b/src/main/java/com/gaotao/modules/dashboard/task/DashboardPushTask.java @@ -0,0 +1,352 @@ +package com.gaotao.modules.dashboard.task; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.gaotao.common.utils.HttpUtils; +import com.gaotao.modules.dashboard.service.DashboardWebSocketService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.*; + +/** + * 看板数据推送定时任务 + * + *

功能说明:

+ *
    + *
  • 定时从WCS Board API获取最新数据
  • + *
  • 检测到数据变更后通过WebSocket推送到前端
  • + *
  • 相比轮询,只在数据变更时推送,减少无效传输
  • + *
+ * + *

推送策略:

+ *
    + *
  • 每5秒检查一次机械臂拣选数据
  • + *
  • 数据有变化时立即推送
  • + *
  • 推送失败时记录日志,不影响下次推送
  • + *
+ * + * @author System + * @since 2025-01-23 + */ +@Slf4j +@Component +public class DashboardPushTask { + + @Autowired + private DashboardWebSocketService webSocketService; + + @Value("${custom.wcs-board-api}") + private String wcsBoardApi; + + /** + * 上次推送的数据哈希值(用于检测数据变更) + */ + private Map lastDataHash = new HashMap<>(); + + @Autowired + private com.gaotao.modules.dashboard.dao.DashboardDao dashboardDao; + + /** + * 每5秒检查机械臂拣选数据并推送 + * + *

注意:这个间隔可以根据实际需求调整

+ *
    + *
  • 如果数据变化频繁,可以缩短间隔(如2-3秒)
  • + *
  • 如果数据变化不频繁,可以延长间隔(如10-15秒)
  • + *
+ */ + @Scheduled(fixedRate = 5000) + public void pushRobotPickingData() { + try { + // 从WCS Board API获取机械臂拣选数据 + Map data = getRobotPickingDataFromWcs(); + + // 如果返回null,转换为空数据(避免前端显示过期数据) + if (data == null) { + data = createEmptyData(); + } + + // 计算数据哈希值 + int currentHash = data.hashCode(); + int lastHash = lastDataHash.getOrDefault("robot-picking", 0); + + // 只在数据变更时推送(包括从有数据变为空数据) + if (currentHash != lastHash) { + boolean isEmpty = isDataEmpty(data); + if (isEmpty) { + log.info("=== 机械臂拣选数据为空,推送空数据清空前端列表 ==="); + } else { + int containerCount = ((List) data.get("containerList")).size(); + int materialCount = ((List) data.get("materialList")).size(); + log.info("=== 检测到机械臂拣选数据变更,推送到前端(周转箱:{}条,原材:{}条)===", + containerCount, materialCount); + } + webSocketService.pushRobotPickingData(data); + lastDataHash.put("robot-picking", currentHash); + } else { + log.debug("机械臂拣选数据无变化,跳过推送"); + } + + } catch (Exception e) { + log.error("推送机械臂拣选数据失败,推送空数据清空前端列表: {}", e.getMessage(), e); + // 异常时推送空数据,避免前端显示过期数据 + try { + Map emptyData = createEmptyData(); + webSocketService.pushRobotPickingData(emptyData); + lastDataHash.put("robot-picking", emptyData.hashCode()); + } catch (Exception ex) { + log.error("推送空数据失败: {}", ex.getMessage()); + } + } + } + + /** + * 从WCS Board API获取机械臂拣选数据 + * + * @return 机械臂拣选数据 + */ + private Map getRobotPickingDataFromWcs() { + try { + // 调用WCS Board API + String url = wcsBoardApi + "WmsDashboard/auto-sorting-info"; + String wcsResponse = HttpUtils.doGet(url, null, null); + + // 解析JSON数据 + ObjectMapper mapper = new ObjectMapper(); + JsonNode rootNode = mapper.readTree(wcsResponse); + + // 检查返回码 + int resCode = rootNode.get("resCode").asInt(); + if (resCode != 200) { + log.warn("WCS API返回错误: code={}", resCode); + return null; + } + + // 获取sortingStations数组 + JsonNode resData = rootNode.get("resData"); + if (resData == null || !resData.has("sortingStations")) { + log.warn("WCS返回数据中没有sortingStations"); + return createEmptyData(); + } + + JsonNode sortingStations = resData.get("sortingStations"); + + // 按照sortingStation分类处理数据 + List> containerList = new ArrayList<>(); // 1071-周转箱 + List> materialList = new ArrayList<>(); // 1060-原材 + + for (JsonNode station : sortingStations) { + String sortingStation = station.get("sortingStation").asText(); + JsonNode materials = station.get("materials"); + + if (materials != null && materials.isArray()) { + for (JsonNode material : materials) { + Map item = convertMaterialToItem(material, sortingStation); + + // 根据工作站分类 + if ("1071".equals(sortingStation)) { + containerList.add(item); + } else if ("1060".equals(sortingStation)) { + materialList.add(item); + } + } + } + } + + // 构造返回数据 + Map resultData = new HashMap<>(); + resultData.put("containerList", containerList); + resultData.put("materialList", materialList); + + return resultData; + + } catch (Exception e) { + log.error("从WCS获取机械臂拣选数据失败: {}", e.getMessage()); + return null; + } + } + + /** + * 将WCS返回的material数据转换为前端需要的格式 + * + * @param material WCS返回的物料数据 + * @param sortingStation 工作站编号 + * @return 前端表格数据格式 + */ + private Map convertMaterialToItem(JsonNode material, String sortingStation) { + Map item = new HashMap<>(); + + // 拣选托盘码 (源托盘) + item.put("pickingBatchNo", material.has("sourcePalletCode") ? + material.get("sourcePalletCode").asText() : ""); + + // 拣选物料名称 (SKU) + item.put("pickingMaterialName", material.has("sku") ? + material.get("sku").asText() : ""); + + // RFID条码 + item.put("rfidBarcode", material.has("rfidBarcode") ? + material.get("rfidBarcode").asText() : ""); + + // 状态 (根据isCompleted判断) + boolean isCompleted = material.has("isCompleted") && material.get("isCompleted").asBoolean(); + item.put("status", isCompleted ? "完成" : "等待分拣"); + + // 存放托盘码 (目标托盘) + item.put("storageBatchNo", material.has("targetPalletCode") ? + material.get("targetPalletCode").asText() : ""); + + // 存放位置 (工作站编号) + item.put("storageLocation", material.has("sortingStation") ? + material.get("sortingStation").asText() : sortingStation); + + return item; + } + + /** + * 创建空数据 + * + * @return 空的数据结构 + */ + private Map createEmptyData() { + Map emptyData = new HashMap<>(); + emptyData.put("containerList", new ArrayList<>()); + emptyData.put("materialList", new ArrayList<>()); + return emptyData; + } + + /** + * 判断数据是否为空 + * + * @param data 待检查的数据 + * @return true=数据为空,false=数据不为空 + */ + private boolean isDataEmpty(Map data) { + if (data == null || data.isEmpty()) { + return true; + } + + List containerList = (List) data.get("containerList"); + List materialList = (List) data.get("materialList"); + + return (containerList == null || containerList.isEmpty()) + && (materialList == null || materialList.isEmpty()); + } + + /** + * 每5秒检查分切区看板数据并推送 + * + *

数据来源:

+ *
    + *
  • view_board_slitting_assist_arm - 助力臂区数据
  • + *
  • view_board_slitting_inbound - 分切入库区数据
  • + *
+ */ + @Scheduled(fixedRate = 5000) + public void pushSlittingBoardData() { + try { + // 从数据库视图获取分切区数据 + Map data = getSlittingBoardDataFromDb(); + + // 如果返回null,转换为空数据 + if (data == null) { + data = createEmptySlittingData(); + } + + // 计算数据哈希值 + int currentHash = data.hashCode(); + int lastHash = lastDataHash.getOrDefault("slitting-board", 0); + + // 只在数据变更时推送 + if (currentHash != lastHash) { + boolean isEmpty = isSlittingDataEmpty(data); + if (isEmpty) { + log.info("=== 分切区看板数据为空,推送空数据清空前端列表 ==="); + } else { + int assistArmCount = ((List) data.get("assistArmList")).size(); + int inboundCount = ((List) data.get("slittingInboundList")).size(); + log.info("=== 检测到分切区看板数据变更,推送到前端(助力臂:{}条,入库:{}条)===", + assistArmCount, inboundCount); + } + webSocketService.pushSlittingBoardData(data); + lastDataHash.put("slitting-board", currentHash); + } else { + log.debug("分切区看板数据无变化,跳过推送"); + } + + } catch (Exception e) { + log.error("推送分切区看板数据失败,推送空数据清空前端列表: {}", e.getMessage(), e); + // 异常时推送空数据,避免前端显示过期数据 + try { + Map emptyData = createEmptySlittingData(); + webSocketService.pushSlittingBoardData(emptyData); + lastDataHash.put("slitting-board", emptyData.hashCode()); + } catch (Exception ex) { + log.error("推送空数据失败: {}", ex.getMessage()); + } + } + } + + /** + * 从数据库视图获取分切区看板数据 + * + * @return 分切区看板数据 + */ + private Map getSlittingBoardDataFromDb() { + try { + // 查询助力臂区数据 + List> assistArmList = dashboardDao.querySlittingAssistArmData(); + log.debug("查询到助力臂区数据: {}条", assistArmList != null ? assistArmList.size() : 0); + + // 查询分切入库区数据 + List> inboundList = dashboardDao.querySlittingInboundData(); + log.debug("查询到分切入库区数据: {}条", inboundList != null ? inboundList.size() : 0); + + // 构造返回数据 + Map resultData = new HashMap<>(); + resultData.put("assistArmList", assistArmList != null ? assistArmList : new ArrayList<>()); + resultData.put("slittingInboundList", inboundList != null ? inboundList : new ArrayList<>()); + + return resultData; + + } catch (Exception e) { + log.error("从数据库获取分切区看板数据失败: {}", e.getMessage(), e); + return null; + } + } + + /** + * 创建空的分切区数据 + * + * @return 空的分切区数据结构 + */ + private Map createEmptySlittingData() { + Map emptyData = new HashMap<>(); + emptyData.put("assistArmList", new ArrayList<>()); + emptyData.put("slittingInboundList", new ArrayList<>()); + return emptyData; + } + + /** + * 判断分切区数据是否为空 + * + * @param data 待检查的数据 + * @return true=数据为空,false=数据不为空 + */ + private boolean isSlittingDataEmpty(Map data) { + if (data == null || data.isEmpty()) { + return true; + } + + List assistArmList = (List) data.get("assistArmList"); + List inboundList = (List) data.get("slittingInboundList"); + + return (assistArmList == null || assistArmList.isEmpty()) + && (inboundList == null || inboundList.isEmpty()); + } +} + diff --git a/src/main/resources/mapper/dashboard/DashboardDao.xml b/src/main/resources/mapper/dashboard/DashboardDao.xml new file mode 100644 index 0000000..1870452 --- /dev/null +++ b/src/main/resources/mapper/dashboard/DashboardDao.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + +