فهرست منبع

Merge remote-tracking branch 'origin/master'

null 4 روز پیش
والد
کامیت
e589dfa6c5
77فایلهای تغییر یافته به همراه2209 افزوده شده و 290 حذف شده
  1. 2 0
      audio-service/src/main/java/com/zksy/audio/AudioApplication.java
  2. 16 0
      audio-service/src/main/java/com/zksy/audio/config/RestTemplateConfig.java
  3. 42 12
      audio-service/src/main/java/com/zksy/audio/utils/DeviceOfflineCheckTask.java
  4. 5 0
      audio-service/src/main/java/com/zksy/audio/utils/MessageHandler.java
  5. 2 0
      environment-service/src/main/java/com/zksy/environment/EnvironmentApplication.java
  6. 8 0
      environment-service/src/main/java/com/zksy/environment/config/RSServerService.java
  7. 79 0
      environment-service/src/main/java/com/zksy/environment/utils/DeviceOfflineCheckTask.java
  8. 2 7
      firefighting-pressure-service/pom.xml
  9. 55 12
      firefighting-pressure-service/src/main/java/com/zksy/pressure/utils/DeviceOfflineCheckTask.java
  10. 7 3
      firefighting-pressure-service/src/main/java/com/zksy/pressure/utils/MessageHandler.java
  11. 42 12
      flammable-gas-service/src/main/java/com/zksy/gas/utils/DeviceOfflineCheckTask.java
  12. 6 0
      flammable-gas-service/src/main/java/com/zksy/gas/utils/MessageHandler.java
  13. 1 2
      manhole-service/pom.xml
  14. 16 6
      manhole-service/src/main/java/com/zksy/manhole/service/impl/BaseDevicesManholeServiceImpl.java
  15. 54 11
      manhole-service/src/main/java/com/zksy/manhole/utils/DeviceOfflineCheckTask.java
  16. 6 1
      manhole-service/src/main/java/com/zksy/manhole/utils/MessageHandler.java
  17. 4 0
      pipe-network-service/CONTEXT.md
  18. 13 7
      pipe-network-service/sql/water_supply_facility_test_data.sql
  19. 1 1
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/PipeNetworkStatistics/PipeNetworkStatisticsController.java
  20. 7 6
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/WaterFacilityExportController.java
  21. 4 2
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/WaterPipeInfo/WaterPipeInfoController.java
  22. 20 1
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/WaterSupplyGisMap/WaterSupplyGisMapController.java
  23. 9 9
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/WaterUserInfo/WaterUserInfoController.java
  24. 20 16
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusRedisListener.java
  25. 173 8
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusWebSocketHandler.java
  26. 3 1
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/RedisPubSubConfig.java
  27. 153 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/WorkOrderWebSocketPushTask.java
  28. 23 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/WaterSupply/PipeNetworkStatistics/PipeNetworkStatisticsControllerTerminologyTest.java
  29. 32 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/WaterSupply/WaterFacilityExportControllerContractTest.java
  30. 53 0
      pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/WaterSupply/WaterSupplyGisMap/WaterSupplyGisMapControllerTest.java
  31. 26 0
      pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/web/service/TokenService.java
  32. 1 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/PipeNetworkStatistics/service/impl/PipeNetworkStatisticsExportServiceImpl.java
  33. 3 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterPipeInfo/service/IWaterPipeInfoService.java
  34. 14 7
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterPipeInfo/service/impl/WaterPipeInfoServiceImpl.java
  35. 11 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterPlantInfo/service/impl/WaterPlantInfoServiceImpl.java
  36. 11 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterPumpStation/service/impl/WaterPumpStationServiceImpl.java
  37. 11 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterSourceInfo/service/impl/WaterSourceInfoServiceImpl.java
  38. 52 8
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterSupplyGisMap/service/impl/WaterSupplyGisMapServiceImpl.java
  39. 6 6
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterUserInfo/domain/WaterUserInfo.java
  40. 11 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterUserInfo/service/impl/WaterUserInfoServiceImpl.java
  41. 2 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/export/WaterPipeExportDTO.java
  42. 3 3
      pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/export/WaterUserExportDTO.java
  43. 12 6
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/PipeNetworkBase.java
  44. 74 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/vo/WorkOrderPushVO.java
  45. 80 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/event/DeviceStatusChangePublisher.java
  46. 8 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/mapper/WorkOrderMapper.java
  47. 7 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/BusinessWorkOrderService.java
  48. 48 44
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/BusinessWorkOrderServiceImpl.java
  49. 24 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/EquipmentBaseServiceImpl.java
  50. 74 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/EquipmentStatusServiceImpl.java
  51. 21 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/WaterSupplyDeviceServiceImpl.java
  52. 1 1
      pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/PipeNetworkStatistics/PipeNetworkStatisticsMapper.xml
  53. 8 8
      pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/WaterPipeInfo/WaterPipeInfoMapper.xml
  54. 4 4
      pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/WaterPlantInfo/WaterPlantInfoMapper.xml
  55. 4 4
      pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/WaterPumpStation/WaterPumpStationMapper.xml
  56. 4 4
      pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/WaterSourceInfo/WaterSourceInfoMapper.xml
  57. 1 1
      pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/WaterSupplyGisMap/WaterSupplyGisMapMapper.xml
  58. 4 4
      pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/WaterUserInfo/WaterUserInfoMapper.xml
  59. 15 0
      pipe-network-service/zksy-system/src/main/resources/mapper/WorkOrderMapper.xml
  60. 27 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/FacilityCoordinateModelTest.java
  61. 138 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/WaterFacilityQueryServiceTest.java
  62. 143 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/WaterSupplyGisMap/WaterSupplyGisMapServiceTest.java
  63. 133 0
      pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/WaterUserTerminologyTest.java
  64. 1 1
      pom.xml
  65. 42 12
      radar-service/src/main/java/com/zksy/radar/utils/DeviceOfflineCheckTask.java
  66. 6 0
      radar-service/src/main/java/com/zksy/radar/utils/MessageHandler.java
  67. 2 0
      telemetry-service/src/main/java/com/zksy/telemetry/TelemetryApplication.java
  68. 16 0
      telemetry-service/src/main/java/com/zksy/telemetry/config/RestTemplateConfig.java
  69. 42 12
      telemetry-service/src/main/java/com/zksy/telemetry/utils/DeviceOfflineCheckTask.java
  70. 7 0
      telemetry-service/src/main/java/com/zksy/telemetry/utils/MessageHandler.java
  71. 42 12
      water-level-service/src/main/java/com/zksy/water/utils/DeviceOfflineCheckTask.java
  72. 6 0
      water-level-service/src/main/java/com/zksy/water/utils/MessageHandler.java
  73. 6 1
      zk-api-service/pom.xml
  74. 35 0
      zk-api-service/src/main/java/com/zksy/api/controller/EquipmentStatusController.java
  75. 73 0
      zk-api-service/src/main/java/com/zksy/api/event/DeviceStatusChangePublisher.java
  76. 9 1
      zk-api-service/src/main/java/com/zksy/api/service/EquipmentStatusApiService.java
  77. 83 3
      zk-api-service/src/main/java/com/zksy/api/service/impl/EquipmentStatusApiServiceImpl.java

+ 2 - 0
audio-service/src/main/java/com/zksy/audio/AudioApplication.java

@@ -4,7 +4,9 @@ package com.zksy.audio;
 import org.mybatis.spring.annotation.MapperScan;
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
 
+@EnableScheduling
 @MapperScan(basePackages = "com.zksy.audio.mapper")
 @SpringBootApplication(scanBasePackages = {"com.zksy.audio","com.zksy.api"})
 public class AudioApplication {

+ 16 - 0
audio-service/src/main/java/com/zksy/audio/config/RestTemplateConfig.java

@@ -0,0 +1,16 @@
+package com.zksy.audio.config;
+
+import org.springframework.cloud.client.loadbalancer.LoadBalanced;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestTemplate;
+
+@Configuration
+public class RestTemplateConfig {
+
+    @Bean
+    @LoadBalanced
+    public RestTemplate restTemplate() {
+        return new RestTemplate();
+    }
+}

+ 42 - 12
audio-service/src/main/java/com/zksy/audio/utils/DeviceOfflineCheckTask.java

@@ -1,36 +1,66 @@
-package com.zksy.telemetry.utils;/*
-package com.zksy.gasTransmitter.utils;
+package com.zksy.audio.utils;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
 
 import java.util.Date;
-import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
 @Component
 public class DeviceOfflineCheckTask {
     private static final Logger logger = LoggerFactory.getLogger(DeviceOfflineCheckTask.class);
 
-    // 用于存储设备编号及其最后一次接收数据的时间
     public static ConcurrentHashMap<String, Date> deviceLastReceiveTimeMap = new ConcurrentHashMap<>();
+
+    private final Set<String> offlineDeviceSet = ConcurrentHashMap.newKeySet();
+
     @Autowired
-    private BaseDevicesService baseDevicesService;
+    private RestTemplate restTemplate;
+
+    @Value("${device.offline.timeout-minutes:30}")
+    private int offlineTimeoutMinutes;
 
-    // 定时检查设备是否离线
-    @Scheduled(fixedRate = 24 * 60 * 60 * 1000) // 每24小时执行一次
+    @Scheduled(fixedRateString = "${device.offline.check-interval-ms:300000}")
     public void checkDeviceOffline() {
         Date now = new Date();
+        long timeoutMs = (long) offlineTimeoutMinutes * 60 * 1000;
         for (Map.Entry<String, Date> entry : deviceLastReceiveTimeMap.entrySet()) {
             long diff = now.getTime() - entry.getValue().getTime();
-            // 如果设备在 23 小时内没有接收数据,则认为设备离线
-            if (diff > 23 * 60 * 60 * 1000) {
-                baseDevicesService.getByDeviceNumberStatus(entry.getKey(), 1, 0);
-                logger.info("设备 {} 已离线", entry.getKey());
+            if (diff > timeoutMs) {
+                if (offlineDeviceSet.add(entry.getKey())) {
+                    updateDeviceOnlineStatus(entry.getKey(), 0);
+                    logger.info("设备 {} 已离线(超过{}分钟未收到数据)", entry.getKey(), offlineTimeoutMinutes);
+                }
             }
         }
     }
-}*/
+
+    public void markDeviceOnline(String deviceCode) {
+        if (offlineDeviceSet.remove(deviceCode)) {
+            updateDeviceOnlineStatus(deviceCode, 1);
+            logger.info("设备 {} 恢复在线", deviceCode);
+        }
+    }
+
+    private void updateDeviceOnlineStatus(String deviceCode, int onlineStatus) {
+        try {
+            Map<String, Object> params = Map.of("deviceCode", deviceCode, "onlineStatus", onlineStatus);
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<Map<String, Object>> request = new HttpEntity<>(params, headers);
+            restTemplate.postForObject("http://zk-api-service/equipmentStatus/updateOnlineStatus", request, Map.class);
+        } catch (Exception e) {
+            logger.error("更新设备在线状态失败: deviceCode={}, onlineStatus={}", deviceCode, onlineStatus, e);
+        }
+    }
+}

+ 5 - 0
audio-service/src/main/java/com/zksy/audio/utils/MessageHandler.java

@@ -52,6 +52,8 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 	private final static String head = "524946462440010057415645666D742010000000010001000020000000400000020010006461746100400100";
 	private final NoiseInfoService service;
 	@Autowired
+	private DeviceOfflineCheckTask deviceOfflineCheckTask;
+	@Autowired
 	public MessageHandler(NoiseInfoService noiseInfoService) {
 		this.service = noiseInfoService;
 	}
@@ -288,6 +290,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 							DatePattern.NORM_DATETIME_PATTERN));
 					//log.info(JsonUtils.toJsonString(noise));
 					service.save(noise);
+					// 更新设备最后接收时间
+					DeviceOfflineCheckTask.deviceLastReceiveTimeMap.put(info.getDeviceNo(), new java.util.Date());
+					deviceOfflineCheckTask.markDeviceOnline(info.getDeviceNo());
 					//设备默认上报三次音频数据, 数据接收完成回复设备Ok,停止上报
 					byte[] cmdBytes = "OK\r\n".getBytes(StandardCharsets.US_ASCII);
 					ctx.channel().writeAndFlush(Unpooled.copiedBuffer(cmdBytes));

+ 2 - 0
environment-service/src/main/java/com/zksy/environment/EnvironmentApplication.java

@@ -5,7 +5,9 @@ import org.mybatis.spring.annotation.MapperScan;
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
 
+@EnableScheduling
 @MapperScan({
         "com.zksy.environment.mapper",
         "com.zksy.base.mapper",

+ 8 - 0
environment-service/src/main/java/com/zksy/environment/config/RSServerService.java

@@ -6,6 +6,7 @@ import com.zksy.api.utils.SmsUtil;
 import com.zksy.environment.domain.ERealTimeData;
 import com.zksy.environment.mapper.ERealTimeDataMapper;
 import com.zksy.environment.utils.AlarmUtil;
+import com.zksy.environment.utils.DeviceOfflineCheckTask;
 import com.zksy.utils.DevicePhoneFetchUtil;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -22,6 +23,7 @@ import javax.annotation.PreDestroy;
 import java.math.BigDecimal;
 import java.text.SimpleDateFormat;
 import java.time.LocalDateTime;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -58,6 +60,9 @@ public class RSServerService {
     @Autowired
     private RestTemplate restTemplate;
 
+    @Autowired
+    private DeviceOfflineCheckTask deviceOfflineCheckTask;
+
     @Value("${device.status.api.url:http://zk-api-service/equipmentStatus/updateAlarmStatus}")
     private String deviceStatusApiUrl;
 
@@ -130,6 +135,9 @@ public class RSServerService {
                 @Override
                 public void receiveRealtimeData(RealTimeData data) {
                     String deviceId = String.valueOf(data.getDeviceId());
+                    // 更新设备最后接收数据时间,并标记设备在线
+                    DeviceOfflineCheckTask.deviceLastReceiveTimeMap.put(deviceId, new Date());
+                    deviceOfflineCheckTask.markDeviceOnline(deviceId);
                     boolean hasAlarm = false;
                     for (NodeData nd : data.getNodeList()) {
                         try {

+ 79 - 0
environment-service/src/main/java/com/zksy/environment/utils/DeviceOfflineCheckTask.java

@@ -0,0 +1,79 @@
+package com.zksy.environment.utils;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.Date;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 设备离线检测定时任务
+ * 通过 REST API 更新设备在线状态
+ */
+@Component
+public class DeviceOfflineCheckTask {
+    private static final Logger logger = LoggerFactory.getLogger(DeviceOfflineCheckTask.class);
+
+    /** 设备最后接收数据时间 */
+    public static ConcurrentHashMap<String, Date> deviceLastReceiveTimeMap = new ConcurrentHashMap<>();
+
+    /** 已知离线设备集合,用于避免重复API调用 */
+    private final Set<String> offlineDeviceSet = ConcurrentHashMap.newKeySet();
+
+    @Autowired
+    private RestTemplate restTemplate;
+
+    /** 离线判定超时,单位:分钟,默认30分钟 */
+    @Value("${device.offline.timeout-minutes:30}")
+    private int offlineTimeoutMinutes;
+
+    /** 检查间隔,单位:毫秒,默认5分钟 */
+    @Scheduled(fixedRateString = "${device.offline.check-interval-ms:300000}")
+    public void checkDeviceOffline() {
+        Date now = new Date();
+        long timeoutMs = (long) offlineTimeoutMinutes * 60 * 1000;
+        for (Map.Entry<String, Date> entry : deviceLastReceiveTimeMap.entrySet()) {
+            long diff = now.getTime() - entry.getValue().getTime();
+            if (diff > timeoutMs) {
+                // 只在设备不在离线集合中时才调用API
+                if (offlineDeviceSet.add(entry.getKey())) {
+                    updateDeviceOnlineStatus(entry.getKey(), 0);
+                    logger.info("设备 {} 已离线(超过{}分钟未收到数据)", entry.getKey(), offlineTimeoutMinutes);
+                }
+            }
+        }
+    }
+
+    /**
+     * 标记设备在线(收到数据时调用)
+     * 只在设备当前处于离线状态时才调用API
+     */
+    public void markDeviceOnline(String deviceCode) {
+        if (offlineDeviceSet.remove(deviceCode)) {
+            updateDeviceOnlineStatus(deviceCode, 1);
+            logger.info("设备 {} 恢复在线", deviceCode);
+        }
+    }
+
+    private void updateDeviceOnlineStatus(String deviceCode, int onlineStatus) {
+        try {
+            Map<String, Object> params = Map.of("deviceCode", deviceCode, "onlineStatus", onlineStatus);
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<Map<String, Object>> request = new HttpEntity<>(params, headers);
+            restTemplate.postForObject("http://zk-api-service/equipmentStatus/updateOnlineStatus", request, Map.class);
+        } catch (Exception e) {
+            logger.error("更新设备在线状态失败: deviceCode={}, onlineStatus={}", deviceCode, onlineStatus, e);
+        }
+    }
+}

+ 2 - 7
firefighting-pressure-service/pom.xml

@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="UTF-8"?>
+<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
@@ -34,12 +34,7 @@
             <artifactId>zksy-system</artifactId>
             <version>3.9.0</version>
         </dependency>
-        <dependency>
-            <groupId>org.mybatis.spring.boot</groupId>
-            <artifactId>mybatis-spring-boot-starter-test</artifactId>
-            <version>3.0.5</version>
-            <scope>test</scope>
-        </dependency>
+        <!-- mybatis-spring-boot-starter-test 已由 mybatis-plus-boot-starter 提供兼容版本,无需单独引入 -->
         <dependency>
             <groupId>org.springframework.boot</groupId>
             <artifactId>spring-boot-starter-web</artifactId>

+ 55 - 12
firefighting-pressure-service/src/main/java/com/zksy/pressure/utils/DeviceOfflineCheckTask.java

@@ -1,36 +1,79 @@
-package com.zksy.pressure.utils;/*
-package com.zksy.gasTransmitter.utils;
+package com.zksy.pressure.utils;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
 
 import java.util.Date;
-import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
+/**
+ * 设备离线检测定时任务
+ * 通过 REST API 更新设备在线状态
+ */
 @Component
 public class DeviceOfflineCheckTask {
     private static final Logger logger = LoggerFactory.getLogger(DeviceOfflineCheckTask.class);
 
-    // 用于存储设备编号及其最后一次接收数据的时间
+    /** 设备最后接收数据时间 */
     public static ConcurrentHashMap<String, Date> deviceLastReceiveTimeMap = new ConcurrentHashMap<>();
+
+    /** 已知离线设备集合,用于避免重复API调用 */
+    private final Set<String> offlineDeviceSet = ConcurrentHashMap.newKeySet();
+
     @Autowired
-    private BaseDevicesService baseDevicesService;
+    private RestTemplate restTemplate;
+
+    /** 离线判定超时,单位:分钟,默认30分钟 */
+    @Value("${device.offline.timeout-minutes:30}")
+    private int offlineTimeoutMinutes;
 
-    // 定时检查设备是否离线
-    @Scheduled(fixedRate = 24 * 60 * 60 * 1000) // 每24小时执行一次
+    /** 检查间隔,单位:毫秒,默认5分钟 */
+    @Scheduled(fixedRateString = "${device.offline.check-interval-ms:300000}")
     public void checkDeviceOffline() {
         Date now = new Date();
+        long timeoutMs = (long) offlineTimeoutMinutes * 60 * 1000;
         for (Map.Entry<String, Date> entry : deviceLastReceiveTimeMap.entrySet()) {
             long diff = now.getTime() - entry.getValue().getTime();
-            // 如果设备在 23 小时内没有接收数据,则认为设备离线
-            if (diff > 23 * 60 * 60 * 1000) {
-                baseDevicesService.getByDeviceNumberStatus(entry.getKey(), 1, 0);
-                logger.info("设备 {} 已离线", entry.getKey());
+            if (diff > timeoutMs) {
+                // 只在设备不在离线集合中时才调用API
+                if (offlineDeviceSet.add(entry.getKey())) {
+                    updateDeviceOnlineStatus(entry.getKey(), 0);
+                    logger.info("设备 {} 已离线(超过{}分钟未收到数据)", entry.getKey(), offlineTimeoutMinutes);
+                }
             }
         }
     }
-}*/
+
+    /**
+     * 标记设备在线(收到数据时调用)
+     * 只在设备当前处于离线状态时才调用API
+     */
+    public void markDeviceOnline(String deviceCode) {
+        if (offlineDeviceSet.remove(deviceCode)) {
+            updateDeviceOnlineStatus(deviceCode, 1);
+            logger.info("设备 {} 恢复在线", deviceCode);
+        }
+    }
+
+    private void updateDeviceOnlineStatus(String deviceCode, int onlineStatus) {
+        try {
+            Map<String, Object> params = Map.of("deviceCode", deviceCode, "onlineStatus", onlineStatus);
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<Map<String, Object>> request = new HttpEntity<>(params, headers);
+            restTemplate.postForObject("http://zk-api-service/equipmentStatus/updateOnlineStatus", request, Map.class);
+        } catch (Exception e) {
+            logger.error("更新设备在线状态失败: deviceCode={}, onlineStatus={}", deviceCode, onlineStatus, e);
+        }
+    }
+}

+ 7 - 3
firefighting-pressure-service/src/main/java/com/zksy/pressure/utils/MessageHandler.java

@@ -33,6 +33,7 @@ import org.springframework.web.client.RestTemplate;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -56,6 +57,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
     @Autowired
     private RestTemplate restTemplate;
 
+    @Autowired
+    private DeviceOfflineCheckTask deviceOfflineCheckTask;
+
     @Value("${device.status.api.url:http://zk-api-service/equipmentStatus/updateAlarmStatus}")
     private String deviceStatusApiUrl;
 	@Autowired
@@ -105,9 +109,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 				}
 
 				resultData.setId(UUID.randomUUID().toString());
-				// 更新设备最后一次接收数据的时间
-				//String addressCode = resultData.getAddressCode();
-				//DeviceOfflineCheckTask.deviceLastReceiveTimeMap.put(addressCode, new Date());
+				// 更新设备最后一次接收数据的时间,并标记设备在线
+				DeviceOfflineCheckTask.deviceLastReceiveTimeMap.put(resultData.getTelemeteringStation(), new Date());
+				deviceOfflineCheckTask.markDeviceOnline(resultData.getTelemeteringStation());
 				firefightingPressureService.save(resultData);
 
 				// 4. 构建回复报文

+ 42 - 12
flammable-gas-service/src/main/java/com/zksy/gas/utils/DeviceOfflineCheckTask.java

@@ -1,36 +1,66 @@
-package com.zksy.gas.utils;/*
-package com.zksy.gasTransmitter.utils;
+package com.zksy.gas.utils;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
 
 import java.util.Date;
-import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
 @Component
 public class DeviceOfflineCheckTask {
     private static final Logger logger = LoggerFactory.getLogger(DeviceOfflineCheckTask.class);
 
-    // 用于存储设备编号及其最后一次接收数据的时间
     public static ConcurrentHashMap<String, Date> deviceLastReceiveTimeMap = new ConcurrentHashMap<>();
+
+    private final Set<String> offlineDeviceSet = ConcurrentHashMap.newKeySet();
+
     @Autowired
-    private BaseDevicesService baseDevicesService;
+    private RestTemplate restTemplate;
+
+    @Value("${device.offline.timeout-minutes:30}")
+    private int offlineTimeoutMinutes;
 
-    // 定时检查设备是否离线
-    @Scheduled(fixedRate = 24 * 60 * 60 * 1000) // 每24小时执行一次
+    @Scheduled(fixedRateString = "${device.offline.check-interval-ms:300000}")
     public void checkDeviceOffline() {
         Date now = new Date();
+        long timeoutMs = (long) offlineTimeoutMinutes * 60 * 1000;
         for (Map.Entry<String, Date> entry : deviceLastReceiveTimeMap.entrySet()) {
             long diff = now.getTime() - entry.getValue().getTime();
-            // 如果设备在 23 小时内没有接收数据,则认为设备离线
-            if (diff > 23 * 60 * 60 * 1000) {
-                baseDevicesService.getByDeviceNumberStatus(entry.getKey(), 1, 0);
-                logger.info("设备 {} 已离线", entry.getKey());
+            if (diff > timeoutMs) {
+                if (offlineDeviceSet.add(entry.getKey())) {
+                    updateDeviceOnlineStatus(entry.getKey(), 0);
+                    logger.info("设备 {} 已离线(超过{}分钟未收到数据)", entry.getKey(), offlineTimeoutMinutes);
+                }
             }
         }
     }
-}*/
+
+    public void markDeviceOnline(String deviceCode) {
+        if (offlineDeviceSet.remove(deviceCode)) {
+            updateDeviceOnlineStatus(deviceCode, 1);
+            logger.info("设备 {} 恢复在线", deviceCode);
+        }
+    }
+
+    private void updateDeviceOnlineStatus(String deviceCode, int onlineStatus) {
+        try {
+            Map<String, Object> params = Map.of("deviceCode", deviceCode, "onlineStatus", onlineStatus);
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<Map<String, Object>> request = new HttpEntity<>(params, headers);
+            restTemplate.postForObject("http://zk-api-service/equipmentStatus/updateOnlineStatus", request, Map.class);
+        } catch (Exception e) {
+            logger.error("更新设备在线状态失败: deviceCode={}, onlineStatus={}", deviceCode, onlineStatus, e);
+        }
+    }
+}

+ 6 - 0
flammable-gas-service/src/main/java/com/zksy/gas/utils/MessageHandler.java

@@ -58,6 +58,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
     @Autowired
     private RestTemplate restTemplate;
 
+    @Autowired
+    private DeviceOfflineCheckTask deviceOfflineCheckTask;
+
     @Value("${device.status.api.url:http://zk-api-service/equipmentStatus/updateAlarmStatus}")
     private String deviceStatusApiUrl;
 
@@ -128,6 +131,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 			com.zksy.gas.domain.GasMonitorData resultData = DataParser.parseMessage(msgBytes);
 			resultData.setId(java.util.UUID.randomUUID().toString());
 			service.save(resultData);
+			// 更新设备最后接收时间,并标记设备在线
+			DeviceOfflineCheckTask.deviceLastReceiveTimeMap.put(resultData.getMacAddress(), new java.util.Date());
+			deviceOfflineCheckTask.markDeviceOnline(resultData.getMacAddress());
 			checkIfSmsAlertNeeded(resultData);
 			logger.info("数据解析入库成功: {}", resultData);
 

+ 1 - 2
manhole-service/pom.xml

@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="UTF-8"?>
+<?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>
@@ -44,7 +44,6 @@
         <dependency>
             <groupId>com.baomidou</groupId>
             <artifactId>mybatis-plus-boot-starter</artifactId>
-            <version>3.5.5</version>
         </dependency>
         <!--nacos 服务注册发现-->
         <dependency>

+ 16 - 6
manhole-service/src/main/java/com/zksy/manhole/service/impl/BaseDevicesManholeServiceImpl.java

@@ -3,7 +3,7 @@ package com.zksy.manhole.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 
-import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.api.event.DeviceStatusChangePublisher;
 import com.zksy.common.exception.ServiceException;
 import com.zksy.manhole.domain.BaseDevicesManhole;
 import com.zksy.manhole.domain.EquipmentStatusManhole;
@@ -12,6 +12,9 @@ import com.zksy.manhole.mapper.EquipmentStatusManholeMapper;
 import com.zksy.manhole.service.BaseDevicesManholeService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Objects;
 
 /**
 * @author Administrator
@@ -27,7 +30,11 @@ public class BaseDevicesManholeServiceImpl extends ServiceImpl<BaseDevicesManhol
     @Autowired
     private EquipmentStatusManholeMapper equipmentStatusBaseDevicesMapper;
 
+    @Autowired
+    private DeviceStatusChangePublisher deviceStatusChangePublisher;
+
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public void getByDeviceNumberStatus(String deviceNumber,Integer queryStatus,Integer updateStatus) {
         LambdaQueryWrapper<BaseDevicesManhole> wrapper = new LambdaQueryWrapper<>();
         //设备编号
@@ -47,12 +54,15 @@ public class BaseDevicesManholeServiceImpl extends ServiceImpl<BaseDevicesManhol
 
         //修改状态表
         if(equipmentStatus != null){
+            boolean changed = !Objects.equals(equipmentStatus.getOnlineStatus(), updateStatus);
             equipmentStatus.setOnlineStatus(updateStatus);
-            equipmentStatusBaseDevicesMapper.updateById(equipmentStatus);
+            int affected = equipmentStatusBaseDevicesMapper.updateById(equipmentStatus);
+            if (affected > 0 && changed) {
+                deviceStatusChangePublisher.publishAfterCommit(device.getEquipmentCode(),
+                        deviceEquipmentId, equipmentStatus.getCurrentStatus(),
+                        equipmentStatus.getAlarmStatus(), equipmentStatus.getOnlineStatus(),
+                        "manhole:updateOnlineStatus");
+            }
         }
     }
 }
-
-
-
-

+ 54 - 11
manhole-service/src/main/java/com/zksy/manhole/utils/DeviceOfflineCheckTask.java

@@ -1,36 +1,79 @@
 package com.zksy.manhole.utils;
 
-import com.zksy.manhole.service.BaseDevicesManholeService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
 
 import java.util.Date;
-import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
+/**
+ * 设备离线检测定时任务
+ * 通过 REST API 更新设备在线状态
+ */
 @Component
 public class DeviceOfflineCheckTask {
     private static final Logger logger = LoggerFactory.getLogger(DeviceOfflineCheckTask.class);
 
-    // 用于存储设备编号及其最后一次接收数据的时间
-    public static Map<String, Date> deviceLastReceiveTimeMap = new HashMap<>();
+    /** 设备最后接收数据时间 */
+    public static ConcurrentHashMap<String, Date> deviceLastReceiveTimeMap = new ConcurrentHashMap<>();
+
+    /** 已知离线设备集合,用于避免重复API调用 */
+    private final Set<String> offlineDeviceSet = ConcurrentHashMap.newKeySet();
+
     @Autowired
-    private BaseDevicesManholeService baseDevicesService;
+    private RestTemplate restTemplate;
+
+    /** 离线判定超时,单位:分钟,默认30分钟 */
+    @Value("${device.offline.timeout-minutes:30}")
+    private int offlineTimeoutMinutes;
 
-    // 定时检查设备是否离线
-    @Scheduled(fixedRate = 24 * 60 * 60 * 1000) // 每24小时执行一次
+    /** 检查间隔,单位:毫秒,默认5分钟 */
+    @Scheduled(fixedRateString = "${device.offline.check-interval-ms:300000}")
     public void checkDeviceOffline() {
         Date now = new Date();
+        long timeoutMs = (long) offlineTimeoutMinutes * 60 * 1000;
         for (Map.Entry<String, Date> entry : deviceLastReceiveTimeMap.entrySet()) {
             long diff = now.getTime() - entry.getValue().getTime();
-            // 如果设备在 23 小时内没有接收数据,则认为设备离线
-            if (diff > 23 * 60 * 60 * 1000) {
-                baseDevicesService.getByDeviceNumberStatus(entry.getKey(), 1, 0);
-                logger.info("设备 {} 已离线", entry.getKey());
+            if (diff > timeoutMs) {
+                // 只在设备不在离线集合中时才调用API
+                if (offlineDeviceSet.add(entry.getKey())) {
+                    updateDeviceOnlineStatus(entry.getKey(), 0);
+                    logger.info("设备 {} 已离线(超过{}分钟未收到数据)", entry.getKey(), offlineTimeoutMinutes);
+                }
             }
         }
     }
+
+    /**
+     * 标记设备在线(收到数据时调用)
+     * 只在设备当前处于离线状态时才调用API
+     */
+    public void markDeviceOnline(String deviceCode) {
+        if (offlineDeviceSet.remove(deviceCode)) {
+            updateDeviceOnlineStatus(deviceCode, 1);
+            logger.info("设备 {} 恢复在线", deviceCode);
+        }
+    }
+
+    private void updateDeviceOnlineStatus(String deviceCode, int onlineStatus) {
+        try {
+            Map<String, Object> params = Map.of("deviceCode", deviceCode, "onlineStatus", onlineStatus);
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<Map<String, Object>> request = new HttpEntity<>(params, headers);
+            restTemplate.postForObject("http://zk-api-service/equipmentStatus/updateOnlineStatus", request, Map.class);
+        } catch (Exception e) {
+            logger.error("更新设备在线状态失败: deviceCode={}, onlineStatus={}", deviceCode, onlineStatus, e);
+        }
+    }
 }

+ 6 - 1
manhole-service/src/main/java/com/zksy/manhole/utils/MessageHandler.java

@@ -12,6 +12,7 @@ import io.netty.handler.timeout.ReadTimeoutException;
 import lombok.extern.slf4j.Slf4j;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
 
 import java.util.Date;
@@ -24,6 +25,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 
 	private final ManholeDataService messageParseResultService;
 
+	@Autowired
+	private DeviceOfflineCheckTask deviceOfflineCheckTask;
+
 	public MessageHandler(ManholeDataService messageParseResultService) {
 		this.messageParseResultService = messageParseResultService;
 	}
@@ -49,9 +53,10 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 				throw new InvalidMessageException("数据校验不成功");
 			} else {
 				ManholeData resultData = DataParser.parseMessage(msgString);
-				// 更新设备最后一次接收数据的时间
+				// 更新设备最后一次接收数据的时间,并标记设备在线
 				String imeiCardNumber = resultData.getImeiCardNumber();
 				DeviceOfflineCheckTask.deviceLastReceiveTimeMap.put(imeiCardNumber, new Date());
+				deviceOfflineCheckTask.markDeviceOnline(imeiCardNumber);
 				messageParseResultService.saveManholeData(resultData);
 			}
 		} catch (InvalidMessageException e) {

+ 4 - 0
pipe-network-service/CONTEXT.md

@@ -28,6 +28,10 @@ _Avoid_:管网、管道(指单条设施时)
 接受供水服务的居民、工业、商业或行政事业主体。
 _Avoid_:大用水户(除非明确指满足特定用水量或重要性标准的子集)、用户
 
+兼容映射:既有设施类型编码 `user`、接口区块 `waterUser`、Java 类型 `WaterUserInfo`、
+持久化表 `water_user_info` 及其 `user_*` 字段均表示“用水户”,覆盖居民、工业、商业和
+行政事业等全部类型。为保持现有接口与数据兼容,这些技术标识继续保留,不表示“大用水户”子集。
+
 ## 运行监测
 
 **监测设备**:

+ 13 - 7
pipe-network-service/sql/water_supply_facility_test_data.sql

@@ -110,7 +110,7 @@ VALUES
      113.32000000, 23.16000000, NULL, NULL);
 
 -- ------------------------------------------------------------
--- 5. 大用水户:2 个坐标完整 + 1 个缺失坐标
+-- 5. 用水户:居民、工业、商业、行政事业均参与,4 个坐标完整 + 1 个缺失坐标
 -- ------------------------------------------------------------
 INSERT INTO app_user.water_user_info
     (user_code, user_name, user_type, address, contact_person,
@@ -120,12 +120,18 @@ INSERT INTO app_user.water_user_info
 VALUES
     ('GIS-TEST-U-001', 'GIS测试用水户-工业园', '工业', '测试工业园1号', '测试联系人1',
      '13800000005', 'GIS-METER-001', 1000.00, 52000.00, 51000.00, '0',
-     'GIS接口测试-坐标完整-大用水量', '0', 'test', CURRENT_TIMESTAMP, 113.32000000, 23.16000000),
-    ('GIS-TEST-U-002', 'GIS测试用水户-医院', '公共机构', '测试医院2号', '测试联系人2',
+     'GIS接口测试-坐标完整-工业', '0', 'test', CURRENT_TIMESTAMP, 113.32000000, 23.16000000),
+    ('GIS-TEST-U-002', 'GIS测试用水户-医院', '行政事业', '测试医院2号', '测试联系人2',
      '13800000006', 'GIS-METER-002', 500.00, 12500.00, 12000.00, '1',
-     'GIS接口测试-坐标完整-异常', '0', 'test', CURRENT_TIMESTAMP, 113.33000000, 23.17000000),
-    ('GIS-TEST-U-003', 'GIS测试用水户-待测', '商业', '测试商业街3号', '测试联系人3',
-     '13800000007', 'GIS-METER-003', 100.00, 800.00, NULL, NULL,
+     'GIS接口测试-坐标完整-行政事业', '0', 'test', CURRENT_TIMESTAMP, 113.33000000, 23.17000000),
+    ('GIS-TEST-U-003', 'GIS测试用水户-商业街', '商业', '测试商业街3号', '测试联系人3',
+     '13800000007', 'GIS-METER-003', 100.00, 800.00, 700.00, '0',
+     'GIS接口测试-坐标完整-商业', '0', 'test', CURRENT_TIMESTAMP, 113.34000000, 23.18000000),
+    ('GIS-TEST-U-004', 'GIS测试用水户-居民', '居民', '测试小区4号', '测试联系人4',
+     '13800000008', 'GIS-METER-004', 10.00, 120.00, 110.00, '0',
+     'GIS接口测试-坐标完整-居民', '0', 'test', CURRENT_TIMESTAMP, 113.35000000, 23.19000000),
+    ('GIS-TEST-U-005', 'GIS测试用水户-缺失坐标', '商业', '测试商业街5号', '测试联系人5',
+     '13800000009', 'GIS-METER-005', 100.00, 800.00, NULL, NULL,
      'GIS接口测试-缺失坐标-概览计数', '0', 'test', CURRENT_TIMESTAMP, NULL, NULL);
 
 COMMIT;
@@ -133,7 +139,7 @@ COMMIT;
 -- ------------------------------------------------------------
 -- 6. 验证输出
 -- ------------------------------------------------------------
--- 预期:source=3/完整2,plant=2/完整1,pumpStation=2/完整1,pipe=4/完整3,user=3/完整2
+-- 预期:source=3/完整2,plant=2/完整1,pumpStation=2/完整1,pipe=4/完整3,user=5/完整4
 SELECT 'source' facility_type, COUNT(*) total_count,
        COALESCE(SUM(CASE WHEN longitude IS NOT NULL AND latitude IS NOT NULL THEN 1 ELSE 0 END), 0) geo_complete_count
   FROM app_user.water_source_info WHERE source_id LIKE 'GIS-TEST-%'

+ 1 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/PipeNetworkStatistics/PipeNetworkStatisticsController.java

@@ -51,7 +51,7 @@ public class PipeNetworkStatisticsController {
 
     @GetMapping("/export")
     @ApiOperation(value = "供水管网-管网数据统计分析-导出统计 Excel",
-            notes = "请求参数与综合统计仪表盘一致。响应为文件流,文件名为管网数据统计分析.xlsx,包含综合概览、管网统计、水厂统计、泵站统计、水源地统计、用水户统计六个工作表。")
+            notes = "请求参数与综合统计仪表盘一致。响应为文件流,文件名为管网数据统计分析.xlsx,包含综合概览、管网统计、水厂统计、泵站统计、水源地统计、用水户统计六个工作表。")
     @Log(title = "管网数据统计分析", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('waterSupply:pipeNetwork:statistics:export')")
     public void export(HttpServletResponse response,

+ 7 - 6
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/WaterFacilityExportController.java

@@ -17,6 +17,7 @@ import com.zksy.common.annotation.Excel;
 import com.zksy.common.enums.BusinessType;
 import com.zksy.common.utils.poi.ExcelUtil;
 import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
@@ -39,7 +40,7 @@ public class WaterFacilityExportController {
     @GetMapping("/sourceInfo/export")
     @Log(title = "导出水源地信息", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('waterSupply:waterSource:export')")
-    public void sourceExport(HttpServletResponse response, @RequestParam(required = false) String sourceId, @RequestParam(required = false) String sourceName, @RequestParam(required = false) String status, @RequestParam(required = false) String unit, @RequestParam(required = false) LocalDateTime startTime, @RequestParam(required = false) LocalDateTime endTime) {
+    public void sourceExport(HttpServletResponse response, @RequestParam(required = false) String sourceId, @RequestParam(required = false) String sourceName, @RequestParam(required = false) String status, @RequestParam(required = false) String unit, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
         var page = sourceService.queryPage(new Page<WaterSourceInfo>(1, Integer.MAX_VALUE), sourceId, sourceName, status, unit, startTime, endTime);
         new ExcelUtil<>(WaterSourceExportDTO.class).exportExcel(response, page.getRecords().stream().map(WaterSourceExportDTO::from).collect(Collectors.toList()), "水源地信息");
     }
@@ -47,7 +48,7 @@ public class WaterFacilityExportController {
     @GetMapping("/plantInfo/export")
     @Log(title = "导出水厂信息", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('waterSupply:waterPlant:export')")
-    public void plantExport(HttpServletResponse response, @RequestParam(required = false) String plantCode, @RequestParam(required = false) String plantName, @RequestParam(required = false) String status, @RequestParam(required = false) String principal, @RequestParam(required = false) LocalDateTime startTime, @RequestParam(required = false) LocalDateTime endTime) {
+    public void plantExport(HttpServletResponse response, @RequestParam(required = false) String plantCode, @RequestParam(required = false) String plantName, @RequestParam(required = false) String status, @RequestParam(required = false) String principal, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
         var page = plantService.queryPage(new Page<WaterPlantInfo>(1, Integer.MAX_VALUE), plantCode, plantName, status, principal, startTime, endTime);
         new ExcelUtil<>(WaterPlantExportDTO.class).exportExcel(response, page.getRecords().stream().map(WaterPlantExportDTO::from).collect(Collectors.toList()), "水厂信息");
     }
@@ -55,7 +56,7 @@ public class WaterFacilityExportController {
     @GetMapping("/pumpStation/export")
     @Log(title = "导出泵站信息", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('waterSupply:waterPumpStation:export')")
-    public void pumpExport(HttpServletResponse response, @RequestParam(required = false) String stationCode, @RequestParam(required = false) String stationName, @RequestParam(required = false) String status, @RequestParam(required = false) String principal, @RequestParam(required = false) LocalDateTime startTime, @RequestParam(required = false) LocalDateTime endTime) {
+    public void pumpExport(HttpServletResponse response, @RequestParam(required = false) String stationCode, @RequestParam(required = false) String stationName, @RequestParam(required = false) String status, @RequestParam(required = false) String principal, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
         var page = pumpService.queryPage(new Page<WaterPumpStation>(1, Integer.MAX_VALUE), stationCode, stationName, status, principal, startTime, endTime);
         new ExcelUtil<>(WaterPumpStationExportDTO.class).exportExcel(response, page.getRecords().stream().map(WaterPumpStationExportDTO::from).collect(Collectors.toList()), "泵站信息");
     }
@@ -63,15 +64,15 @@ public class WaterFacilityExportController {
     @GetMapping("/pipe/export")
     @Log(title = "导出管网信息", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('waterSupply:waterPipe:export')")
-    public void pipeExport(HttpServletResponse response, @RequestParam(required = false) String pipeCode, @RequestParam(required = false) String pipeName, @RequestParam(required = false) String pipeMaterial, @RequestParam(required = false) String status, @RequestParam(required = false) LocalDateTime startTime, @RequestParam(required = false) LocalDateTime endTime) {
-        var page = pipeService.queryPage(new Page<WaterPipeInfo>(1, Integer.MAX_VALUE), pipeCode, pipeName, pipeMaterial, status, startTime, endTime);
+    public void pipeExport(HttpServletResponse response, @RequestParam(required = false) String pipeCode, @RequestParam(required = false) String pipeName, @RequestParam(required = false) String pipeMaterial, @RequestParam(required = false) String status, @RequestParam(required = false) Integer pipeDiameter, @RequestParam(required = false) Integer layingYear, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
+        var page = pipeService.queryPage(new Page<WaterPipeInfo>(1, Integer.MAX_VALUE), pipeCode, pipeName, pipeMaterial, status, pipeDiameter, layingYear, startTime, endTime);
         new ExcelUtil<>(WaterPipeExportDTO.class).exportExcel(response, page.getRecords().stream().map(WaterPipeExportDTO::from).collect(Collectors.toList()), "管网信息");
     }
 
     @GetMapping("/userInfo/export")
     @Log(title = "导出用水户信息", businessType = BusinessType.EXPORT)
     @PreAuthorize("@ss.hasPermi('waterSupply:waterUser:export')")
-    public void userExport(HttpServletResponse response, @RequestParam(required = false) String userCode, @RequestParam(required = false) String userName, @RequestParam(required = false) String status, @RequestParam(required = false) String userType, @RequestParam(required = false) LocalDateTime startTime, @RequestParam(required = false) LocalDateTime endTime) {
+    public void userExport(HttpServletResponse response, @RequestParam(required = false) String userCode, @RequestParam(required = false) String userName, @RequestParam(required = false) String status, @RequestParam(required = false) String userType, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime, @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
         var page = userService.queryPage(new Page<WaterUserInfo>(1, Integer.MAX_VALUE), userCode, userName, status, userType, startTime, endTime);
         new ExcelUtil<>(WaterUserExportDTO.class).exportExcel(response, page.getRecords().stream().map(WaterUserExportDTO::from).collect(Collectors.toList()), "用水户信息");
     }

+ 4 - 2
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/WaterPipeInfo/WaterPipeInfoController.java

@@ -48,11 +48,13 @@ public class WaterPipeInfoController {
             @ApiParam(value = "管网名称") @RequestParam(required = false) String pipeName,
             @ApiParam(value = "管网材质") @RequestParam(required = false) String pipeMaterial,
             @ApiParam(value = "状态(0正常 1停用)") @RequestParam(required = false) String status,
+            @ApiParam(value = "管径(毫米)") @RequestParam(required = false) Integer pipeDiameter,
+            @ApiParam(value = "铺设年份") @RequestParam(required = false) Integer layingYear,
             @ApiParam(value = "开始时间(格式:yyyy-MM-dd HH:mm:ss)", example = "2026-01-01 00:00:00") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
             @ApiParam(value = "结束时间(格式:yyyy-MM-dd HH:mm:ss)", example = "2026-12-31 23:59:59") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
         try {
             Page<WaterPipeInfo> page = new Page<>(pageNum, pageSize);
-            IPage<WaterPipeInfo> resultPage = waterPipeInfoService.queryPage(page, pipeCode, pipeName, pipeMaterial, status, startTime, endTime);
+            IPage<WaterPipeInfo> resultPage = waterPipeInfoService.queryPage(page, pipeCode, pipeName, pipeMaterial, status, pipeDiameter, layingYear, startTime, endTime);
             return AjaxResult.success("查询成功", resultPage);
         } catch (Exception e) {
             log.error("分页查询管网数据异常", e);
@@ -287,4 +289,4 @@ public class WaterPipeInfoController {
             return AjaxResult.error("彻底删除失败:" + e.getMessage());
         }
     }
-}
+}

+ 20 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/WaterSupplyGisMap/WaterSupplyGisMapController.java

@@ -49,9 +49,11 @@ public class WaterSupplyGisMapController {
             @PathVariable Long id) {
         try {
             Object detail = gisMapService.getDetail(facilityType, id);
-            return detail == null ? AjaxResult.error("设施不存在") : AjaxResult.success(detail);
+            return detail == null ? AjaxResult.warn(notFoundMessage(facilityType)) : AjaxResult.success(detail);
         } catch (IllegalArgumentException e) {
             return AjaxResult.error(e.getMessage());
+        } catch (Exception e) {
+            return AjaxResult.error("查询失败:" + e.getMessage());
         }
     }
 
@@ -67,4 +69,21 @@ public class WaterSupplyGisMapController {
         if (text == null || text.trim().isEmpty()) return Collections.emptySet();
         return Arrays.stream(text.split(",")).map(String::trim).filter(s -> !s.isEmpty()).collect(Collectors.toSet());
     }
+
+    private String notFoundMessage(String facilityType) {
+        switch (facilityType) {
+            case "source":
+                return "未找到该水源地信息";
+            case "plant":
+                return "未找到该水厂信息";
+            case "pumpStation":
+                return "未找到该泵站信息";
+            case "pipe":
+                return "未找到该供水管线信息";
+            case "user":
+                return "未找到该用水户信息";
+            default:
+                return "设施不存在";
+        }
+    }
 }

+ 9 - 9
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/WaterSupply/WaterUserInfo/WaterUserInfoController.java

@@ -34,7 +34,7 @@ public class WaterUserInfoController {
 
     // -------------------------- 基础CRUD接口 --------------------------
     @GetMapping("/page")
-    @ApiOperation(value = "分页查询用水户数据", notes = "支持按用户编号、名称、状态、用户类型、时间范围模糊查询")
+    @ApiOperation(value = "分页查询用水户数据", notes = "支持按用户编号、名称、状态、类型、时间范围模糊查询")
     @ApiResponses({
             @ApiResponse(code = 200, message = "查询成功", response = AjaxResult.class),
             @ApiResponse(code = 500, message = "服务器内部错误,查询失败", response = AjaxResult.class)
@@ -42,10 +42,10 @@ public class WaterUserInfoController {
     public AjaxResult queryPage(
             @ApiParam(value = "页码", defaultValue = "1") @RequestParam(defaultValue = "1") Integer pageNum,
             @ApiParam(value = "每页大小", defaultValue = "10") @RequestParam(defaultValue = "10") Integer pageSize,
-            @ApiParam(value = "用户编号") @RequestParam(required = false) String userCode,
-            @ApiParam(value = "用户名称") @RequestParam(required = false) String userName,
-            @ApiParam(value = "用户状态(0正常 1欠费 2停用)") @RequestParam(required = false) String status,
-            @ApiParam(value = "用户类型(居民/工业/商业/行政事业)") @RequestParam(required = false) String userType,
+            @ApiParam(value = "用户编号") @RequestParam(required = false) String userCode,
+            @ApiParam(value = "用户名称") @RequestParam(required = false) String userName,
+            @ApiParam(value = "用户状态(0正常 1欠费 2停用)") @RequestParam(required = false) String status,
+            @ApiParam(value = "用户类型(居民/工业/商业/行政事业)") @RequestParam(required = false) String userType,
             @ApiParam(value = "开始时间(格式:yyyy-MM-dd HH:mm:ss)", example = "2025-01-01 00:00:00") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
             @ApiParam(value = "结束时间(格式:yyyy-MM-dd HH:mm:ss)", example = "2025-12-31 23:59:59") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
         try {
@@ -160,9 +160,9 @@ public class WaterUserInfoController {
     public AjaxResult recycleList(
             @ApiParam(value = "页码", defaultValue = "1") @RequestParam(defaultValue = "1") Integer pageNum,
             @ApiParam(value = "每页大小", defaultValue = "10") @RequestParam(defaultValue = "10") Integer pageSize,
-            @ApiParam(value = "用户编号") @RequestParam(required = false) String userCode,
-            @ApiParam(value = "用户名称") @RequestParam(required = false) String userName,
-            @ApiParam(value = "用户状态(0正常 1欠费 2停用)") @RequestParam(required = false) String status,
+            @ApiParam(value = "用户编号") @RequestParam(required = false) String userCode,
+            @ApiParam(value = "用户名称") @RequestParam(required = false) String userName,
+            @ApiParam(value = "用户状态(0正常 1欠费 2停用)") @RequestParam(required = false) String status,
             @ApiParam(value = "开始时间(格式:yyyy-MM-dd HH:mm:ss)", example = "2025-01-01 00:00:00") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
             @ApiParam(value = "结束时间(格式:yyyy-MM-dd HH:mm:ss)", example = "2025-12-31 23:59:59") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
         try {
@@ -223,4 +223,4 @@ public class WaterUserInfoController {
             return AjaxResult.error("彻底删除失败:" + e.getMessage());
         }
     }
-}
+}

+ 20 - 16
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusRedisListener.java

@@ -8,10 +8,9 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.redis.connection.Message;
 import org.springframework.data.redis.connection.MessageListener;
 import org.springframework.stereotype.Component;
-import org.springframework.web.socket.TextMessage;
 import org.springframework.web.socket.WebSocketSession;
 
-import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.util.Set;
 
 /**
@@ -32,6 +31,9 @@ public class DeviceStatusRedisListener implements MessageListener {
     @Autowired
     private EquipmentStatusService equipmentStatusService;
 
+    @Autowired
+    private DeviceStatusWebSocketHandler webSocketHandler;
+
     /**
      * Redis 频道名称
      */
@@ -39,7 +41,7 @@ public class DeviceStatusRedisListener implements MessageListener {
 
     @Override
     public void onMessage(Message message, byte[] pattern) {
-        String body = new String(message.getBody());
+        String body = new String(message.getBody(), StandardCharsets.UTF_8);
         log.debug("收到 Redis 设备状态变更消息: channel={}, body={}", CHANNEL, body);
 
         try {
@@ -66,31 +68,32 @@ public class DeviceStatusRedisListener implements MessageListener {
             }
 
             // 构建推送消息
+            JSONObject data = JSONObject.parseObject(JSONObject.toJSONString(status));
+            // 客户端可能同时订阅多个设备,必须在推送中携带设备编码以便路由状态。
+            data.put("deviceCode", deviceCode);
+            if (msgObj.containsKey("eventId")) {
+                data.put("eventId", msgObj.getString("eventId"));
+            }
             JSONObject pushMsg = new JSONObject();
             pushMsg.put("type", "deviceStatusPush");
-            pushMsg.put("data", status);
+            pushMsg.put("deviceCode", deviceCode);
+            pushMsg.put("data", data);
 
             String pushPayload = pushMsg.toJSONString();
-            TextMessage textMessage = new TextMessage(pushPayload);
 
             // 推送到所有订阅了该设备的会话
             int successCount = 0;
             int failCount = 0;
             for (String sessionId : sessionIds) {
-                try {
-                    WebSocketSession session = DeviceStatusWebSocketHandler.getSession(sessionId);
-                    if (session != null && session.isOpen()) {
-                        synchronized (session) {
-                            session.sendMessage(textMessage);
-                        }
+                WebSocketSession session = DeviceStatusWebSocketHandler.getSession(sessionId);
+                if (session != null && session.isOpen()) {
+                    if (webSocketHandler.sendText(session, pushPayload)) {
                         successCount++;
                     } else {
-                        // 会话已失效,清理订阅
-                        subscriptionManager.removeSession(sessionId);
                         failCount++;
                     }
-                } catch (IOException e) {
-                    log.error("推送设备状态失败: sessionId={}, deviceCode={}", sessionId, deviceCode, e);
+                } else {
+                    // 会话已失效,清理订阅
                     subscriptionManager.removeSession(sessionId);
                     failCount++;
                 }
@@ -103,4 +106,5 @@ public class DeviceStatusRedisListener implements MessageListener {
             log.error("处理 Redis 设备状态变更消息异常: body={}", body, e);
         }
     }
-}
+
+}

+ 173 - 8
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusWebSocketHandler.java

@@ -4,8 +4,14 @@ import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.base.service.EquipmentStatusService;
+import com.zksy.base.domain.vo.WorkOrderPushVO;
+import com.zksy.base.service.BusinessWorkOrderService;
+import com.zksy.common.core.domain.model.LoginUser;
+import com.zksy.common.utils.DateUtils;
+import com.zksy.framework.web.service.TokenService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
 import org.springframework.web.socket.CloseStatus;
 import org.springframework.web.socket.TextMessage;
@@ -14,8 +20,11 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
 
 import java.io.IOException;
 import java.util.HashSet;
+import java.util.HashMap;
+import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -24,14 +33,20 @@ import java.util.concurrent.ConcurrentHashMap;
  * <p>
  * 支持的消息类型(通过 JSON 中的 type 字段区分):
  * <ul>
+ *   <li><b>authenticate</b> — 使用登录 JWT 鉴权(工单推送必需)</li>
  *   <li><b>subscribe</b> — 批量订阅设备,后端状态变更时主动推送</li>
  *   <li><b>unsubscribe</b> — 取消订阅设备</li>
  *   <li><b>deviceStatus</b> — 单次查询设备状态(兼容旧接口)</li>
+ *   <li><b>workOrderReminder</b> — 查询当前用户可见的待处理/超时工单</li>
  *   <li><b>ping</b> — 心跳检测</li>
  * </ul>
  *
  * <h3>协议示例</h3>
  * <pre>
+ * // 工单鉴权(连接后首帧发送)
+ * → {"type":"authenticate","token":"Bearer &lt;JWT&gt;"}
+ * ← {"type":"authenticated","success":true,"data":{"userId":10,"deptId":100}}
+ *
  * // 订阅
  * → {"type":"subscribe","deviceCodes":["DEV001","DEV002"]}
  * ← {"type":"subscribed","deviceCodes":["DEV001","DEV002"]}
@@ -49,7 +64,10 @@ import java.util.concurrent.ConcurrentHashMap;
  * ← {"type":"deviceStatus","success":true,"data":{...}}
  *
  * // 设备状态变更推送(服务端主动推送)
- * ← {"type":"deviceStatusPush","data":{...}}
+ * ← {"type":"deviceStatusPush","deviceCode":"DEV001","data":{...}}
+ *
+ * // 工单提醒推送(服务端每分钟扫描,有变化才推送)
+ * ← {"type":"workOrderReminderPush","success":true,"data":{"items":[...],"total":1}}
  * </pre>
  *
  * @author zksy
@@ -64,6 +82,18 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
     @Autowired
     private SubscriptionManager subscriptionManager;
 
+    @Autowired
+    private TokenService tokenService;
+
+    @Autowired
+    private BusinessWorkOrderService businessWorkOrderService;
+
+    @Value("${websocket.work-order.max-items:100}")
+    private int workOrderMaxItems;
+
+    static final String WORK_ORDER_LOGIN_USER_ATTRIBUTE = "workOrderLoginUser";
+    static final String WORK_ORDER_TOKEN_ATTRIBUTE = "workOrderToken";
+
     /**
      * 维护所有在线连接,key 为 sessionId
      * 供 RedisListener 跨线程查找会话
@@ -77,6 +107,42 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
         return SESSION_MAP.get(sessionId);
     }
 
+    /**
+     * 获取当前在线会话快照,供定时推送任务安全遍历。
+     */
+    public Map<String, WebSocketSession> getSessionSnapshot() {
+        return new HashMap<>(SESSION_MAP);
+    }
+
+    public LoginUser getAuthenticatedUser(WebSocketSession session) {
+        if (session == null) {
+            return null;
+        }
+        Map<String, Object> attributes = session.getAttributes();
+        if (attributes == null) {
+            return null;
+        }
+        Object token;
+        Object value;
+        synchronized (session) {
+            token = attributes.get(WORK_ORDER_TOKEN_ATTRIBUTE);
+            value = attributes.get(WORK_ORDER_LOGIN_USER_ATTRIBUTE);
+        }
+        if (token instanceof String && !((String) token).trim().isEmpty()) {
+            LoginUser loginUser = tokenService.getLoginUserByToken((String) token);
+            if (loginUser == null) {
+                clearAuthentication(session);
+                return null;
+            }
+            tokenService.verifyToken(loginUser);
+            synchronized (session) {
+                attributes.put(WORK_ORDER_LOGIN_USER_ATTRIBUTE, loginUser);
+            }
+            return loginUser;
+        }
+        return value instanceof LoginUser ? (LoginUser) value : null;
+    }
+
     @Override
     public void afterConnectionEstablished(WebSocketSession session) {
         String sessionId = session.getId();
@@ -87,11 +153,11 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
     @Override
     protected void handleTextMessage(WebSocketSession session, TextMessage message) throws IOException {
         String payload = message.getPayload();
-        log.debug("收到 WebSocket 消息: sessionId={}, payload={}", session.getId(), payload);
 
         try {
             JSONObject request = JSONObject.parseObject(payload);
             String type = request.getString("type");
+            log.debug("收到 WebSocket 消息: sessionId={}, type={}", session.getId(), type);
 
             if (type == null || type.isEmpty()) {
                 sendError(session, "消息缺少 type 字段");
@@ -100,6 +166,12 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
 
             // === 根据 type 分发到不同的业务处理 ===
             switch (type) {
+                case "authenticate":
+                    handleAuthenticate(session, request);
+                    break;
+                case "workOrderReminder":
+                    handleWorkOrderReminder(session);
+                    break;
                 //订阅
                 case "subscribe":
                     handleSubscribe(session, request);
@@ -126,6 +198,65 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
         }
     }
 
+    /**
+     * WebSocket 工单推送鉴权。浏览器 WebSocket 无法设置 Authorization 请求头,
+     * 因此客户端连接成功后需先发送 {"type":"authenticate","token":"..."}。
+     */
+    private void handleAuthenticate(WebSocketSession session, JSONObject request) throws IOException {
+        String token = request.getString("token");
+        LoginUser loginUser = tokenService.getLoginUserByToken(token);
+        if (loginUser == null) {
+            clearAuthentication(session);
+            sendResponse(session, "authenticated", false, null, "token 无效或已过期");
+            return;
+        }
+
+        tokenService.verifyToken(loginUser);
+        synchronized (session) {
+            session.getAttributes().put(WORK_ORDER_LOGIN_USER_ATTRIBUTE, loginUser);
+            session.getAttributes().put(WORK_ORDER_TOKEN_ATTRIBUTE, token);
+        }
+        JSONObject data = new JSONObject();
+        data.put("userId", loginUser.getUserId());
+        data.put("deptId", loginUser.getDeptId());
+        sendResponse(session, "authenticated", true, data, null);
+        log.info("WebSocket 工单推送鉴权成功: sessionId={}, userId={}, deptId={}",
+                session.getId(), loginUser.getUserId(), loginUser.getDeptId());
+    }
+
+    private void handleWorkOrderReminder(WebSocketSession session) throws IOException {
+        LoginUser loginUser = getAuthenticatedUser(session);
+        if (loginUser == null) {
+            sendResponse(session, "workOrderReminder", false, null, "请先完成 WebSocket 鉴权");
+            return;
+        }
+        List<WorkOrderPushVO> candidates = businessWorkOrderService.selectPendingOrOverdueWorkOrders(new java.util.Date());
+        if (candidates == null) {
+            candidates = Collections.emptyList();
+        }
+        List<WorkOrderPushVO> visibleItems = candidates
+                .stream()
+                .filter(item -> isWorkOrderVisibleTo(item, loginUser))
+                .collect(Collectors.toList());
+        int limit = Math.max(1, workOrderMaxItems);
+        List<WorkOrderPushVO> items = visibleItems.stream().limit(limit).collect(Collectors.toList());
+        JSONObject data = new JSONObject();
+        data.put("items", items);
+        data.put("total", visibleItems.size());
+        data.put("truncated", visibleItems.size() > items.size());
+        data.put("serverTime", DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, new java.util.Date()));
+        sendResponse(session, "workOrderReminder", true, data, null);
+    }
+
+    static boolean isWorkOrderVisibleTo(WorkOrderPushVO item, LoginUser loginUser) {
+        if (item == null || loginUser == null) return false;
+        boolean sameDepartment = loginUser.getDeptId() != null && loginUser.getDeptId().equals(item.getDeptId());
+        boolean receiver = item.getReceiveUser() != null && loginUser.getUserId() != null
+                && java.util.Arrays.stream(item.getReceiveUser().split(","))
+                .map(String::trim).anyMatch(String.valueOf(loginUser.getUserId())::equals);
+        return sameDepartment || receiver;
+    }
+
     /**
      * 处理批量订阅
      */
@@ -154,7 +285,7 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
         JSONObject response = new JSONObject();
         response.put("type", "subscribed");
         response.put("deviceCodes", subscribed);
-        session.sendMessage(new TextMessage(response.toJSONString()));
+        sendText(session, response.toJSONString());
 
         log.info("订阅成功: sessionId={}, 订阅设备={}", session.getId(), subscribed);
     }
@@ -188,7 +319,7 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
         response.put("type", "unsubscribed");
         response.put("deviceCodes", deviceCodes);
         response.put("remaining", remaining);
-        session.sendMessage(new TextMessage(response.toJSONString()));
+        sendText(session, response.toJSONString());
 
         log.info("取消订阅: sessionId={}, 取消设备={}, 剩余订阅={}", session.getId(), deviceCodes, remaining);
     }
@@ -220,13 +351,14 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
         JSONObject response = new JSONObject();
         response.put("type", "pong");
         response.put("timestamp", System.currentTimeMillis());
-        session.sendMessage(new TextMessage(response.toJSONString()));
+        sendText(session, response.toJSONString());
     }
 
     @Override
     public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
         String sessionId = session.getId();
         SESSION_MAP.remove(sessionId);
+        clearAuthentication(session);
         subscriptionManager.removeSession(sessionId);
         log.info("WebSocket 连接关闭: sessionId={}, closeStatus={}", sessionId, status);
     }
@@ -235,6 +367,7 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
     public void handleTransportError(WebSocketSession session, Throwable exception) {
         String sessionId = session.getId();
         SESSION_MAP.remove(sessionId);
+        clearAuthentication(session);
         subscriptionManager.removeSession(sessionId);
         log.error("WebSocket 传输异常: sessionId={}", sessionId, exception);
     }
@@ -253,7 +386,7 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
         if (msg != null) {
             response.put("msg", msg);
         }
-        session.sendMessage(new TextMessage(response.toJSONString()));
+        sendText(session, response.toJSONString());
     }
 
     /**
@@ -264,6 +397,38 @@ public class DeviceStatusWebSocketHandler extends TextWebSocketHandler {
         response.put("type", "error");
         response.put("success", false);
         response.put("msg", msg);
-        session.sendMessage(new TextMessage(response.toJSONString()));
+        sendText(session, response.toJSONString());
+    }
+
+    /**
+     * 统一串行发送,防止心跳、Redis 通知和工单定时推送并发写同一会话。
+     */
+    public boolean sendText(WebSocketSession session, String payload) {
+        if (session == null || !session.isOpen()) {
+            return false;
+        }
+        try {
+            synchronized (session) {
+                session.sendMessage(new TextMessage(payload));
+            }
+            return true;
+        } catch (Exception e) {
+            log.error("WebSocket 消息发送失败: sessionId={}", session.getId(), e);
+            SESSION_MAP.remove(session.getId());
+            subscriptionManager.removeSession(session.getId());
+            return false;
+        }
+    }
+
+    private void clearAuthentication(WebSocketSession session) {
+        if (session != null) {
+            Map<String, Object> attributes = session.getAttributes();
+            if (attributes != null) {
+                synchronized (session) {
+                    attributes.remove(WORK_ORDER_LOGIN_USER_ATTRIBUTE);
+                    attributes.remove(WORK_ORDER_TOKEN_ATTRIBUTE);
+                }
+            }
+        }
     }
-}
+}

+ 3 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/RedisPubSubConfig.java

@@ -3,6 +3,7 @@ package com.zksy.web.websocket;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.EnableScheduling;
 import org.springframework.data.redis.connection.RedisConnectionFactory;
 import org.springframework.data.redis.listener.ChannelTopic;
 import org.springframework.data.redis.listener.RedisMessageListenerContainer;
@@ -16,6 +17,7 @@ import org.springframework.data.redis.listener.RedisMessageListenerContainer;
  * @author zksy
  */
 @Configuration
+@EnableScheduling
 public class RedisPubSubConfig {
 
     @Autowired
@@ -39,4 +41,4 @@ public class RedisPubSubConfig {
 
         return container;
     }
-}
+}

+ 153 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/WorkOrderWebSocketPushTask.java

@@ -0,0 +1,153 @@
+package com.zksy.web.websocket;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.zksy.base.domain.vo.WorkOrderPushVO;
+import com.zksy.base.service.BusinessWorkOrderService;
+import com.zksy.common.core.domain.model.LoginUser;
+import com.zksy.common.utils.DateUtils;
+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 org.springframework.web.socket.WebSocketSession;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+/**
+ * 待处理及超时工单 WebSocket 主动推送任务。
+ */
+@Slf4j
+@Component
+public class WorkOrderWebSocketPushTask {
+
+    public static final String PUSH_TYPE = "workOrderReminderPush";
+
+    @Autowired
+    private BusinessWorkOrderService businessWorkOrderService;
+
+    @Autowired
+    private DeviceStatusWebSocketHandler webSocketHandler;
+
+    @Value("${websocket.work-order.max-items:100}")
+    private int maxItems;
+
+    /** sessionId -> 上次已推送的业务快照指纹。 */
+    private final Map<String, String> lastFingerprints = new ConcurrentHashMap<>();
+
+    @Scheduled(
+            fixedDelayString = "${websocket.work-order.scan-interval-ms:60000}",
+            initialDelayString = "${websocket.work-order.initial-delay-ms:10000}")
+    public void pushPendingOrOverdueWorkOrders() {
+        Map<String, WebSocketSession> sessions = webSocketHandler.getSessionSnapshot();
+        cleanupDisconnectedSessions(sessions.keySet());
+        if (sessions.isEmpty()) {
+            return;
+        }
+
+        // 先完成会话鉴权,再查库;只有确实存在需要接收工单提醒的用户时才执行扫描。
+        // 这样设备状态专用连接不会触发无意义的工单全表查询,同时每次扫描只校验一次 Token。
+        Map<String, LoginUser> authenticatedUsers = new HashMap<>();
+        for (Map.Entry<String, WebSocketSession> entry : sessions.entrySet()) {
+            LoginUser loginUser = webSocketHandler.getAuthenticatedUser(entry.getValue());
+            if (loginUser == null) {
+                lastFingerprints.remove(entry.getKey());
+            } else {
+                authenticatedUsers.put(entry.getKey(), loginUser);
+            }
+        }
+        if (authenticatedUsers.isEmpty()) {
+            return;
+        }
+
+        Date now = new Date();
+        List<WorkOrderPushVO> candidates = businessWorkOrderService.selectPendingOrOverdueWorkOrders(now);
+        if (candidates == null) {
+            candidates = Collections.emptyList();
+        }
+        for (Map.Entry<String, LoginUser> userEntry : authenticatedUsers.entrySet()) {
+            String sessionId = userEntry.getKey();
+            WebSocketSession session = sessions.get(sessionId);
+            LoginUser loginUser = userEntry.getValue();
+
+            List<WorkOrderPushVO> visibleItems = candidates.stream()
+                    .filter(item -> isVisibleTo(item, loginUser))
+                    .collect(Collectors.toList());
+            pushSnapshot(sessionId, session, loginUser, visibleItems, now);
+        }
+    }
+
+    private void pushSnapshot(String sessionId, WebSocketSession session,
+                              LoginUser loginUser, List<WorkOrderPushVO> visibleItems, Date now) {
+        int total = visibleItems.size();
+        int limit = Math.max(1, maxItems);
+        List<WorkOrderPushVO> items = total > limit
+                ? new ArrayList<>(visibleItems.subList(0, limit)) : visibleItems;
+        String fingerprint = buildFingerprint(visibleItems, loginUser);
+        String previous = lastFingerprints.get(sessionId);
+
+        if (fingerprint.equals(previous) || (previous == null && visibleItems.isEmpty())) {
+            return;
+        }
+
+        JSONObject data = new JSONObject();
+        data.put("items", items);
+        data.put("total", total);
+        data.put("truncated", total > items.size());
+        data.put("serverTime", DateUtils.parseDateToStr(DateUtils.YYYY_MM_DD_HH_MM_SS, now));
+
+        JSONObject response = new JSONObject();
+        response.put("type", PUSH_TYPE);
+        response.put("success", true);
+        response.put("data", data);
+        String payload = JSON.toJSONString(response, SerializerFeature.WriteDateUseDateFormat);
+        if (webSocketHandler.sendText(session, payload)) {
+            lastFingerprints.put(sessionId, fingerprint);
+            log.info("工单提醒推送完成: sessionId={}, total={}, sent={}",
+                    sessionId, total, items.size());
+        }
+    }
+
+    boolean isVisibleTo(WorkOrderPushVO item, LoginUser loginUser) {
+        return DeviceStatusWebSocketHandler.isWorkOrderVisibleTo(item, loginUser);
+    }
+
+    private String buildFingerprint(List<WorkOrderPushVO> items, LoginUser loginUser) {
+        if (items.isEmpty()) {
+            return "EMPTY:" + userFingerprint(loginUser);
+        }
+        return userFingerprint(loginUser) + ":" + items.stream()
+                .map(item -> String.valueOf(item.getOrderId()) + ':'
+                        + item.getOrderStatus() + ':'
+                        + time(item.getEffectiveDeadline()) + ':'
+                        + time(item.getUpdateTime()) + ':'
+                        + item.getDeptId() + ':'
+                        + item.getReceiveUser())
+                .collect(Collectors.joining("|"));
+    }
+
+    private String userFingerprint(LoginUser loginUser) {
+        if (loginUser == null) {
+            return "ANONYMOUS";
+        }
+        return String.valueOf(loginUser.getUserId()) + ":" + String.valueOf(loginUser.getDeptId());
+    }
+
+    private long time(Date value) {
+        return value != null ? value.getTime() : 0L;
+    }
+
+    private void cleanupDisconnectedSessions(Set<String> activeSessionIds) {
+        lastFingerprints.keySet().retainAll(activeSessionIds);
+    }
+}

+ 23 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/WaterSupply/PipeNetworkStatistics/PipeNetworkStatisticsControllerTerminologyTest.java

@@ -0,0 +1,23 @@
+package com.zksy.web.controller.WaterSupply.PipeNetworkStatistics;
+
+import io.swagger.annotations.ApiOperation;
+import org.junit.jupiter.api.Test;
+
+import javax.servlet.http.HttpServletResponse;
+import java.lang.reflect.Method;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PipeNetworkStatisticsControllerTerminologyTest {
+
+    @Test
+    void exportSwaggerDescriptionUsesUnifiedWaterUserWorksheetName() throws Exception {
+        Method export = PipeNetworkStatisticsController.class.getMethod(
+                "export", HttpServletResponse.class, String.class, String.class);
+        ApiOperation operation = export.getAnnotation(ApiOperation.class);
+
+        assertTrue(operation.notes().contains("用水户统计"));
+        assertFalse(operation.notes().contains("大用水户统计"));
+    }
+}

+ 32 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/WaterSupply/WaterFacilityExportControllerContractTest.java

@@ -0,0 +1,32 @@
+package com.zksy.web.controller.WaterSupply;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.format.annotation.DateTimeFormat;
+
+import java.lang.reflect.Method;
+import java.time.LocalDateTime;
+import java.util.Arrays;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class WaterFacilityExportControllerContractTest {
+
+    @Test
+    void allFacilityExportsUseTheFrontendDateTimeContract() {
+        for (String methodName : new String[]{"sourceExport", "plantExport", "pumpExport", "pipeExport", "userExport"}) {
+            Method method = Arrays.stream(WaterFacilityExportController.class.getDeclaredMethods())
+                    .filter(candidate -> candidate.getName().equals(methodName))
+                    .findFirst()
+                    .orElseThrow();
+
+            Arrays.stream(method.getParameters())
+                    .filter(parameter -> parameter.getType().equals(LocalDateTime.class))
+                    .forEach(parameter -> {
+                        DateTimeFormat format = parameter.getAnnotation(DateTimeFormat.class);
+                        assertTrue(format != null, methodName + " must declare @DateTimeFormat");
+                        assertEquals("yyyy-MM-dd HH:mm:ss", format.pattern(), methodName);
+                    });
+        }
+    }
+}

+ 53 - 0
pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/WaterSupply/WaterSupplyGisMap/WaterSupplyGisMapControllerTest.java

@@ -0,0 +1,53 @@
+package com.zksy.web.controller.WaterSupply.WaterSupplyGisMap;
+
+import com.zksy.WaterSupply.WaterSupplyGisMap.service.WaterSupplyGisMapService;
+import com.zksy.common.core.domain.AjaxResult;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Map;
+
+class WaterSupplyGisMapControllerTest {
+
+    @Test
+    void missingFacilityDetailUsesTheExistingFacilitySpecificWarning() {
+        WaterSupplyGisMapService service = mock(WaterSupplyGisMapService.class);
+        WaterSupplyGisMapController controller = controllerWith(service);
+
+        for (Map.Entry<String, String> expected : Map.of(
+                "source", "未找到该水源地信息",
+                "plant", "未找到该水厂信息",
+                "pumpStation", "未找到该泵站信息",
+                "pipe", "未找到该供水管线信息",
+                "user", "未找到该用水户信息").entrySet()) {
+            when(service.getDetail(expected.getKey(), 1L)).thenReturn(null);
+            AjaxResult result = controller.detail(expected.getKey(), 1L);
+
+            assertEquals(601, result.get(AjaxResult.CODE_TAG));
+            assertTrue(result.get(AjaxResult.MSG_TAG).toString().contains(expected.getValue()));
+        }
+    }
+
+    @Test
+    void detailQueryFailureUsesTheExistingExplainableError() {
+        WaterSupplyGisMapService service = mock(WaterSupplyGisMapService.class);
+        when(service.getDetail("plant", 2L)).thenThrow(new IllegalStateException("数据库暂不可用"));
+        WaterSupplyGisMapController controller = controllerWith(service);
+
+        AjaxResult result = controller.detail("plant", 2L);
+
+        assertEquals(500, result.get(AjaxResult.CODE_TAG));
+        assertEquals("查询失败:数据库暂不可用", result.get(AjaxResult.MSG_TAG));
+    }
+
+    private static WaterSupplyGisMapController controllerWith(WaterSupplyGisMapService service) {
+        WaterSupplyGisMapController controller = new WaterSupplyGisMapController();
+        ReflectionTestUtils.setField(controller, "gisMapService", service);
+        return controller;
+    }
+}

+ 26 - 0
pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/web/service/TokenService.java

@@ -82,6 +82,32 @@ public class TokenService
         return null;
     }
 
+    /**
+     * 根据 WebSocket 首帧携带的 JWT 查询登录用户。
+     * WebSocket 客户端通常无法自定义 Authorization 请求头,因此由握手建立后的
+     * authenticate 消息调用此方法完成同一套 JWT/Redis 校验。
+     */
+    public LoginUser getLoginUserByToken(String token)
+    {
+        if (StringUtils.isEmpty(token))
+        {
+            return null;
+        }
+        try
+        {
+            String rawToken = token.startsWith(Constants.TOKEN_PREFIX)
+                    ? token.substring(Constants.TOKEN_PREFIX.length()) : token;
+            Claims claims = parseToken(rawToken);
+            String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
+            return redisCache.getCacheObject(getTokenKey(uuid));
+        }
+        catch (Exception e)
+        {
+            log.warn("WebSocket token 校验失败: {}", e.getMessage());
+            return null;
+        }
+    }
+
     /**
      * 设置用户身份信息
      */

+ 1 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/PipeNetworkStatistics/service/impl/PipeNetworkStatisticsExportServiceImpl.java

@@ -27,7 +27,7 @@ public class PipeNetworkStatisticsExportServiceImpl implements PipeNetworkStatis
             writeMapSheet(workbook.createSheet("水厂统计"), dashboard.getWaterPlant());
             writeMapSheet(workbook.createSheet("泵站统计"), dashboard.getPumpStation());
             writeMapSheet(workbook.createSheet("水源地统计"), dashboard.getWaterSource());
-            writeMapSheet(workbook.createSheet("用水户统计"), dashboard.getWaterUser());
+            writeMapSheet(workbook.createSheet("用水户统计"), dashboard.getWaterUser());
             workbook.write(outputStream);
         }
     }

+ 3 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterPipeInfo/service/IWaterPipeInfoService.java

@@ -22,6 +22,8 @@ public interface IWaterPipeInfoService extends IService<WaterPipeInfo> {
                                    String pipeName,
                                    String pipeMaterial,
                                    String status,
+                                   Integer pipeDiameter,
+                                   Integer layingYear,
                                    LocalDateTime startTime,
                                    LocalDateTime endTime);
 
@@ -64,4 +66,4 @@ public interface IWaterPipeInfoService extends IService<WaterPipeInfo> {
      * 批量彻底物理删除数据
      */
     boolean deleteByIdsPhysically(List<Long> ids);
-}
+}

+ 14 - 7
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterPipeInfo/service/impl/WaterPipeInfoServiceImpl.java

@@ -8,12 +8,10 @@ import com.zksy.WaterSupply.WaterPipeInfo.domain.WaterPipeInfo;
 import com.zksy.WaterSupply.WaterPipeInfo.mapper.WaterPipeInfoMapper;
 import com.zksy.WaterSupply.WaterPipeInfo.service.IWaterPipeInfoService;
 import org.springframework.stereotype.Service;
-import org.springframework.util.StringUtils;
 
 import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
-import java.util.stream.Collectors;
 
 @Service
 public class WaterPipeInfoServiceImpl extends ServiceImpl<WaterPipeInfoMapper, WaterPipeInfo>
@@ -25,12 +23,21 @@ public class WaterPipeInfoServiceImpl extends ServiceImpl<WaterPipeInfoMapper, W
                                           String pipeName,
                                           String pipeMaterial,
                                           String status,
+                                          Integer pipeDiameter,
+                                          Integer layingYear,
                                           LocalDateTime startTime,
                                           LocalDateTime endTime) {
-        List<WaterPipeInfo> list = baseMapper.selectPipeList(pipeCode, pipeName, pipeMaterial, status, startTime, endTime);
-        page.setRecords(list);
-        page.setTotal(list.size());
-        return page;
+        LambdaQueryWrapper<WaterPipeInfo> query = new LambdaQueryWrapper<WaterPipeInfo>()
+                .like(pipeCode != null && !pipeCode.isEmpty(), WaterPipeInfo::getPipeCode, pipeCode)
+                .like(pipeName != null && !pipeName.isEmpty(), WaterPipeInfo::getPipeName, pipeName)
+                .eq(pipeMaterial != null && !pipeMaterial.isEmpty(), WaterPipeInfo::getPipeMaterial, pipeMaterial)
+                .eq(status != null && !status.isEmpty(), WaterPipeInfo::getStatus, status)
+                .eq(pipeDiameter != null, WaterPipeInfo::getPipeDiameter, pipeDiameter)
+                .eq(layingYear != null, WaterPipeInfo::getLayingYear, layingYear)
+                .ge(startTime != null, WaterPipeInfo::getCreateTime, startTime)
+                .le(endTime != null, WaterPipeInfo::getCreateTime, endTime)
+                .orderByDesc(WaterPipeInfo::getCreateTime);
+        return baseMapper.selectPage(page, query);
     }
 
     @Override
@@ -82,4 +89,4 @@ public class WaterPipeInfoServiceImpl extends ServiceImpl<WaterPipeInfoMapper, W
         }
         return baseMapper.deleteByIdsPhysically(ids) > 0;
     }
-}
+}

+ 11 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterPlantInfo/service/impl/WaterPlantInfoServiceImpl.java

@@ -1,5 +1,6 @@
 package com.zksy.WaterSupply.WaterPlantInfo.service.impl;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -23,10 +24,15 @@ public class WaterPlantInfoServiceImpl extends ServiceImpl<WaterPlantInfoMapper,
                                            String principal,
                                            LocalDateTime startTime,
                                            LocalDateTime endTime) {
-        List<WaterPlantInfo> list = baseMapper.selectPlantList(plantCode, plantName, status, principal, startTime, endTime);
-        page.setRecords(list);
-        page.setTotal(list.size());
-        return page;
+        LambdaQueryWrapper<WaterPlantInfo> query = new LambdaQueryWrapper<WaterPlantInfo>()
+                .like(plantCode != null && !plantCode.isEmpty(), WaterPlantInfo::getPlantCode, plantCode)
+                .like(plantName != null && !plantName.isEmpty(), WaterPlantInfo::getPlantName, plantName)
+                .eq(status != null && !status.isEmpty(), WaterPlantInfo::getStatus, status)
+                .like(principal != null && !principal.isEmpty(), WaterPlantInfo::getPrincipal, principal)
+                .ge(startTime != null, WaterPlantInfo::getCreateTime, startTime)
+                .le(endTime != null, WaterPlantInfo::getCreateTime, endTime)
+                .orderByDesc(WaterPlantInfo::getCreateTime);
+        return baseMapper.selectPage(page, query);
     }
 
     @Override
@@ -57,4 +63,4 @@ public class WaterPlantInfoServiceImpl extends ServiceImpl<WaterPlantInfoMapper,
         }
         return baseMapper.deleteByIdsPhysically(ids) > 0;
     }
-}
+}

+ 11 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterPumpStation/service/impl/WaterPumpStationServiceImpl.java

@@ -1,5 +1,6 @@
 package com.zksy.WaterSupply.WaterPumpStation.service.impl;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -23,10 +24,15 @@ public class WaterPumpStationServiceImpl extends ServiceImpl<WaterPumpStationMap
                                              String principal,
                                              LocalDateTime startTime,
                                              LocalDateTime endTime) {
-        List<WaterPumpStation> list = baseMapper.selectStationList(stationCode, stationName, status, principal, startTime, endTime);
-        page.setRecords(list);
-        page.setTotal(list.size());
-        return page;
+        LambdaQueryWrapper<WaterPumpStation> query = new LambdaQueryWrapper<WaterPumpStation>()
+                .like(stationCode != null && !stationCode.isEmpty(), WaterPumpStation::getStationCode, stationCode)
+                .like(stationName != null && !stationName.isEmpty(), WaterPumpStation::getStationName, stationName)
+                .eq(status != null && !status.isEmpty(), WaterPumpStation::getStatus, status)
+                .like(principal != null && !principal.isEmpty(), WaterPumpStation::getPrincipal, principal)
+                .ge(startTime != null, WaterPumpStation::getCreateTime, startTime)
+                .le(endTime != null, WaterPumpStation::getCreateTime, endTime)
+                .orderByDesc(WaterPumpStation::getCreateTime);
+        return baseMapper.selectPage(page, query);
     }
 
     @Override
@@ -57,4 +63,4 @@ public class WaterPumpStationServiceImpl extends ServiceImpl<WaterPumpStationMap
         }
         return baseMapper.deleteByIdsPhysically(ids) > 0;
     }
-}
+}

+ 11 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterSourceInfo/service/impl/WaterSourceInfoServiceImpl.java

@@ -1,5 +1,6 @@
 package com.zksy.WaterSupply.WaterSourceInfo.service.impl;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -23,10 +24,15 @@ public class WaterSourceInfoServiceImpl extends ServiceImpl<WaterSourceInfoMappe
                                             String unit,
                                             LocalDateTime startTime,
                                             LocalDateTime endTime) {
-        List<WaterSourceInfo> list = baseMapper.selectSourceList(sourceId, sourceName, status, unit, startTime, endTime);
-        page.setRecords(list);
-        page.setTotal(list.size());
-        return page;
+        LambdaQueryWrapper<WaterSourceInfo> query = new LambdaQueryWrapper<WaterSourceInfo>()
+                .like(sourceId != null && !sourceId.isEmpty(), WaterSourceInfo::getSourceId, sourceId)
+                .like(sourceName != null && !sourceName.isEmpty(), WaterSourceInfo::getSourceName, sourceName)
+                .eq(status != null && !status.isEmpty(), WaterSourceInfo::getStatus, status)
+                .like(unit != null && !unit.isEmpty(), WaterSourceInfo::getUnit, unit)
+                .ge(startTime != null, WaterSourceInfo::getCreateTime, startTime)
+                .le(endTime != null, WaterSourceInfo::getCreateTime, endTime)
+                .orderByDesc(WaterSourceInfo::getCreateTime);
+        return baseMapper.selectPage(page, query);
     }
 
     @Override
@@ -53,4 +59,4 @@ public class WaterSourceInfoServiceImpl extends ServiceImpl<WaterSourceInfoMappe
         if (ids == null || ids.isEmpty()) return false;
         return baseMapper.deleteByIdsPhysically(ids) > 0;
     }
-}
+}

+ 52 - 8
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterSupplyGisMap/service/impl/WaterSupplyGisMapServiceImpl.java

@@ -16,7 +16,6 @@ import javax.annotation.Resource;
 import java.math.BigDecimal;
 import java.util.ArrayList;
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -24,8 +23,9 @@ import java.util.Set;
 
 @Service
 public class WaterSupplyGisMapServiceImpl implements WaterSupplyGisMapService {
+    private static final List<String> TYPE_ORDER = List.of("source", "plant", "pumpStation", "pipe", "user");
     private static final Set<String> SUPPORTED_TYPES = Set.of("source", "plant", "pumpStation", "pipe", "user");
-    private static final Map<String, String> TYPE_NAMES = Map.of("source", "水源地", "plant", "水厂", "pumpStation", "泵站", "pipe", "供水管网", "user", "用水户");
+    private static final Map<String, String> TYPE_NAMES = Map.of("source", "水源地", "plant", "水厂", "pumpStation", "泵站", "pipe", "供水管网", "user", "用水户");
 
     @Resource private WaterSupplyGisMapMapper mapper;
     @Resource private IWaterSourceInfoService sourceService;
@@ -62,18 +62,44 @@ public class WaterSupplyGisMapServiceImpl implements WaterSupplyGisMapService {
     @Override
     public List<GisOverviewItemDTO> getOverview() {
         List<GisOverviewItemDTO> rows = mapper.selectOverview();
-        if (rows == null || rows.isEmpty()) return Collections.emptyList();
+        Map<String, GisOverviewItemDTO> rowsByType = rows == null
+                ? Collections.emptyMap()
+                : rows.stream().collect(java.util.stream.Collectors.toMap(
+                        GisOverviewItemDTO::getFacilityType,
+                        item -> item,
+                        (left, right) -> left));
+        List<GisOverviewItemDTO> result = new ArrayList<>();
+        for (String type : TYPE_ORDER) {
+            GisOverviewItemDTO item = rowsByType.getOrDefault(type, emptyOverviewItem(type));
+            item.setTotalCount(zeroIfNull(item.getTotalCount()));
+            item.setGeoCompleteCount(zeroIfNull(item.getGeoCompleteCount()));
+            item.setGeoMissingCount(zeroIfNull(item.getGeoMissingCount()));
+            result.add(item);
+        }
         GisOverviewItemDTO total = new GisOverviewItemDTO();
         total.setFacilityType("total");
         total.setFacilityTypeName("合计");
-        total.setTotalCount(rows.stream().mapToLong(item -> item.getTotalCount() == null ? 0 : item.getTotalCount()).sum());
-        total.setGeoCompleteCount(rows.stream().mapToLong(item -> item.getGeoCompleteCount() == null ? 0 : item.getGeoCompleteCount()).sum());
-        total.setGeoMissingCount(rows.stream().mapToLong(item -> item.getGeoMissingCount() == null ? 0 : item.getGeoMissingCount()).sum());
-        List<GisOverviewItemDTO> result = new ArrayList<>(rows);
+        total.setTotalCount(result.stream().mapToLong(GisOverviewItemDTO::getTotalCount).sum());
+        total.setGeoCompleteCount(result.stream().mapToLong(GisOverviewItemDTO::getGeoCompleteCount).sum());
+        total.setGeoMissingCount(result.stream().mapToLong(GisOverviewItemDTO::getGeoMissingCount).sum());
         result.add(total);
         return result;
     }
 
+    private GisOverviewItemDTO emptyOverviewItem(String type) {
+        GisOverviewItemDTO item = new GisOverviewItemDTO();
+        item.setFacilityType(type);
+        item.setFacilityTypeName(TYPE_NAMES.get(type));
+        item.setTotalCount(0L);
+        item.setGeoCompleteCount(0L);
+        item.setGeoMissingCount(0L);
+        return item;
+    }
+
+    private long zeroIfNull(Long value) {
+        return value == null ? 0L : value;
+    }
+
     @Override
     public GisFeatureDTO toPipeFeature(Map<String, Object> row) {
         GisFeatureDTO feature = baseFeature("pipe", row);
@@ -90,6 +116,7 @@ public class WaterSupplyGisMapServiceImpl implements WaterSupplyGisMapService {
     private void addPointFeatures(GisFeatureCollectionDTO collection, String type, List<Map<String, Object>> rows) {
         if (rows == null) return;
         for (Map<String, Object> row : rows) {
+            if (!hasValues(row, "longitude", "latitude")) continue;
             GisFeatureDTO feature = baseFeature(type, row);
             Map<String, Object> geometry = new LinkedHashMap<>();
             geometry.put("type", "Point");
@@ -100,7 +127,15 @@ public class WaterSupplyGisMapServiceImpl implements WaterSupplyGisMapService {
     }
 
     private void addPipeFeatures(GisFeatureCollectionDTO collection, List<Map<String, Object>> rows) {
-        if (rows != null) for (Map<String, Object> row : rows) collection.getFeatures().add(toPipeFeature(row));
+        if (rows != null) {
+            for (Map<String, Object> row : rows) {
+                if (!hasCoordinate(row, "startLongitude", "start_longitude")
+                        || !hasCoordinate(row, "startLatitude", "start_latitude")
+                        || !hasCoordinate(row, "endLongitude", "end_longitude")
+                        || !hasCoordinate(row, "endLatitude", "end_latitude")) continue;
+                collection.getFeatures().add(toPipeFeature(row));
+            }
+        }
     }
 
     private GisFeatureDTO baseFeature(String type, Map<String, Object> row) {
@@ -132,4 +167,13 @@ public class WaterSupplyGisMapServiceImpl implements WaterSupplyGisMapService {
         Object value = value(row, keys);
         return value instanceof BigDecimal ? (BigDecimal) value : new BigDecimal(value.toString());
     }
+
+    private boolean hasValues(Map<String, Object> row, String... keys) {
+        for (String key : keys) if (value(row, key) == null) return false;
+        return true;
+    }
+
+    private boolean hasCoordinate(Map<String, Object> row, String... aliases) {
+        return value(row, aliases) != null;
+    }
 }

+ 6 - 6
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterUserInfo/domain/WaterUserInfo.java

@@ -34,7 +34,7 @@ public class WaterUserInfo {
     private Long id;
 
     @ApiModelProperty(
-            value = "用户编号",
+            value = "用户编号",
             example = "USER_XX",
             required = true,
             position = 2
@@ -42,15 +42,15 @@ public class WaterUserInfo {
     private String userCode;
 
     @ApiModelProperty(
-            value = "用户名称",
-            example = "XX用户",
+            value = "用户名称",
+            example = "XX用户",
             required = true,
             position = 3
     )
     private String userName;
 
     @ApiModelProperty(
-            value = "用户类型",
+            value = "用户类型",
             example = "居民",
             position = 4,
             notes = "居民/工业/商业/行政事业"
@@ -116,7 +116,7 @@ public class WaterUserInfo {
     private BigDecimal annualWaterConsumption;
 
     @ApiModelProperty(
-            value = "用户状态",
+            value = "用户状态",
             example = "0",
             allowableValues = "0,1,2",
             position = 12,
@@ -126,7 +126,7 @@ public class WaterUserInfo {
 
     @ApiModelProperty(
             value = "备注",
-            example = "XX区域重要用水户",
+            example = "用水户补充说明",
             position = 13
     )
     private String remark;

+ 11 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/WaterUserInfo/service/impl/WaterUserInfoServiceImpl.java

@@ -1,5 +1,6 @@
 package com.zksy.WaterSupply.WaterUserInfo.service.impl;
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
@@ -22,10 +23,15 @@ public class WaterUserInfoServiceImpl extends ServiceImpl<WaterUserInfoMapper, W
                                           String userType,
                                           LocalDateTime startTime,
                                           LocalDateTime endTime) {
-        List<WaterUserInfo> list = baseMapper.selectUserList(userCode, userName, status, userType, startTime, endTime);
-        page.setRecords(list);
-        page.setTotal(list.size());
-        return page;
+        LambdaQueryWrapper<WaterUserInfo> query = new LambdaQueryWrapper<WaterUserInfo>()
+                .like(userCode != null && !userCode.isEmpty(), WaterUserInfo::getUserCode, userCode)
+                .like(userName != null && !userName.isEmpty(), WaterUserInfo::getUserName, userName)
+                .eq(status != null && !status.isEmpty(), WaterUserInfo::getStatus, status)
+                .eq(userType != null && !userType.isEmpty(), WaterUserInfo::getUserType, userType)
+                .ge(startTime != null, WaterUserInfo::getCreateTime, startTime)
+                .le(endTime != null, WaterUserInfo::getCreateTime, endTime)
+                .orderByDesc(WaterUserInfo::getCreateTime);
+        return baseMapper.selectPage(page, query);
     }
 
     @Override
@@ -52,4 +58,4 @@ public class WaterUserInfoServiceImpl extends ServiceImpl<WaterUserInfoMapper, W
         if (ids == null || ids.isEmpty()) return false;
         return baseMapper.deleteByIdsPhysically(ids) > 0;
     }
-}
+}

+ 2 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/export/WaterPipeExportDTO.java

@@ -12,11 +12,12 @@ public class WaterPipeExportDTO {
     @Excel(name = "管网名称") private String pipeName;
     @Excel(name = "材质") private String pipeMaterial;
     @Excel(name = "管径(mm)") private Integer pipeDiameter;
+    @Excel(name = "铺设年份") private Integer layingYear;
     @Excel(name = "长度(m)") private BigDecimal pipeLength;
     @Excel(name = "状态") private String status;
     @Excel(name = "起点经度") private BigDecimal startLongitude;
     @Excel(name = "起点纬度") private BigDecimal startLatitude;
     @Excel(name = "终点经度") private BigDecimal endLongitude;
     @Excel(name = "终点纬度") private BigDecimal endLatitude;
-    public static WaterPipeExportDTO from(WaterPipeInfo value) { WaterPipeExportDTO dto = new WaterPipeExportDTO(); dto.pipeCode=value.getPipeCode(); dto.pipeName=value.getPipeName(); dto.pipeMaterial=value.getPipeMaterial(); dto.pipeDiameter=value.getPipeDiameter(); dto.pipeLength=value.getPipeLength(); dto.status=value.getStatus(); dto.startLongitude=value.getStartLongitude(); dto.startLatitude=value.getStartLatitude(); dto.endLongitude=value.getEndLongitude(); dto.endLatitude=value.getEndLatitude(); return dto; }
+    public static WaterPipeExportDTO from(WaterPipeInfo value) { WaterPipeExportDTO dto = new WaterPipeExportDTO(); dto.pipeCode=value.getPipeCode(); dto.pipeName=value.getPipeName(); dto.pipeMaterial=value.getPipeMaterial(); dto.pipeDiameter=value.getPipeDiameter(); dto.layingYear=value.getLayingYear(); dto.pipeLength=value.getPipeLength(); dto.status=value.getStatus(); dto.startLongitude=value.getStartLongitude(); dto.startLatitude=value.getStartLatitude(); dto.endLongitude=value.getEndLongitude(); dto.endLatitude=value.getEndLatitude(); return dto; }
 }

+ 3 - 3
pipe-network-service/zksy-system/src/main/java/com/zksy/WaterSupply/export/WaterUserExportDTO.java

@@ -8,9 +8,9 @@ import java.math.BigDecimal;
 
 @Data
 public class WaterUserExportDTO {
-    @Excel(name = "用户编号") private String userCode;
-    @Excel(name = "用户名称") private String userName;
-    @Excel(name = "用户类型") private String userType;
+    @Excel(name = "用户编号") private String userCode;
+    @Excel(name = "用户名称") private String userName;
+    @Excel(name = "用户类型") private String userType;
     @Excel(name = "地址") private String address;
     @Excel(name = "年用水量") private BigDecimal annualWaterConsumption;
     @Excel(name = "状态") private String status;

+ 12 - 6
pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/PipeNetworkBase.java

@@ -45,12 +45,6 @@ public class PipeNetworkBase {
     @ApiModelProperty("位置描述")
     private String location;
 
-    @ApiModelProperty("经度")
-    private BigDecimal longitude;
-
-    @ApiModelProperty("纬度")
-    private BigDecimal latitude;
-
     @ApiModelProperty("长度(米)")
     private BigDecimal length;
 
@@ -87,4 +81,16 @@ public class PipeNetworkBase {
     @TableLogic(value = "0", delval = "2")
     @ApiModelProperty("删除标志(0=正常 2=删除)")
     private String delFlag;
+
+    @ApiModelProperty("管网起点WGS84经度")
+    private BigDecimal start_longitude;
+
+    @ApiModelProperty("管网起点WGS84纬度")
+    private BigDecimal start_latitude;
+
+    @ApiModelProperty("管网终点WGS84经度")
+    private BigDecimal end_longitude;
+
+    @ApiModelProperty("管网终点WGS84纬度")
+    private BigDecimal end_latitude;
 }

+ 74 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/vo/WorkOrderPushVO.java

@@ -0,0 +1,74 @@
+package com.zksy.base.domain.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * WebSocket 工单提醒出参。
+ * 只返回提醒场景需要的字段,避免把工单完整维护字段广播给客户端。
+ */
+@Data
+@ApiModel(value = "WorkOrderPushVO", description = "WebSocket 工单提醒")
+public class WorkOrderPushVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @ApiModelProperty("工单ID")
+    private Long orderId;
+
+    @ApiModelProperty("工单编号")
+    private String orderNo;
+
+    @ApiModelProperty("工单状态:1-待派单 2-待接单 3-已接单 4-处理中 5-待验收 8-延期审核 9-已延期")
+    private Integer orderStatus;
+
+    @ApiModelProperty("工单优先级")
+    private Integer orderLevel;
+
+    @ApiModelProperty("所属部门ID")
+    private Long deptId;
+
+    @ApiModelProperty("接单人ID,多个使用逗号分隔")
+    private String receiveUser;
+
+    @ApiModelProperty("设备ID")
+    private String deviceId;
+
+    @ApiModelProperty("设备编码")
+    private String deviceCode;
+
+    @ApiModelProperty("工单描述")
+    private String orderDesc;
+
+    @JsonFormat(locale = "zh", pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    @ApiModelProperty("计划完成时间")
+    private Date planFinishTime;
+
+    @JsonFormat(locale = "zh", pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    @ApiModelProperty("延期截止时间")
+    private Date delayedDeadline;
+
+    @JsonFormat(locale = "zh", pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    @ApiModelProperty("当前生效的截止时间")
+    private Date effectiveDeadline;
+
+    @ApiModelProperty("超时类型:PENDING-待处理,PLAN_FINISH_TIME-计划完成时间,DELAYED_DEADLINE-延期截止时间")
+    private String overdueType;
+
+    @ApiModelProperty("是否已经超过生效截止时间")
+    private Boolean overdue;
+
+    @ApiModelProperty("超过截止时间的分钟数")
+    private Long overdueMinutes;
+
+    @JsonFormat(locale = "zh", pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date createTime;
+
+    @JsonFormat(locale = "zh", pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private Date updateTime;
+}

+ 80 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/event/DeviceStatusChangePublisher.java

@@ -0,0 +1,80 @@
+package com.zksy.base.event;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * 设备状态变更事件发布器。
+ *
+ * <p>状态写库成功后发布 Redis 事件,由管网主服务的 WebSocket 监听器
+ * 查询最新状态并推送给订阅该设备的客户端。事务存在时严格在提交成功后发布,
+ * 避免客户端收到已回滚的数据。</p>
+ */
+@Slf4j
+@Component
+public class DeviceStatusChangePublisher {
+
+    public static final String CHANNEL = "device:status:change";
+
+    @Autowired
+    private StringRedisTemplate redisTemplate;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    public void publishAfterCommit(String deviceCode, String equipmentId,
+                                   Integer currentStatus, Integer alarmStatus,
+                                   Integer onlineStatus, String source) {
+        if (deviceCode == null || deviceCode.trim().isEmpty()) {
+            log.warn("设备状态变更事件未发布:deviceCode为空, equipmentId={}, source={}", equipmentId, source);
+            return;
+        }
+
+        Runnable publishAction = () -> publish(deviceCode, equipmentId,
+                currentStatus, alarmStatus, onlineStatus, source);
+        if (TransactionSynchronizationManager.isSynchronizationActive()) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    publishAction.run();
+                }
+            });
+        } else {
+            publishAction.run();
+        }
+    }
+
+    private void publish(String deviceCode, String equipmentId,
+                         Integer currentStatus, Integer alarmStatus,
+                         Integer onlineStatus, String source) {
+        Map<String, Object> event = new LinkedHashMap<>();
+        event.put("eventId", UUID.randomUUID().toString());
+        event.put("deviceCode", deviceCode);
+        event.put("equipmentId", equipmentId);
+        event.put("currentStatus", currentStatus);
+        event.put("alarmStatus", alarmStatus);
+        event.put("onlineStatus", onlineStatus);
+        event.put("source", source);
+        event.put("changedAt", LocalDateTime.now().toString());
+        try {
+            redisTemplate.convertAndSend(CHANNEL, objectMapper.writeValueAsString(event));
+            log.info("设备状态变更事件已发布: deviceCode={}, equipmentId={}, source={}",
+                    deviceCode, equipmentId, source);
+        } catch (JsonProcessingException e) {
+            log.error("设备状态变更事件序列化失败: deviceCode={}, source={}", deviceCode, source, e);
+        } catch (Exception e) {
+            log.error("设备状态变更事件发布失败: deviceCode={}, source={}", deviceCode, source, e);
+        }
+    }
+}

+ 8 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/mapper/WorkOrderMapper.java

@@ -3,6 +3,10 @@ package com.zksy.base.mapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import com.zksy.base.domain.WorkOrder;
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
 
 /**
 * @author Administrator
@@ -13,6 +17,10 @@ import org.apache.ibatis.annotations.Mapper;
 @Mapper
 public interface WorkOrderMapper extends BaseMapper<WorkOrder> {
 
+    /**
+     * 查询待处理或已超时的未办结工单。
+     */
+    List<WorkOrder> selectPendingOrOverdue(@Param("now") Date now);
 }
 
 

+ 7 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/BusinessWorkOrderService.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.service.IService;
 import com.zksy.base.domain.WorkOrder;
 import com.zksy.base.domain.WorkOrderLog;
+import com.zksy.base.domain.vo.WorkOrderPushVO;
 
 import java.util.Date;
 import java.util.List;
@@ -93,4 +94,10 @@ public interface BusinessWorkOrderService extends IService<WorkOrder> {
      * @return 工单ID
      */
     Long createWorkOrder(WorkOrder workOrder);
+
+    /**
+     * 查询需要通过 WebSocket 提醒的工单。
+     * 状态 1/2 始终纳入;其他未办结状态仅在计划完成时间或延期截止时间超时后纳入。
+     */
+    List<WorkOrderPushVO> selectPendingOrOverdueWorkOrders(Date now);
 }

+ 48 - 44
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/BusinessWorkOrderServiceImpl.java

@@ -5,12 +5,11 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.zksy.base.domain.EquipmentBase;
-import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.base.domain.HazardHiddenAccount;
 import com.zksy.base.domain.WorkOrder;
 import com.zksy.base.domain.WorkOrderLog;
+import com.zksy.base.domain.vo.WorkOrderPushVO;
 import com.zksy.base.mapper.EquipmentBaseMapper;
-import com.zksy.base.mapper.EquipmentStatusMapper;
 import com.zksy.base.mapper.HazardHiddenAccountMapper;
 import com.zksy.base.mapper.WorkOrderLogMapper;
 import com.zksy.base.mapper.WorkOrderMapper;
@@ -25,7 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
 import java.util.Arrays;
 import java.util.Date;
 import java.util.List;
-import java.time.LocalDateTime;
+import java.util.ArrayList;
 import java.util.stream.Collectors;
 
 /**
@@ -44,9 +43,6 @@ public class BusinessWorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, W
     @Autowired
     private HazardHiddenAccountMapper hazardHiddenAccountMapper;
 
-    @Autowired
-    private EquipmentStatusMapper equipmentStatusMapper;
-
     @Override
     public IPage<WorkOrder> selectWorkOrderList(Integer orderType, Integer orderLevel, String orderStatus, String deviceCode, String keyword, String equipmentType, Integer pageNum, Integer pageSize) {
         Page<WorkOrder> page = new Page<>(pageNum, pageSize);
@@ -336,6 +332,51 @@ public class BusinessWorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, W
         return workOrder.getOrderId();
     }
 
+    @Override
+    public List<WorkOrderPushVO> selectPendingOrOverdueWorkOrders(Date now) {
+        Date currentTime = now != null ? now : new Date();
+        List<WorkOrder> orders = baseMapper.selectPendingOrOverdue(currentTime);
+
+        List<WorkOrderPushVO> result = new ArrayList<>();
+        if (orders == null || orders.isEmpty()) {
+            return result;
+        }
+        for (WorkOrder order : orders) {
+            Integer status = order.getOrderStatus();
+            Date effectiveDeadline = order.getDelayedDeadline() != null
+                    ? order.getDelayedDeadline() : order.getPlanFinishTime();
+            boolean pending = Integer.valueOf(1).equals(status) || Integer.valueOf(2).equals(status);
+            boolean overdue = effectiveDeadline != null && effectiveDeadline.before(currentTime);
+            if (!pending && !overdue) {
+                continue;
+            }
+
+            WorkOrderPushVO item = new WorkOrderPushVO();
+            item.setOrderId(order.getOrderId());
+            item.setOrderNo(order.getOrderNo());
+            item.setOrderStatus(status);
+            item.setOrderLevel(order.getOrderLevel());
+            item.setDeptId(order.getDeptId());
+            item.setReceiveUser(order.getReceiveUser());
+            item.setDeviceId(order.getDeviceId());
+            item.setDeviceCode(order.getDeviceCode());
+            item.setOrderDesc(order.getOrderDesc());
+            item.setPlanFinishTime(order.getPlanFinishTime());
+            item.setDelayedDeadline(order.getDelayedDeadline());
+            item.setEffectiveDeadline(effectiveDeadline);
+            item.setOverdue(overdue);
+            item.setOverdueType(overdue
+                    ? order.getDelayedDeadline() != null ? "DELAYED_DEADLINE" : "PLAN_FINISH_TIME"
+                    : "PENDING");
+            item.setOverdueMinutes(overdue
+                    ? Math.max(0L, (currentTime.getTime() - effectiveDeadline.getTime()) / 60000L) : 0L);
+            item.setCreateTime(order.getCreateTime());
+            item.setUpdateTime(order.getUpdateTime());
+            result.add(item);
+        }
+        return result;
+    }
+
     /**
      * 校验设备是否存在进行中工单(状态 1-5/8/9),存在则禁止再次创建工单
      * 业务规则:同一设备同一时间只能有一个进行中的工单,结案(6)或驳回(7)后方可再次创建
@@ -423,8 +464,7 @@ public class BusinessWorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, W
         // 工单已结案 → 同步隐患状态为"已整改"
         syncHazardStatus(order, "已整改", result);
 
-        // 工单验收通过 → 重置关联设备报警状态为正常
-        resetEquipmentAlarmStatus(order);
+        // 设备报警状态由实时监测链路维护;工单结案不直接清除报警,避免覆盖仍存在的实时告警。
 
         WorkOrderLog logEntry = new WorkOrderLog();
         logEntry.setOrderId(orderId);
@@ -467,40 +507,4 @@ public class BusinessWorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, W
         }
     }
 
-    /**
-     * 工单验收通过后,重置关联设备的报警状态为正常(0)
-     * 参考 EquipmentStatusApiServiceImpl.updateAlarmStatus 逻辑
-     */
-    private void resetEquipmentAlarmStatus(WorkOrder order) {
-        if (StringUtils.isEmpty(order.getDeviceId())) {
-            return;
-        }
-        try {
-            LambdaQueryWrapper<EquipmentStatus> wrapper = new LambdaQueryWrapper<>();
-            wrapper.eq(EquipmentStatus::getEquipmentId, order.getDeviceId()).last("LIMIT 1");
-            EquipmentStatus existingStatus = equipmentStatusMapper.selectOne(wrapper);
-            if (existingStatus != null) {
-                // 更新已有记录
-                existingStatus.setAlarmStatus(0);
-                existingStatus.setStatusUpdateTime(LocalDateTime.now());
-                equipmentStatusMapper.updateById(existingStatus);
-                log.info("工单[{}]验收通过,设备[{}]报警状态已重置为正常",
-                        order.getOrderNo(), order.getDeviceCode());
-            } else {
-                // 不存在则创建新记录
-                EquipmentStatus newStatus = new EquipmentStatus();
-                newStatus.setEquipmentId(order.getDeviceId());
-                newStatus.setAlarmStatus(0);
-                newStatus.setCurrentStatus(1); // 默认在用
-                newStatus.setOnlineStatus(1);  // 默认在线
-                newStatus.setStatusUpdateTime(LocalDateTime.now());
-                newStatus.setCreateTime(LocalDateTime.now());
-                equipmentStatusMapper.insert(newStatus);
-                log.info("工单[{}]验收通过,创建设备状态记录,设备[{}]报警状态为正常",
-                        order.getOrderNo(), order.getDeviceCode());
-            }
-        } catch (Exception e) {
-            log.error("工单[{}]重置设备报警状态失败", order.getOrderNo(), e);
-        }
-    }
 }

+ 24 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/EquipmentBaseServiceImpl.java

@@ -11,6 +11,7 @@ import com.zksy.base.alarm.domain.WarningThreshold;
 import com.zksy.base.alarm.mapper.WarningThresholdMapper;
 import com.zksy.base.domain.*;
 import com.zksy.base.domain.vo.EquipmentFullVO;
+import com.zksy.base.event.DeviceStatusChangePublisher;
 import com.zksy.base.manhole.domain.ManholeData;
 import com.zksy.base.manhole.mapper.ManholeDataMapper;
 import com.zksy.base.mapper.*;
@@ -50,6 +51,9 @@ public class EquipmentBaseServiceImpl extends ServiceImpl<EquipmentBaseMapper, E
     @Autowired
     private EquipmentStatusMapper equipmentStatusMapper;
 
+    @Autowired
+    private DeviceStatusChangePublisher deviceStatusChangePublisher;
+
     @Autowired
     private EquipmentPointRelMapper equipmentPointRelMapper;
 
@@ -775,12 +779,30 @@ public class EquipmentBaseServiceImpl extends ServiceImpl<EquipmentBaseMapper, E
             status.setAlarmStatus(0);
             status.setOnlineStatus(1);
             status.setStatusUpdateTime(LocalDateTime.now());
-            return equipmentStatusMapper.insert(status) > 0;
+            boolean saved = equipmentStatusMapper.insert(status) > 0;
+            if (saved) {
+                publishEquipmentStatusChange(equipment, status, "pipe-network:updateEquipmentStatus");
+            }
+            return saved;
         } else {
+            boolean changed = !Objects.equals(status.getCurrentStatus(), currentStatus);
             status.setCurrentStatus(currentStatus);
             status.setStatusUpdateTime(LocalDateTime.now());
-            return equipmentStatusMapper.updateById(status) > 0;
+            boolean updated = equipmentStatusMapper.updateById(status) > 0;
+            if (updated && changed) {
+                publishEquipmentStatusChange(equipment, status, "pipe-network:updateEquipmentStatus");
+            }
+            return updated;
+        }
+    }
+
+    private void publishEquipmentStatusChange(EquipmentBase equipment, EquipmentStatus status, String source) {
+        if (equipment == null || status == null || StringUtils.isBlank(equipment.getEquipmentCode())) {
+            return;
         }
+        deviceStatusChangePublisher.publishAfterCommit(equipment.getEquipmentCode(),
+                status.getEquipmentId(), status.getCurrentStatus(), status.getAlarmStatus(),
+                status.getOnlineStatus(), source);
     }
 
     @Override

+ 74 - 4
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/EquipmentStatusServiceImpl.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
 import com.zksy.base.domain.EquipmentBase;
 import com.zksy.base.domain.EquipmentStatus;
+import com.zksy.base.event.DeviceStatusChangePublisher;
 import com.zksy.base.mapper.EquipmentBaseMapper;
 import com.zksy.base.mapper.EquipmentStatusMapper;
 import com.zksy.base.service.EquipmentStatusService;
@@ -20,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional;
 import java.util.Date;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
 
@@ -34,6 +36,9 @@ public class EquipmentStatusServiceImpl extends ServiceImpl<EquipmentStatusMappe
     @Autowired
     private EquipmentBaseMapper equipmentBaseMapper;
 
+    @Autowired
+    private DeviceStatusChangePublisher deviceStatusChangePublisher;
+
     @Override
     public Page<EquipmentStatus> findByPage(long pageNum, long pageSize,
                                             String statusId,String equipmentId,
@@ -55,6 +60,7 @@ public class EquipmentStatusServiceImpl extends ServiceImpl<EquipmentStatusMappe
      * @return
      */
     @Override
+    @Transactional
     public boolean saveWithCheck(EquipmentStatus entity) {
         //校验关联的设备是否存在
         String equipmentId = entity.getEquipmentId();
@@ -67,8 +73,13 @@ public class EquipmentStatusServiceImpl extends ServiceImpl<EquipmentStatusMappe
         wrapper.eq(EquipmentStatus::getEquipmentId, equipmentId);
         EquipmentStatus existing = this.getOne(wrapper);
         if (existing != null) {
+            boolean changed = hasStateChange(existing, entity);
             entity.setStatusId(existing.getStatusId());
-            return this.updateById(entity);
+            boolean updated = this.updateById(entity);
+            if (updated && changed) {
+                publishLatest(existing.getEquipmentId(), existing.getStatusId(), "pipe-network:saveStatus");
+            }
+            return updated;
         }
 
         //校验当前状态值是否合法(1-在用,2-闲置,3-维修,4-报废,5-待入库)
@@ -86,7 +97,11 @@ public class EquipmentStatusServiceImpl extends ServiceImpl<EquipmentStatusMappe
             }
         }
         //新增
-        return this.save(entity);
+        boolean saved = this.save(entity);
+        if (saved) {
+            publish(entity, "pipe-network:saveStatus");
+        }
+        return saved;
     }
 
     /**
@@ -154,7 +169,13 @@ public class EquipmentStatusServiceImpl extends ServiceImpl<EquipmentStatusMappe
             }
         }
         //批量新增
-        return this.saveBatch(entityList);
+        boolean saved = this.saveBatch(entityList);
+        if (saved) {
+            for (EquipmentStatus entity : entityList) {
+                publish(entity, "pipe-network:saveStatusBatch");
+            }
+        }
+        return saved;
     }
 
     /**
@@ -163,6 +184,7 @@ public class EquipmentStatusServiceImpl extends ServiceImpl<EquipmentStatusMappe
      * @return
      */
     @Override
+    @Transactional
     public boolean updateWithCheck(EquipmentStatus entity) {
         //校验当前状态记录是否存在
         Integer statusId = entity.getStatusId();
@@ -205,7 +227,12 @@ public class EquipmentStatusServiceImpl extends ServiceImpl<EquipmentStatusMappe
             }
         }
         //更新
-        return this.updateById(entity);
+        boolean changed = hasStateChange(oldStatus, entity);
+        boolean updated = this.updateById(entity);
+        if (updated && changed) {
+            publishLatest(oldStatus.getEquipmentId(), oldStatus.getStatusId(), "pipe-network:updateStatus");
+        }
+        return updated;
     }
 
     /**
@@ -214,6 +241,7 @@ public class EquipmentStatusServiceImpl extends ServiceImpl<EquipmentStatusMappe
      * @return
      */
     @Override
+    @Transactional
     public boolean updateDeviceStatus(EquipmentStatusInDTO inDTO) {
         log.info("修改设备状态-入参:{}", inDTO);
         //校验状态值合法性
@@ -222,11 +250,19 @@ public class EquipmentStatusServiceImpl extends ServiceImpl<EquipmentStatusMappe
             throw new ServiceException("设备状态值无效(必须为1-5):" + status);
         }
         //校验当前状态记录是否存在
+        EquipmentStatus oldStatus = this.lambdaQuery()
+                .eq(EquipmentStatus::getEquipmentId, inDTO.getEquipmentId())
+                .one();
+        boolean changed = oldStatus != null
+                && !Objects.equals(oldStatus.getCurrentStatus(), inDTO.getCurrentStatus());
         boolean update = this.lambdaUpdate()
                 .set(EquipmentStatus::getCurrentStatus, inDTO.getCurrentStatus())
                 .set(EquipmentStatus::getStatusUpdateTime, new Date())
                 .eq(EquipmentStatus::getEquipmentId, inDTO.getEquipmentId())
                 .update();
+        if (update && changed) {
+            publishLatest(inDTO.getEquipmentId(), oldStatus.getStatusId(), "pipe-network:updateDeviceStatus");
+        }
         return update;
     }
 
@@ -257,4 +293,38 @@ public class EquipmentStatusServiceImpl extends ServiceImpl<EquipmentStatusMappe
                 deviceCode, equipmentBase.getEquipmentId(), status != null);
         return status;
     }
+
+    private boolean hasStateChange(EquipmentStatus oldStatus, EquipmentStatus patch) {
+        if (oldStatus == null || patch == null) {
+            return false;
+        }
+        return (patch.getCurrentStatus() != null
+                && !Objects.equals(oldStatus.getCurrentStatus(), patch.getCurrentStatus()))
+                || (patch.getAlarmStatus() != null
+                && !Objects.equals(oldStatus.getAlarmStatus(), patch.getAlarmStatus()))
+                || (patch.getOnlineStatus() != null
+                && !Objects.equals(oldStatus.getOnlineStatus(), patch.getOnlineStatus()));
+    }
+
+    private void publish(EquipmentStatus status, String source) {
+        if (status == null) {
+            return;
+        }
+        publishLatest(status.getEquipmentId(), status.getStatusId(), source);
+    }
+
+    private void publishLatest(String equipmentId, Integer statusId, String source) {
+        if (equipmentId == null) {
+            return;
+        }
+        EquipmentStatus latest = statusId != null ? this.getById(statusId) : null;
+        if (latest == null) {
+            latest = this.lambdaQuery().eq(EquipmentStatus::getEquipmentId, equipmentId).one();
+        }
+        EquipmentBase equipment = equipmentBaseMapper.selectById(equipmentId);
+        if (latest != null && equipment != null) {
+            deviceStatusChangePublisher.publishAfterCommit(equipment.getEquipmentCode(), equipmentId,
+                    latest.getCurrentStatus(), latest.getAlarmStatus(), latest.getOnlineStatus(), source);
+        }
+    }
 }

+ 21 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/WaterSupplyDeviceServiceImpl.java

@@ -14,6 +14,7 @@ import com.zksy.base.domain.EquipmentMaintain;
 import com.zksy.base.domain.EquipmentStatus;
 import com.zksy.base.domain.EquipmentType;
 import com.zksy.base.domain.vo.*;
+import com.zksy.base.event.DeviceStatusChangePublisher;
 import com.zksy.base.environment.domain.ERealTimeData;
 import com.zksy.base.environment.mapper.ERealTimeDataMapper;
 import com.zksy.base.gas.domain.GasMonitorData;
@@ -53,6 +54,9 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
     @Autowired
     private EquipmentStatusMapper equipmentStatusMapper;
 
+    @Autowired
+    private DeviceStatusChangePublisher deviceStatusChangePublisher;
+
     @Autowired
     private EquipmentMaintainMapper equipmentMaintainMapper;
 
@@ -288,9 +292,13 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
                     new LambdaQueryWrapper<EquipmentStatus>().eq(EquipmentStatus::getEquipmentId, vo.getEquipmentId())
             );
             if (status != null) {
+                boolean changed = !Objects.equals(status.getCurrentStatus(), 3);
                 status.setCurrentStatus(3);
                 status.setStatusUpdateTime(LocalDateTime.now());
-                equipmentStatusMapper.updateById(status);
+                boolean updated = equipmentStatusMapper.updateById(status) > 0;
+                if (updated && changed) {
+                    publishEquipmentStatusChange(device, status, "pipe-network:createMaintainOrder");
+                }
             } else {
                 EquipmentStatus newStatus = new EquipmentStatus();
                 newStatus.setEquipmentId(vo.getEquipmentId());
@@ -299,7 +307,9 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
                 newStatus.setOnlineStatus(1);
                 newStatus.setStatusUpdateTime(LocalDateTime.now());
                 newStatus.setCreateTime(LocalDateTime.now());
-                equipmentStatusMapper.insert(newStatus);
+                if (equipmentStatusMapper.insert(newStatus) > 0) {
+                    publishEquipmentStatusChange(device, newStatus, "pipe-network:createMaintainOrder");
+                }
             }
             log.info("设备[{}]派单成功,工单ID:{},维修人:{}",
                     vo.getEquipmentId(), maintain.getMaintainId(), vo.getMaintainPerson());
@@ -311,6 +321,15 @@ public class WaterSupplyDeviceServiceImpl extends ServiceImpl<EquipmentBaseMappe
         return insert > 0;
     }
 
+    private void publishEquipmentStatusChange(EquipmentBase equipment, EquipmentStatus status, String source) {
+        if (equipment == null || status == null || StrUtil.isBlank(equipment.getEquipmentCode())) {
+            return;
+        }
+        deviceStatusChangePublisher.publishAfterCommit(equipment.getEquipmentCode(),
+                status.getEquipmentId(), status.getCurrentStatus(), status.getAlarmStatus(),
+                status.getOnlineStatus(), source);
+    }
+
     private void sendMaintainSmsNotify(EquipmentBase device, EquipmentMaintain maintain) {
         try {
             if (smsTemplate == null) {

+ 1 - 1
pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/PipeNetworkStatistics/PipeNetworkStatisticsMapper.xml

@@ -20,7 +20,7 @@
         FROM app_user.water_pipe_info WHERE del_flag = '0'
         <if test="status != null and status != ''">AND status = #{status}</if>
         UNION ALL
-        SELECT 'user', '用水户', COUNT(*), COALESCE(SUM(CASE WHEN status = '0' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN status IS NOT NULL AND status &lt;&gt; '0' THEN 1 ELSE 0 END), 0)
+        SELECT 'user', '用水户', COUNT(*), COALESCE(SUM(CASE WHEN status = '0' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN status IS NOT NULL AND status &lt;&gt; '0' THEN 1 ELSE 0 END), 0)
         FROM app_user.water_user_info WHERE del_flag = '0'
         <if test="status != null and status != ''">AND status = #{status}</if>
     </select>

+ 8 - 8
pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/WaterPipeInfo/WaterPipeInfoMapper.xml

@@ -35,7 +35,7 @@
 
     <select id="selectPipeList" resultMap="BaseResultMap">
         SELECT <include refid="Base_Column_List"/>
-        FROM water_pipe_info
+        FROM app_user.water_pipe_info
         WHERE del_flag = '0'
         <if test="pipeCode != null and pipeCode != ''">
             AND pipe_code LIKE CONCAT('%', #{pipeCode}, '%')
@@ -62,7 +62,7 @@
         SELECT
         status,
         COUNT(*) AS pipeCount
-        FROM water_pipe_info
+        FROM app_user.water_pipe_info
         WHERE del_flag = '0'
         GROUP BY status
     </select>
@@ -72,14 +72,14 @@
         pipe_material AS pipeMaterial,
         COUNT(*) AS pipeCount,
         SUM(pipe_length) AS totalLength
-        FROM water_pipe_info
+        FROM app_user.water_pipe_info
         WHERE del_flag = '0'
         GROUP BY pipe_material
     </select>
 
     <select id="getLatestPipe" resultMap="BaseResultMap">
         SELECT <include refid="Base_Column_List"/>
-        FROM water_pipe_info
+        FROM app_user.water_pipe_info
         WHERE del_flag = '0'
         ORDER BY create_time DESC
         LIMIT 1
@@ -91,13 +91,13 @@
         SUM(pipe_length) AS totalLength,
         SUM(CASE WHEN status = '0' THEN 1 ELSE 0 END) AS runningCount,
         SUM(CASE WHEN status = '1' THEN 1 ELSE 0 END) AS stopCount
-        FROM water_pipe_info
+        FROM app_user.water_pipe_info
         WHERE del_flag = '0'
     </select>
 
     <select id="selectDeletedList" resultMap="BaseResultMap">
         SELECT <include refid="Base_Column_List"/>
-        FROM water_pipe_info
+        FROM app_user.water_pipe_info
         WHERE del_flag = '2'
         <if test="pipeCode != null and pipeCode != ''">
             AND pipe_code LIKE CONCAT('%', #{pipeCode}, '%')
@@ -118,14 +118,14 @@
     </select>
 
     <delete id="deleteByIdsPhysically">
-        DELETE FROM water_pipe_info WHERE id IN
+        DELETE FROM app_user.water_pipe_info WHERE id IN
         <foreach collection="ids" item="id" open="(" separator="," close=")">
             #{id}
         </foreach>
     </delete>
 
     <update id="restoreByIds">
-        UPDATE water_pipe_info
+        UPDATE app_user.water_pipe_info
         SET del_flag = '0',
         update_by = #{updateBy},
         update_time = CURRENT_TIMESTAMP

+ 4 - 4
pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/WaterPlantInfo/WaterPlantInfoMapper.xml

@@ -36,7 +36,7 @@
     <!-- 条件查询水厂列表 -->
     <select id="selectPlantList" resultMap="BaseResultMap">
         SELECT <include refid="Base_Column_List"/>
-        FROM water_plant_info
+        FROM app_user.water_plant_info
         WHERE del_flag = '0'
         <if test="plantCode != null and plantCode != ''">
             AND plant_code LIKE CONCAT('%', #{plantCode}, '%')
@@ -61,7 +61,7 @@
 
     <select id="selectDeletedList" resultMap="BaseResultMap">
         SELECT <include refid="Base_Column_List"/>
-        FROM water_plant_info
+        FROM app_user.water_plant_info
         WHERE del_flag = '2'
         <if test="plantCode != null and plantCode != ''">
             AND plant_code LIKE CONCAT('%', #{plantCode}, '%')
@@ -82,7 +82,7 @@
     </select>
 
     <update id="restoreByIds">
-        UPDATE water_plant_info
+        UPDATE app_user.water_plant_info
         SET del_flag = '0',
         update_by = #{updateBy},
         update_time = CURRENT_TIMESTAMP
@@ -93,7 +93,7 @@
     </update>
 
     <delete id="deleteByIdsPhysically">
-        DELETE FROM water_plant_info WHERE id IN
+        DELETE FROM app_user.water_plant_info WHERE id IN
         <foreach collection="ids" item="id" open="(" separator="," close=")">
             #{id}
         </foreach>

+ 4 - 4
pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/WaterPumpStation/WaterPumpStationMapper.xml

@@ -34,7 +34,7 @@
     <!-- 条件查询泵站列表 -->
     <select id="selectStationList" resultMap="BaseResultMap">
         SELECT <include refid="Base_Column_List"/>
-        FROM water_pump_station
+        FROM app_user.water_pump_station
         WHERE del_flag = '0'
         <if test="stationCode != null and stationCode != ''">
             AND station_code LIKE CONCAT('%', #{stationCode}, '%')
@@ -60,7 +60,7 @@
     <!-- 回收站SQL -->
     <select id="selectDeletedList" resultMap="BaseResultMap">
         SELECT <include refid="Base_Column_List"/>
-        FROM water_pump_station
+        FROM app_user.water_pump_station
         WHERE del_flag = '2'
         <if test="stationCode != null and stationCode != ''">
             AND station_code LIKE CONCAT('%', #{stationCode}, '%')
@@ -81,7 +81,7 @@
     </select>
 
     <update id="restoreByIds">
-        UPDATE water_pump_station
+        UPDATE app_user.water_pump_station
         SET del_flag = '0',
         update_by = #{updateBy},
         update_time = CURRENT_TIMESTAMP
@@ -92,7 +92,7 @@
     </update>
 
     <delete id="deleteByIdsPhysically">
-        DELETE FROM water_pump_station WHERE id IN
+        DELETE FROM app_user.water_pump_station WHERE id IN
         <foreach collection="ids" item="id" open="(" separator="," close=")">
             #{id}
         </foreach>

+ 4 - 4
pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/WaterSourceInfo/WaterSourceInfoMapper.xml

@@ -34,7 +34,7 @@
 
     <select id="selectSourceList" resultMap="BaseResultMap">
         SELECT <include refid="Base_Column_List"/>
-        FROM water_source_info
+        FROM app_user.water_source_info
         WHERE del_flag = '0'
         <if test="sourceId != null and sourceId != ''">
             AND source_id LIKE CONCAT('%', #{sourceId}, '%')
@@ -59,7 +59,7 @@
 
     <select id="selectDeletedList" resultMap="BaseResultMap">
         SELECT <include refid="Base_Column_List"/>
-        FROM water_source_info
+        FROM app_user.water_source_info
         WHERE del_flag = '2'
         <if test="sourceId != null and sourceId != ''">
             AND source_id LIKE CONCAT('%', #{sourceId}, '%')
@@ -80,7 +80,7 @@
     </select>
 
     <update id="restoreByIds">
-        UPDATE water_source_info
+        UPDATE app_user.water_source_info
         SET del_flag = '0',
         update_by = #{updateBy},
         update_time = CURRENT_TIMESTAMP
@@ -91,7 +91,7 @@
     </update>
 
     <delete id="deleteByIdsPhysically">
-        DELETE FROM water_source_info WHERE id IN
+        DELETE FROM app_user.water_source_info WHERE id IN
         <foreach collection="ids" item="id" open="(" separator="," close=")">
             #{id}
         </foreach>

+ 1 - 1
pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/WaterSupplyGisMap/WaterSupplyGisMapMapper.xml

@@ -39,7 +39,7 @@
         SELECT 'pipe', '供水管网', COUNT(*), COALESCE(SUM(CASE WHEN start_longitude IS NOT NULL AND start_latitude IS NOT NULL AND end_longitude IS NOT NULL AND end_latitude IS NOT NULL THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN start_longitude IS NULL OR start_latitude IS NULL OR end_longitude IS NULL OR end_latitude IS NULL THEN 1 ELSE 0 END), 0)
         FROM app_user.water_pipe_info WHERE del_flag = '0'
         UNION ALL
-        SELECT 'user', '用水户', COUNT(*), COALESCE(SUM(CASE WHEN longitude IS NOT NULL AND latitude IS NOT NULL THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN longitude IS NULL OR latitude IS NULL THEN 1 ELSE 0 END), 0)
+        SELECT 'user', '用水户', COUNT(*), COALESCE(SUM(CASE WHEN longitude IS NOT NULL AND latitude IS NOT NULL THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN longitude IS NULL OR latitude IS NULL THEN 1 ELSE 0 END), 0)
         FROM app_user.water_user_info WHERE del_flag = '0'
     </select>
 </mapper>

+ 4 - 4
pipe-network-service/zksy-system/src/main/resources/mapper/WaterSupply/WaterUserInfo/WaterUserInfoMapper.xml

@@ -34,7 +34,7 @@
 
     <select id="selectUserList" resultMap="BaseResultMap">
         SELECT <include refid="Base_Column_List"/>
-        FROM water_user_info
+        FROM app_user.water_user_info
         WHERE del_flag = '0'
         <if test="userCode != null and userCode != ''">
             AND user_code LIKE CONCAT('%', #{userCode}, '%')
@@ -59,7 +59,7 @@
 
     <select id="selectDeletedList" resultMap="BaseResultMap">
         SELECT <include refid="Base_Column_List"/>
-        FROM water_user_info
+        FROM app_user.water_user_info
         WHERE del_flag = '2'
         <if test="userCode != null and userCode != ''">
             AND user_code LIKE CONCAT('%', #{userCode}, '%')
@@ -80,7 +80,7 @@
     </select>
 
     <update id="restoreByIds">
-        UPDATE water_user_info
+        UPDATE app_user.water_user_info
         SET del_flag = '0',
         update_by = #{updateBy},
         update_time = CURRENT_TIMESTAMP
@@ -91,7 +91,7 @@
     </update>
 
     <delete id="deleteByIdsPhysically">
-        DELETE FROM water_user_info WHERE id IN
+        DELETE FROM app_user.water_user_info WHERE id IN
         <foreach collection="ids" item="id" open="(" separator="," close=")">
             #{id}
         </foreach>

+ 15 - 0
pipe-network-service/zksy-system/src/main/resources/mapper/WorkOrderMapper.xml

@@ -43,4 +43,19 @@
         real_finish_time,repair_result,maintenance_before_img,maintenance_after_img,verify_user,
         verify_time,create_time,update_time
     </sql>
+
+    <select id="selectPendingOrOverdue" resultMap="BaseResultMap">
+        SELECT
+        <include refid="Base_Column_List"/>
+        FROM app_user.work_order
+        WHERE order_status IN (1, 2)
+           OR (
+                order_status IN (3, 4, 5, 8, 9)
+                AND COALESCE(delayed_deadline, plan_finish_time) &lt; #{now}
+           )
+        ORDER BY order_level ASC NULLS LAST,
+                 order_status ASC,
+                 create_time DESC NULLS LAST,
+                 order_id DESC
+    </select>
 </mapper>

+ 27 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/FacilityCoordinateModelTest.java

@@ -7,6 +7,8 @@ import org.junit.jupiter.api.Test;
 import java.io.InputStream;
 import java.math.BigDecimal;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.Map;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -114,4 +116,29 @@ class FacilityCoordinateModelTest {
                     "GIS SQL must not resolve water supply tables through search_path");
         }
     }
+
+    @Test
+    void kingbaseMigrationIsRepeatableAndOnlyAddsTheApprovedWgs84Coordinates() throws Exception {
+        Path workingDirectory = Path.of(System.getProperty("user.dir"));
+        Path migration = workingDirectory.resolve(
+                "pipe-network-service/sql/water_supply_facility_gis.sql");
+        if (!Files.exists(migration)) {
+            migration = workingDirectory.resolve("../sql/water_supply_facility_gis.sql").normalize();
+        }
+        assertTrue(Files.exists(migration), migration.toString());
+        String sql = Files.readString(migration, StandardCharsets.UTF_8);
+
+        assertEquals(12, sql.split("ADD COLUMN IF NOT EXISTS", -1).length - 1);
+        assertEquals(5, sql.split("CREATE INDEX IF NOT EXISTS", -1).length - 1);
+        assertTrue(sql.contains("ALTER TABLE app_user.water_source_info"));
+        assertTrue(sql.contains("ALTER TABLE app_user.water_plant_info"));
+        assertTrue(sql.contains("ALTER TABLE app_user.water_pump_station"));
+        assertTrue(sql.contains("ALTER TABLE app_user.water_pipe_info"));
+        assertTrue(sql.contains("ALTER TABLE app_user.water_user_info"));
+        assertTrue(sql.contains("longitude numeric(11,8)"));
+        assertTrue(sql.contains("latitude numeric(10,8)"));
+        assertFalse(sql.toLowerCase().contains("hydrant"));
+        assertFalse(sql.contains("消火栓"));
+        assertFalse(sql.toUpperCase().contains("CREATE TABLE"));
+    }
 }

+ 138 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/WaterFacilityQueryServiceTest.java

@@ -0,0 +1,138 @@
+package com.zksy.WaterSupply;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.WaterSupply.WaterPipeInfo.domain.WaterPipeInfo;
+import com.zksy.WaterSupply.WaterPipeInfo.mapper.WaterPipeInfoMapper;
+import com.zksy.WaterSupply.WaterPipeInfo.service.impl.WaterPipeInfoServiceImpl;
+import com.zksy.WaterSupply.WaterPlantInfo.domain.WaterPlantInfo;
+import com.zksy.WaterSupply.WaterPlantInfo.mapper.WaterPlantInfoMapper;
+import com.zksy.WaterSupply.WaterPlantInfo.service.impl.WaterPlantInfoServiceImpl;
+import com.zksy.WaterSupply.WaterPumpStation.domain.WaterPumpStation;
+import com.zksy.WaterSupply.WaterPumpStation.mapper.WaterPumpStationMapper;
+import com.zksy.WaterSupply.WaterPumpStation.service.impl.WaterPumpStationServiceImpl;
+import com.zksy.WaterSupply.WaterSourceInfo.domain.WaterSourceInfo;
+import com.zksy.WaterSupply.WaterSourceInfo.mapper.WaterSourceInfoMapper;
+import com.zksy.WaterSupply.WaterSourceInfo.service.impl.WaterSourceInfoServiceImpl;
+import com.zksy.WaterSupply.WaterUserInfo.domain.WaterUserInfo;
+import com.zksy.WaterSupply.WaterUserInfo.mapper.WaterUserInfoMapper;
+import com.zksy.WaterSupply.WaterUserInfo.service.impl.WaterUserInfoServiceImpl;
+import com.zksy.WaterSupply.export.WaterPipeExportDTO;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.lang.reflect.Field;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class WaterFacilityQueryServiceTest {
+
+    @Test
+    void pipeQueryUsesDatabasePaginationAndFilters() {
+        WaterPipeInfoMapper mapper = mock(WaterPipeInfoMapper.class);
+        Page<WaterPipeInfo> expected = pageOf(new WaterPipeInfo());
+        when(mapper.selectPage(ArgumentMatchers.any(), ArgumentMatchers.any())).thenReturn(expected);
+        WaterPipeInfoServiceImpl service = new WaterPipeInfoServiceImpl();
+        ReflectionTestUtils.setField(service, "baseMapper", mapper);
+
+        IPage<WaterPipeInfo> actual = service.queryPage(new Page<>(2, 20), "P-1", "主干", "PE", "0", 300, 2020, null, null);
+
+        assertEquals(expected, actual);
+        verify(mapper).selectPage(ArgumentMatchers.any(), ArgumentMatchers.any());
+    }
+
+    @Test
+    void plantQueryUsesDatabasePagination() {
+        WaterPlantInfoMapper mapper = mock(WaterPlantInfoMapper.class);
+        Page<WaterPlantInfo> expected = pageOf(new WaterPlantInfo());
+        when(mapper.selectPage(ArgumentMatchers.any(), ArgumentMatchers.any())).thenReturn(expected);
+        WaterPlantInfoServiceImpl service = new WaterPlantInfoServiceImpl();
+        ReflectionTestUtils.setField(service, "baseMapper", mapper);
+
+        assertEquals(expected, service.queryPage(new Page<>(3, 5), "P", "厂", "0", "负责人", null, null));
+        verify(mapper).selectPage(ArgumentMatchers.any(), ArgumentMatchers.any());
+    }
+
+    @Test
+    void pumpStationQueryUsesDatabasePagination() {
+        WaterPumpStationMapper mapper = mock(WaterPumpStationMapper.class);
+        Page<WaterPumpStation> expected = pageOf(new WaterPumpStation());
+        when(mapper.selectPage(ArgumentMatchers.any(), ArgumentMatchers.any())).thenReturn(expected);
+        WaterPumpStationServiceImpl service = new WaterPumpStationServiceImpl();
+        ReflectionTestUtils.setField(service, "baseMapper", mapper);
+
+        assertEquals(expected, service.queryPage(new Page<>(1, 10), "S", "泵站", "0", "负责人", null, null));
+        verify(mapper).selectPage(ArgumentMatchers.any(), ArgumentMatchers.any());
+    }
+
+    @Test
+    void sourceQueryUsesDatabasePagination() {
+        WaterSourceInfoMapper mapper = mock(WaterSourceInfoMapper.class);
+        Page<WaterSourceInfo> expected = pageOf(new WaterSourceInfo());
+        when(mapper.selectPage(ArgumentMatchers.any(), ArgumentMatchers.any())).thenReturn(expected);
+        WaterSourceInfoServiceImpl service = new WaterSourceInfoServiceImpl();
+        ReflectionTestUtils.setField(service, "baseMapper", mapper);
+
+        assertEquals(expected, service.queryPage(new Page<>(1, 10), "W", "水源", "0", "单位", null, null));
+        verify(mapper).selectPage(ArgumentMatchers.any(), ArgumentMatchers.any());
+    }
+
+    @Test
+    void waterUserQueryUsesDatabasePagination() {
+        WaterUserInfoMapper mapper = mock(WaterUserInfoMapper.class);
+        Page<WaterUserInfo> expected = pageOf(new WaterUserInfo());
+        when(mapper.selectPage(ArgumentMatchers.any(), ArgumentMatchers.any())).thenReturn(expected);
+        WaterUserInfoServiceImpl service = new WaterUserInfoServiceImpl();
+        ReflectionTestUtils.setField(service, "baseMapper", mapper);
+
+        assertEquals(expected, service.queryPage(new Page<>(4, 10), "U", "居民", "0", "居民", null, null));
+        verify(mapper).selectPage(ArgumentMatchers.any(), ArgumentMatchers.any());
+    }
+
+    @Test
+    void facilityMapperQueriesUseKingbaseAppUserSchema() throws Exception {
+        String[] mapperPaths = {
+                "mapper/WaterSupply/WaterPipeInfo/WaterPipeInfoMapper.xml",
+                "mapper/WaterSupply/WaterPlantInfo/WaterPlantInfoMapper.xml",
+                "mapper/WaterSupply/WaterPumpStation/WaterPumpStationMapper.xml",
+                "mapper/WaterSupply/WaterSourceInfo/WaterSourceInfoMapper.xml",
+                "mapper/WaterSupply/WaterUserInfo/WaterUserInfoMapper.xml"
+        };
+        for (String path : mapperPaths) {
+            try (InputStream input = getClass().getClassLoader().getResourceAsStream(path)) {
+                String xml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+                org.junit.jupiter.api.Assertions.assertFalse(
+                        xml.matches("(?s).*\\b(?:FROM|UPDATE|DELETE FROM)\\s+water_(?:pipe_info|plant_info|pump_station|source_info|user_info)\\b.*"),
+                        path + " must qualify facilities with app_user schema");
+            }
+        }
+    }
+
+    @Test
+    void pipeExportIncludesLayingYear() {
+        WaterPipeInfo pipe = new WaterPipeInfo();
+        pipe.setLayingYear(1998);
+
+        assertEquals(1998, WaterPipeExportDTO.from(pipe).getLayingYear());
+        try {
+            Field field = WaterPipeExportDTO.class.getDeclaredField("layingYear");
+            assertEquals("铺设年份", field.getAnnotation(com.zksy.common.annotation.Excel.class).name());
+        } catch (ReflectiveOperationException e) {
+            throw new AssertionError("layingYear must be an exported Excel field", e);
+        }
+    }
+
+    private static <T> Page<T> pageOf(T record) {
+        Page<T> page = new Page<>(1, 1);
+        page.setRecords(List.of(record));
+        page.setTotal(1);
+        return page;
+    }
+}

+ 143 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/WaterSupplyGisMap/WaterSupplyGisMapServiceTest.java

@@ -0,0 +1,143 @@
+package com.zksy.WaterSupply.WaterSupplyGisMap;
+
+import com.zksy.WaterSupply.WaterPlantInfo.domain.WaterPlantInfo;
+import com.zksy.WaterSupply.WaterPlantInfo.service.IWaterPlantInfoService;
+import com.zksy.WaterSupply.WaterSupplyGisMap.dto.GisOverviewItemDTO;
+import com.zksy.WaterSupply.WaterSupplyGisMap.dto.GisFeatureCollectionDTO;
+import com.zksy.WaterSupply.WaterSupplyGisMap.mapper.WaterSupplyGisMapMapper;
+import com.zksy.WaterSupply.WaterSupplyGisMap.service.impl.WaterSupplyGisMapServiceImpl;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class WaterSupplyGisMapServiceTest {
+
+    @Mock
+    private WaterSupplyGisMapMapper mapper;
+
+    @Mock
+    private IWaterPlantInfoService plantService;
+
+    @InjectMocks
+    private WaterSupplyGisMapServiceImpl service;
+
+    @Test
+    void overviewStillDescribesAllFacilityTypesWhenNoCoordinatesHaveBeenLoaded() {
+        when(mapper.selectOverview()).thenReturn(Collections.emptyList());
+
+        List<GisOverviewItemDTO> overview = service.getOverview();
+
+        assertEquals(List.of("source", "plant", "pumpStation", "pipe", "user", "total"),
+                overview.stream().map(GisOverviewItemDTO::getFacilityType).collect(Collectors.toList()));
+        overview.forEach(item -> {
+            assertEquals(0L, item.getTotalCount());
+            assertEquals(0L, item.getGeoCompleteCount());
+            assertEquals(0L, item.getGeoMissingCount());
+        });
+    }
+
+    @Test
+    void pointLayerOmitsRowsWithoutBothCoordinatesAndKeepsLongitudeFirst() {
+        Map<String, Object> complete = pointRow(1L, "完整水厂");
+        Map<String, Object> missingLatitude = pointRow(2L, "缺纬度水厂");
+        missingLatitude.put("latitude", null);
+        when(mapper.selectPlantFeatures()).thenReturn(List.of(complete, missingLatitude));
+
+        GisFeatureCollectionDTO features = service.getFeatures(Set.of("plant"));
+
+        assertEquals(1, features.getFeatures().size());
+        assertEquals("Point", features.getFeatures().get(0).getGeometry().get("type"));
+        assertEquals(List.of(new BigDecimal("113.12345678"), new BigDecimal("23.12345678")),
+                features.getFeatures().get(0).getGeometry().get("coordinates"));
+    }
+
+    @Test
+    void pipeLayerOmitsRowsWithoutFourCoordinatesAndKeepsEndpointOrder() {
+        Map<String, Object> complete = pipeRow(1L, "完整管线");
+        Map<String, Object> missingEndpoint = pipeRow(2L, "缺终点纬度管线");
+        missingEndpoint.put("end_latitude", null);
+        when(mapper.selectPipeFeatures()).thenReturn(List.of(complete, missingEndpoint));
+
+        GisFeatureCollectionDTO features = service.getFeatures(Set.of("pipe"));
+
+        assertEquals(1, features.getFeatures().size());
+        assertEquals("LineString", features.getFeatures().get(0).getGeometry().get("type"));
+        assertEquals(List.of(
+                        List.of(new BigDecimal("113.10000000"), new BigDecimal("23.10000000")),
+                        List.of(new BigDecimal("113.20000000"), new BigDecimal("23.20000000"))),
+                features.getFeatures().get(0).getGeometry().get("coordinates"));
+    }
+
+    @Test
+    void requestedFacilityTypesDoNotLoadUnselectedLayers() {
+        when(mapper.selectPlantFeatures()).thenReturn(Collections.emptyList());
+
+        service.getFeatures(Set.of("plant"));
+
+        verify(mapper).selectPlantFeatures();
+        verify(mapper, never()).selectSourceFeatures();
+        verify(mapper, never()).selectPumpStationFeatures();
+        verify(mapper, never()).selectPipeFeatures();
+        verify(mapper, never()).selectUserFeatures();
+    }
+
+    @Test
+    void detailReturnsTheExistingFacilityEvenWhenItHasNoCoordinates() {
+        WaterPlantInfo plant = new WaterPlantInfo();
+        plant.setId(9L);
+        plant.setPlantName("待补坐标水厂");
+        when(plantService.getById(9L)).thenReturn(plant);
+
+        Object detail = service.getDetail("plant", 9L);
+
+        assertSame(plant, detail);
+    }
+
+    @Test
+    void unsupportedFacilityTypeIsRejected() {
+        IllegalArgumentException error = assertThrows(IllegalArgumentException.class,
+                () -> service.getDetail("hydrant", 1L));
+
+        assertEquals("不支持的设施类型:hydrant", error.getMessage());
+        verify(plantService, never()).getById(1L);
+    }
+
+    private Map<String, Object> pointRow(Long id, String name) {
+        Map<String, Object> row = new HashMap<>();
+        row.put("id", id);
+        row.put("code", "WP" + id);
+        row.put("name", name);
+        row.put("status", "0");
+        row.put("location", "测试地址");
+        row.put("longitude", new BigDecimal("113.12345678"));
+        row.put("latitude", new BigDecimal("23.12345678"));
+        return row;
+    }
+
+    private Map<String, Object> pipeRow(Long id, String name) {
+        Map<String, Object> row = pointRow(id, name);
+        row.put("start_longitude", new BigDecimal("113.10000000"));
+        row.put("start_latitude", new BigDecimal("23.10000000"));
+        row.put("end_longitude", new BigDecimal("113.20000000"));
+        row.put("end_latitude", new BigDecimal("23.20000000"));
+        return row;
+    }
+}

+ 133 - 0
pipe-network-service/zksy-system/src/test/java/com/zksy/WaterSupply/WaterUserTerminologyTest.java

@@ -0,0 +1,133 @@
+package com.zksy.WaterSupply;
+
+import com.zksy.WaterSupply.PipeNetworkStatistics.dto.PipeNetworkStatisticsDashboardDTO;
+import com.zksy.WaterSupply.PipeNetworkStatistics.mapper.PipeNetworkStatisticsMapper;
+import com.zksy.WaterSupply.PipeNetworkStatistics.service.impl.PipeNetworkStatisticsExportServiceImpl;
+import com.zksy.WaterSupply.WaterSupplyGisMap.dto.GisFeatureCollectionDTO;
+import com.zksy.WaterSupply.WaterSupplyGisMap.mapper.WaterSupplyGisMapMapper;
+import com.zksy.WaterSupply.WaterSupplyGisMap.service.impl.WaterSupplyGisMapServiceImpl;
+import org.apache.ibatis.builder.xml.XMLMapperBuilder;
+import org.apache.ibatis.io.Resources;
+import org.apache.ibatis.mapping.MappedStatement;
+import org.apache.ibatis.session.Configuration;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class WaterUserTerminologyTest {
+
+    @Mock
+    private WaterSupplyGisMapMapper gisMapper;
+
+    @InjectMocks
+    private WaterSupplyGisMapServiceImpl gisService;
+
+    @Test
+    void gisUserLayerReturnsEveryWaterUserCategoryWithUnifiedName() {
+        when(gisMapper.selectUserFeatures()).thenReturn(List.of(
+                userRow(1L, "RESIDENT", "居民用水户"),
+                userRow(2L, "INDUSTRIAL", "工业用水户"),
+                userRow(3L, "COMMERCIAL", "商业用水户"),
+                userRow(4L, "PUBLIC", "行政事业用水户")
+        ));
+
+        GisFeatureCollectionDTO result = gisService.getFeatures(Set.of("user"));
+
+        assertEquals(4, result.getFeatures().size());
+        assertEquals(Set.of("RESIDENT", "INDUSTRIAL", "COMMERCIAL", "PUBLIC"),
+                result.getFeatures().stream()
+                        .map(feature -> (String) feature.getProperties().get("code"))
+                        .collect(Collectors.toSet()));
+        result.getFeatures().forEach(feature -> {
+            assertEquals("user", feature.getProperties().get("facilityType"));
+            assertEquals("用水户", feature.getProperties().get("facilityTypeName"));
+        });
+    }
+
+    @Test
+    void mapperOverviewKeepsUserCodeAndLabelsAllWaterUsersWithoutCategoryFilter() throws Exception {
+        String gisOverviewSql = mappedSql(
+                "mapper/WaterSupply/WaterSupplyGisMap/WaterSupplyGisMapMapper.xml",
+                WaterSupplyGisMapMapper.class,
+                "selectOverview",
+                null
+        );
+        String gisUsersSql = mappedSql(
+                "mapper/WaterSupply/WaterSupplyGisMap/WaterSupplyGisMapMapper.xml",
+                WaterSupplyGisMapMapper.class,
+                "selectUserFeatures",
+                null
+        );
+        String statisticsOverviewSql = mappedSql(
+                "mapper/WaterSupply/PipeNetworkStatistics/PipeNetworkStatisticsMapper.xml",
+                PipeNetworkStatisticsMapper.class,
+                "selectFacilityOverview",
+                Collections.singletonMap("status", null)
+        );
+
+        assertTrue(gisOverviewSql.contains("SELECT 'user', '用水户'"));
+        assertTrue(statisticsOverviewSql.contains("SELECT 'user', '用水户'"));
+        assertFalse(gisOverviewSql.contains("大用水户"));
+        assertFalse(statisticsOverviewSql.contains("大用水户"));
+        assertTrue(gisUsersSql.contains("FROM app_user.water_user_info"));
+        assertFalse(gisUsersSql.matches("(?is).*\\buser_type\\s*=.*"),
+                "the user GIS layer must not restrict resident, industrial, commercial, or public-service users");
+        assertFalse(statisticsOverviewSql.matches("(?is).*\\buser_type\\s*=.*"),
+                "facility statistics must include every water user category");
+    }
+
+    @Test
+    void statisticsExportCreatesUnifiedWaterUserWorksheet() throws Exception {
+        ByteArrayOutputStream output = new ByteArrayOutputStream();
+        new PipeNetworkStatisticsExportServiceImpl().write(output, new PipeNetworkStatisticsDashboardDTO());
+
+        try (Workbook workbook = new XSSFWorkbook(new ByteArrayInputStream(output.toByteArray()))) {
+            assertNotNull(workbook.getSheet("用水户统计"));
+            assertNull(workbook.getSheet("大用水户统计"));
+        }
+    }
+
+    private Map<String, Object> userRow(Long id, String code, String name) {
+        return Map.of(
+                "id", id,
+                "code", code,
+                "name", name,
+                "status", "0",
+                "location", "测试地址",
+                "longitude", new BigDecimal("113.12345678"),
+                "latitude", new BigDecimal("23.12345678")
+        );
+    }
+
+    private String mappedSql(String resource, Class<?> mapperType, String statementId, Object parameter) throws Exception {
+        Configuration configuration = new Configuration();
+        configuration.addMapper(mapperType);
+        try (InputStream input = Resources.getResourceAsStream(resource)) {
+            new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
+        }
+        MappedStatement statement = configuration.getMappedStatement(mapperType.getName() + "." + statementId);
+        return statement.getBoundSql(parameter).getSql().replaceAll("\\s+", " ").trim();
+    }
+}

+ 1 - 1
pom.xml

@@ -85,7 +85,7 @@
             <dependency>
                 <groupId>com.baomidou</groupId>
                 <artifactId>mybatis-plus-boot-starter</artifactId>
-                <version>3.5.5</version>
+                <version>${mybatis-plus.version}</version>
             </dependency>
             <!--hutool工具包-->
             <dependency>

+ 42 - 12
radar-service/src/main/java/com/zksy/radar/utils/DeviceOfflineCheckTask.java

@@ -1,36 +1,66 @@
-package com.zksy.radar.utils;/*
-package com.zksy.gasTransmitter.utils;
+package com.zksy.radar.utils;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
 
 import java.util.Date;
-import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
 @Component
 public class DeviceOfflineCheckTask {
     private static final Logger logger = LoggerFactory.getLogger(DeviceOfflineCheckTask.class);
 
-    // 用于存储设备编号及其最后一次接收数据的时间
     public static ConcurrentHashMap<String, Date> deviceLastReceiveTimeMap = new ConcurrentHashMap<>();
+
+    private final Set<String> offlineDeviceSet = ConcurrentHashMap.newKeySet();
+
     @Autowired
-    private BaseDevicesService baseDevicesService;
+    private RestTemplate restTemplate;
+
+    @Value("${device.offline.timeout-minutes:30}")
+    private int offlineTimeoutMinutes;
 
-    // 定时检查设备是否离线
-    @Scheduled(fixedRate = 24 * 60 * 60 * 1000) // 每24小时执行一次
+    @Scheduled(fixedRateString = "${device.offline.check-interval-ms:300000}")
     public void checkDeviceOffline() {
         Date now = new Date();
+        long timeoutMs = (long) offlineTimeoutMinutes * 60 * 1000;
         for (Map.Entry<String, Date> entry : deviceLastReceiveTimeMap.entrySet()) {
             long diff = now.getTime() - entry.getValue().getTime();
-            // 如果设备在 23 小时内没有接收数据,则认为设备离线
-            if (diff > 23 * 60 * 60 * 1000) {
-                baseDevicesService.getByDeviceNumberStatus(entry.getKey(), 1, 0);
-                logger.info("设备 {} 已离线", entry.getKey());
+            if (diff > timeoutMs) {
+                if (offlineDeviceSet.add(entry.getKey())) {
+                    updateDeviceOnlineStatus(entry.getKey(), 0);
+                    logger.info("设备 {} 已离线(超过{}分钟未收到数据)", entry.getKey(), offlineTimeoutMinutes);
+                }
             }
         }
     }
-}*/
+
+    public void markDeviceOnline(String deviceCode) {
+        if (offlineDeviceSet.remove(deviceCode)) {
+            updateDeviceOnlineStatus(deviceCode, 1);
+            logger.info("设备 {} 恢复在线", deviceCode);
+        }
+    }
+
+    private void updateDeviceOnlineStatus(String deviceCode, int onlineStatus) {
+        try {
+            Map<String, Object> params = Map.of("deviceCode", deviceCode, "onlineStatus", onlineStatus);
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<Map<String, Object>> request = new HttpEntity<>(params, headers);
+            restTemplate.postForObject("http://zk-api-service/equipmentStatus/updateOnlineStatus", request, Map.class);
+        } catch (Exception e) {
+            logger.error("更新设备在线状态失败: deviceCode={}, onlineStatus={}", deviceCode, onlineStatus, e);
+        }
+    }
+}

+ 6 - 0
radar-service/src/main/java/com/zksy/radar/utils/MessageHandler.java

@@ -47,6 +47,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 	private final DevicePhoneFetchUtil devicePhoneFetchUtil;
     private final RestTemplate restTemplate;
 
+    @Autowired
+    private DeviceOfflineCheckTask deviceOfflineCheckTask;
+
     @Value("${device.status.api.url:http://zk-api-service/equipmentStatus/updateAlarmStatus}")
     private String deviceStatusApiUrl;
 
@@ -108,6 +111,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 				}
 				// 入库
 				service.save(resultData);
+				// 更新设备最后接收时间,并标记设备在线
+				DeviceOfflineCheckTask.deviceLastReceiveTimeMap.put(resultData.getSystemIdentifier(), new java.util.Date());
+				deviceOfflineCheckTask.markDeviceOnline(resultData.getSystemIdentifier());
 				logger.info("上报历史记录数据入库成功: {}", resultData);
 				
 				// 瞬时流量和流速告警入库

+ 2 - 0
telemetry-service/src/main/java/com/zksy/telemetry/TelemetryApplication.java

@@ -4,7 +4,9 @@ package com.zksy.telemetry;
 import org.mybatis.spring.annotation.MapperScan;
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
 
+@EnableScheduling
 @MapperScan({
         "com.zksy.telemetry.mapper",
         "com.zksy.base.mapper",

+ 16 - 0
telemetry-service/src/main/java/com/zksy/telemetry/config/RestTemplateConfig.java

@@ -0,0 +1,16 @@
+package com.zksy.telemetry.config;
+
+import org.springframework.cloud.client.loadbalancer.LoadBalanced;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestTemplate;
+
+@Configuration
+public class RestTemplateConfig {
+
+    @Bean
+    @LoadBalanced
+    public RestTemplate restTemplate() {
+        return new RestTemplate();
+    }
+}

+ 42 - 12
telemetry-service/src/main/java/com/zksy/telemetry/utils/DeviceOfflineCheckTask.java

@@ -1,36 +1,66 @@
-package com.zksy.telemetry.utils;/*
-package com.zksy.gasTransmitter.utils;
+package com.zksy.telemetry.utils;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
 
 import java.util.Date;
-import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
 @Component
 public class DeviceOfflineCheckTask {
     private static final Logger logger = LoggerFactory.getLogger(DeviceOfflineCheckTask.class);
 
-    // 用于存储设备编号及其最后一次接收数据的时间
     public static ConcurrentHashMap<String, Date> deviceLastReceiveTimeMap = new ConcurrentHashMap<>();
+
+    private final Set<String> offlineDeviceSet = ConcurrentHashMap.newKeySet();
+
     @Autowired
-    private BaseDevicesService baseDevicesService;
+    private RestTemplate restTemplate;
+
+    @Value("${device.offline.timeout-minutes:30}")
+    private int offlineTimeoutMinutes;
 
-    // 定时检查设备是否离线
-    @Scheduled(fixedRate = 24 * 60 * 60 * 1000) // 每24小时执行一次
+    @Scheduled(fixedRateString = "${device.offline.check-interval-ms:300000}")
     public void checkDeviceOffline() {
         Date now = new Date();
+        long timeoutMs = (long) offlineTimeoutMinutes * 60 * 1000;
         for (Map.Entry<String, Date> entry : deviceLastReceiveTimeMap.entrySet()) {
             long diff = now.getTime() - entry.getValue().getTime();
-            // 如果设备在 23 小时内没有接收数据,则认为设备离线
-            if (diff > 23 * 60 * 60 * 1000) {
-                baseDevicesService.getByDeviceNumberStatus(entry.getKey(), 1, 0);
-                logger.info("设备 {} 已离线", entry.getKey());
+            if (diff > timeoutMs) {
+                if (offlineDeviceSet.add(entry.getKey())) {
+                    updateDeviceOnlineStatus(entry.getKey(), 0);
+                    logger.info("设备 {} 已离线(超过{}分钟未收到数据)", entry.getKey(), offlineTimeoutMinutes);
+                }
             }
         }
     }
-}*/
+
+    public void markDeviceOnline(String deviceCode) {
+        if (offlineDeviceSet.remove(deviceCode)) {
+            updateDeviceOnlineStatus(deviceCode, 1);
+            logger.info("设备 {} 恢复在线", deviceCode);
+        }
+    }
+
+    private void updateDeviceOnlineStatus(String deviceCode, int onlineStatus) {
+        try {
+            Map<String, Object> params = Map.of("deviceCode", deviceCode, "onlineStatus", onlineStatus);
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<Map<String, Object>> request = new HttpEntity<>(params, headers);
+            restTemplate.postForObject("http://zk-api-service/equipmentStatus/updateOnlineStatus", request, Map.class);
+        } catch (Exception e) {
+            logger.error("更新设备在线状态失败: deviceCode={}, onlineStatus={}", deviceCode, onlineStatus, e);
+        }
+    }
+}

+ 7 - 0
telemetry-service/src/main/java/com/zksy/telemetry/utils/MessageHandler.java

@@ -16,6 +16,8 @@ import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
 
+import java.util.Date;
+
 @ChannelHandler.Sharable
 @Slf4j
 @Component
@@ -23,6 +25,8 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 	private static Logger logger = LoggerFactory.getLogger(MessageHandler.class);
 	private final TelemetryDataService service;
 	@Autowired
+	private DeviceOfflineCheckTask deviceOfflineCheckTask;
+	@Autowired
 	public MessageHandler(TelemetryDataService telemetryDataService) {
 		this.service = telemetryDataService;
 	}
@@ -71,6 +75,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 				}
 				// 入库
 				service.save(resultData);
+				// 更新设备最后接收时间,并标记设备在线
+				DeviceOfflineCheckTask.deviceLastReceiveTimeMap.put(resultData.getSystemIdentifier(), new Date());
+				deviceOfflineCheckTask.markDeviceOnline(resultData.getSystemIdentifier());
 				logger.info("上报历史记录数据入库成功: {}", resultData);
 
 				// 如果结束位=1,可以根据需要在此处理关闭逻辑

+ 42 - 12
water-level-service/src/main/java/com/zksy/water/utils/DeviceOfflineCheckTask.java

@@ -1,36 +1,66 @@
-package com.zksy.radar.utils;/*
-package com.zksy.gasTransmitter.utils;
+package com.zksy.water.utils;
 
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
+import org.springframework.web.client.RestTemplate;
 
 import java.util.Date;
-import java.util.HashMap;
 import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
 
 @Component
 public class DeviceOfflineCheckTask {
     private static final Logger logger = LoggerFactory.getLogger(DeviceOfflineCheckTask.class);
 
-    // 用于存储设备编号及其最后一次接收数据的时间
     public static ConcurrentHashMap<String, Date> deviceLastReceiveTimeMap = new ConcurrentHashMap<>();
+
+    private final Set<String> offlineDeviceSet = ConcurrentHashMap.newKeySet();
+
     @Autowired
-    private BaseDevicesService baseDevicesService;
+    private RestTemplate restTemplate;
+
+    @Value("${device.offline.timeout-minutes:30}")
+    private int offlineTimeoutMinutes;
 
-    // 定时检查设备是否离线
-    @Scheduled(fixedRate = 24 * 60 * 60 * 1000) // 每24小时执行一次
+    @Scheduled(fixedRateString = "${device.offline.check-interval-ms:300000}")
     public void checkDeviceOffline() {
         Date now = new Date();
+        long timeoutMs = (long) offlineTimeoutMinutes * 60 * 1000;
         for (Map.Entry<String, Date> entry : deviceLastReceiveTimeMap.entrySet()) {
             long diff = now.getTime() - entry.getValue().getTime();
-            // 如果设备在 23 小时内没有接收数据,则认为设备离线
-            if (diff > 23 * 60 * 60 * 1000) {
-                baseDevicesService.getByDeviceNumberStatus(entry.getKey(), 1, 0);
-                logger.info("设备 {} 已离线", entry.getKey());
+            if (diff > timeoutMs) {
+                if (offlineDeviceSet.add(entry.getKey())) {
+                    updateDeviceOnlineStatus(entry.getKey(), 0);
+                    logger.info("设备 {} 已离线(超过{}分钟未收到数据)", entry.getKey(), offlineTimeoutMinutes);
+                }
             }
         }
     }
-}*/
+
+    public void markDeviceOnline(String deviceCode) {
+        if (offlineDeviceSet.remove(deviceCode)) {
+            updateDeviceOnlineStatus(deviceCode, 1);
+            logger.info("设备 {} 恢复在线", deviceCode);
+        }
+    }
+
+    private void updateDeviceOnlineStatus(String deviceCode, int onlineStatus) {
+        try {
+            Map<String, Object> params = Map.of("deviceCode", deviceCode, "onlineStatus", onlineStatus);
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<Map<String, Object>> request = new HttpEntity<>(params, headers);
+            restTemplate.postForObject("http://zk-api-service/equipmentStatus/updateOnlineStatus", request, Map.class);
+        } catch (Exception e) {
+            logger.error("更新设备在线状态失败: deviceCode={}, onlineStatus={}", deviceCode, onlineStatus, e);
+        }
+    }
+}

+ 6 - 0
water-level-service/src/main/java/com/zksy/water/utils/MessageHandler.java

@@ -47,6 +47,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 	private final DevicePhoneFetchUtil devicePhoneFetchUtil;
     private final RestTemplate restTemplate;
 
+    @Autowired
+    private DeviceOfflineCheckTask deviceOfflineCheckTask;
+
     @Value("${device.status.api.url:http://zk-api-service/equipmentStatus/updateAlarmStatus}")
     private String deviceStatusApiUrl;
 
@@ -108,6 +111,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 				}
 				// 入库
 				service.save(resultData);
+				// 更新设备最后接收时间,并标记设备在线
+				DeviceOfflineCheckTask.deviceLastReceiveTimeMap.put(resultData.getSystemIdentifier(), new java.util.Date());
+				deviceOfflineCheckTask.markDeviceOnline(resultData.getSystemIdentifier());
 				logger.info("上报历史记录数据入库成功: {}", resultData);
 				
 				// 水位告警入库

+ 6 - 1
zk-api-service/pom.xml

@@ -17,6 +17,11 @@
     </properties>
 
     <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
         <!--common-->
         <dependency>
             <groupId>com.zksy</groupId>
@@ -102,4 +107,4 @@
             </plugin>
         </plugins>
     </build>
-</project>
+</project>

+ 35 - 0
zk-api-service/src/main/java/com/zksy/api/controller/EquipmentStatusController.java

@@ -56,4 +56,39 @@ public class EquipmentStatusController {
             return Map.of("success", false, "msg", "更新失败: " + e.getMessage());
         }
     }
+
+    /**
+     * 更新设备在线状态
+     * @param params { deviceCode: "设备编码", onlineStatus: 0或1 }
+     * @return 更新结果
+     */
+    @PostMapping("/updateOnlineStatus")
+    @ApiOperation(value = "更新设备在线状态")
+    public Map<String, Object> updateOnlineStatus(@RequestBody Map<String, Object> params) {
+        String deviceCode = (String) params.get("deviceCode");
+        Integer onlineStatus = null;
+        Object onlineStatusObj = params.get("onlineStatus");
+        if (onlineStatusObj instanceof Integer) {
+            onlineStatus = (Integer) onlineStatusObj;
+        } else if (onlineStatusObj != null) {
+            onlineStatus = Integer.parseInt(onlineStatusObj.toString());
+        }
+
+        if (deviceCode == null || deviceCode.isEmpty()) {
+            log.warn("更新在线状态参数无效:deviceCode为空");
+            return Map.of("success", false, "msg", "deviceCode不能为空");
+        }
+        if (onlineStatus == null || (onlineStatus != 0 && onlineStatus != 1)) {
+            log.warn("更新在线状态参数无效:onlineStatus={}", onlineStatus);
+            return Map.of("success", false, "msg", "onlineStatus必须为0或1");
+        }
+
+        try {
+            boolean result = equipmentStatusApiService.updateOnlineStatus(deviceCode, onlineStatus);
+            return Map.of("success", result, "deviceCode", deviceCode, "onlineStatus", onlineStatus);
+        } catch (Exception e) {
+            log.error("更新设备在线状态异常: deviceCode={}, onlineStatus={}", deviceCode, onlineStatus, e);
+            return Map.of("success", false, "msg", "更新失败: " + e.getMessage());
+        }
+    }
 }

+ 73 - 0
zk-api-service/src/main/java/com/zksy/api/event/DeviceStatusChangePublisher.java

@@ -0,0 +1,73 @@
+package com.zksy.api.event;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+
+/** 发布设备状态变更事件,供 pipe-network-service WebSocket 转发。 */
+@Slf4j
+@Component
+public class DeviceStatusChangePublisher {
+
+    public static final String CHANNEL = "device:status:change";
+
+    @Autowired
+    private StringRedisTemplate redisTemplate;
+
+    @Autowired
+    private ObjectMapper objectMapper;
+
+    public void publishAfterCommit(String deviceCode, String equipmentId,
+                                   Integer currentStatus, Integer alarmStatus,
+                                   Integer onlineStatus, String source) {
+        if (deviceCode == null || deviceCode.trim().isEmpty()) {
+            log.warn("设备状态变更事件未发布:deviceCode为空, equipmentId={}, source={}", equipmentId, source);
+            return;
+        }
+        Runnable publishAction = () -> publish(deviceCode, equipmentId,
+                currentStatus, alarmStatus, onlineStatus, source);
+        if (TransactionSynchronizationManager.isSynchronizationActive()) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    publishAction.run();
+                }
+            });
+        } else {
+            publishAction.run();
+        }
+    }
+
+    private void publish(String deviceCode, String equipmentId,
+                         Integer currentStatus, Integer alarmStatus,
+                         Integer onlineStatus, String source) {
+        Map<String, Object> event = new LinkedHashMap<>();
+        event.put("eventId", UUID.randomUUID().toString());
+        event.put("deviceCode", deviceCode);
+        event.put("equipmentId", equipmentId);
+        event.put("currentStatus", currentStatus);
+        event.put("alarmStatus", alarmStatus);
+        event.put("onlineStatus", onlineStatus);
+        event.put("source", source);
+        event.put("changedAt", LocalDateTime.now().toString());
+        try {
+            redisTemplate.convertAndSend(CHANNEL, objectMapper.writeValueAsString(event));
+            log.info("设备状态变更事件已发布: deviceCode={}, equipmentId={}, source={}",
+                    deviceCode, equipmentId, source);
+        } catch (JsonProcessingException e) {
+            log.error("设备状态变更事件序列化失败: deviceCode={}, source={}", deviceCode, source, e);
+        } catch (Exception e) {
+            log.error("设备状态变更事件发布失败: deviceCode={}, source={}", deviceCode, source, e);
+        }
+    }
+}

+ 9 - 1
zk-api-service/src/main/java/com/zksy/api/service/EquipmentStatusApiService.java

@@ -1,7 +1,7 @@
 package com.zksy.api.service;
 
 /**
- * 设备报警状态服务接口
+ * 设备状态服务接口
  */
 public interface EquipmentStatusApiService {
 
@@ -12,4 +12,12 @@ public interface EquipmentStatusApiService {
      * @return 是否更新成功
      */
     boolean updateAlarmStatus(String deviceCode, Integer alarmStatus);
+
+    /**
+     * 更新设备在线状态
+     * @param deviceCode 设备编码
+     * @param onlineStatus 在线状态:0-离线,1-在线
+     * @return 是否更新成功
+     */
+    boolean updateOnlineStatus(String deviceCode, Integer onlineStatus);
 }

+ 83 - 3
zk-api-service/src/main/java/com/zksy/api/service/impl/EquipmentStatusApiServiceImpl.java

@@ -3,6 +3,7 @@ package com.zksy.api.service.impl;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.zksy.api.domain.EquipmentBase;
 import com.zksy.api.domain.EquipmentStatus;
+import com.zksy.api.event.DeviceStatusChangePublisher;
 import com.zksy.api.mapper.EquipmentBaseApiMapper;
 import com.zksy.api.mapper.EquipmentStatusApiMapper;
 import com.zksy.api.service.EquipmentStatusApiService;
@@ -12,6 +13,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.time.LocalDateTime;
+import java.util.Objects;
 
 /**
  * 设备报警状态服务实现
@@ -27,6 +29,9 @@ public class EquipmentStatusApiServiceImpl implements EquipmentStatusApiService
     @Autowired
     private EquipmentStatusApiMapper equipmentStatusApiMapper;
 
+    @Autowired
+    private DeviceStatusChangePublisher deviceStatusChangePublisher;
+
     @Override
     @Transactional
     public boolean updateAlarmStatus(String deviceCode, Integer alarmStatus) {
@@ -53,12 +58,21 @@ public class EquipmentStatusApiServiceImpl implements EquipmentStatusApiService
                         .eq(EquipmentStatus::getEquipmentId, equipmentId));
 
         if (existingStatus != null) {
+            Integer previousAlarmStatus = existingStatus.getAlarmStatus();
             // 更新已有记录
             existingStatus.setAlarmStatus(alarmStatus);
             existingStatus.setStatusUpdateTime(LocalDateTime.now());
-            equipmentStatusApiMapper.updateById(existingStatus);
+            int affected = equipmentStatusApiMapper.updateById(existingStatus);
+            if (affected <= 0) {
+                return false;
+            }
             log.info("更新设备报警状态成功: deviceCode={}, equipmentId={}, alarmStatus={}",
                     deviceCode, equipmentId, alarmStatus);
+            if (!Objects.equals(previousAlarmStatus, alarmStatus)) {
+                deviceStatusChangePublisher.publishAfterCommit(deviceCode, equipmentId,
+                        existingStatus.getCurrentStatus(), existingStatus.getAlarmStatus(),
+                        existingStatus.getOnlineStatus(), "zk-api:updateAlarmStatus");
+            }
         } else {
             // 不存在则创建新记录
             EquipmentStatus newStatus = new EquipmentStatus();
@@ -68,11 +82,77 @@ public class EquipmentStatusApiServiceImpl implements EquipmentStatusApiService
             newStatus.setOnlineStatus(1);  // 默认在线
             newStatus.setStatusUpdateTime(LocalDateTime.now());
             newStatus.setCreateTime(LocalDateTime.now());
-            equipmentStatusApiMapper.insert(newStatus);
+            int affected = equipmentStatusApiMapper.insert(newStatus);
+            if (affected <= 0) {
+                return false;
+            }
             log.info("创建设备报警状态记录成功: deviceCode={}, equipmentId={}, alarmStatus={}",
                     deviceCode, equipmentId, alarmStatus);
+            deviceStatusChangePublisher.publishAfterCommit(deviceCode, equipmentId,
+                    newStatus.getCurrentStatus(), newStatus.getAlarmStatus(),
+                    newStatus.getOnlineStatus(), "zk-api:updateAlarmStatus");
+        }
+
+        return true;
+    }
+
+    @Override
+    @Transactional
+    public boolean updateOnlineStatus(String deviceCode, Integer onlineStatus) {
+        if (deviceCode == null || deviceCode.isEmpty()) {
+            log.warn("更新设备在线状态失败:deviceCode为空");
+            return false;
+        }
+
+        EquipmentBase equipmentBase = equipmentBaseApiMapper.selectOne(
+                new LambdaQueryWrapper<EquipmentBase>()
+                        .eq(EquipmentBase::getEquipmentCode, deviceCode)
+                        .last("LIMIT 1"));
+        if (equipmentBase == null) {
+            log.warn("更新设备在线状态失败:未找到设备编码对应的设备信息,deviceCode={}", deviceCode);
+            return false;
+        }
+
+        String equipmentId = equipmentBase.getEquipmentId();
+
+        EquipmentStatus existingStatus = equipmentStatusApiMapper.selectOne(
+                new LambdaQueryWrapper<EquipmentStatus>()
+                        .eq(EquipmentStatus::getEquipmentId, equipmentId));
+
+        if (existingStatus != null) {
+            Integer previousOnlineStatus = existingStatus.getOnlineStatus();
+            existingStatus.setOnlineStatus(onlineStatus);
+            existingStatus.setStatusUpdateTime(LocalDateTime.now());
+            int affected = equipmentStatusApiMapper.updateById(existingStatus);
+            if (affected <= 0) {
+                return false;
+            }
+            log.info("更新设备在线状态成功: deviceCode={}, equipmentId={}, onlineStatus={}",
+                    deviceCode, equipmentId, onlineStatus);
+            if (!Objects.equals(previousOnlineStatus, onlineStatus)) {
+                deviceStatusChangePublisher.publishAfterCommit(deviceCode, equipmentId,
+                        existingStatus.getCurrentStatus(), existingStatus.getAlarmStatus(),
+                        existingStatus.getOnlineStatus(), "zk-api:updateOnlineStatus");
+            }
+        } else {
+            EquipmentStatus newStatus = new EquipmentStatus();
+            newStatus.setEquipmentId(equipmentId);
+            newStatus.setOnlineStatus(onlineStatus);
+            newStatus.setAlarmStatus(0);
+            newStatus.setCurrentStatus(1);
+            newStatus.setStatusUpdateTime(LocalDateTime.now());
+            newStatus.setCreateTime(LocalDateTime.now());
+            int affected = equipmentStatusApiMapper.insert(newStatus);
+            if (affected <= 0) {
+                return false;
+            }
+            log.info("创建设备在线状态记录成功: deviceCode={}, equipmentId={}, onlineStatus={}",
+                    deviceCode, equipmentId, onlineStatus);
+            deviceStatusChangePublisher.publishAfterCommit(deviceCode, equipmentId,
+                    newStatus.getCurrentStatus(), newStatus.getAlarmStatus(),
+                    newStatus.getOnlineStatus(), "zk-api:updateOnlineStatus");
         }
 
         return true;
     }
-}
+}