Просмотр исходного кода

Merge remote-tracking branch 'origin/master'

Kazerin 4 дней назад
Родитель
Сommit
5f3a7b84bb
86 измененных файлов с 3370 добавлено и 358 удалено
  1. 42 12
      audio-service/src/main/java/com/zksy/audio/utils/DeviceOfflineCheckTask.java
  2. 5 0
      audio-service/src/main/java/com/zksy/audio/utils/MessageHandler.java
  3. 2 0
      environment-service/src/main/java/com/zksy/environment/EnvironmentApplication.java
  4. 8 0
      environment-service/src/main/java/com/zksy/environment/config/RSServerService.java
  5. 79 0
      environment-service/src/main/java/com/zksy/environment/utils/DeviceOfflineCheckTask.java
  6. 2 7
      firefighting-pressure-service/pom.xml
  7. 55 12
      firefighting-pressure-service/src/main/java/com/zksy/pressure/utils/DeviceOfflineCheckTask.java
  8. 7 3
      firefighting-pressure-service/src/main/java/com/zksy/pressure/utils/MessageHandler.java
  9. 42 12
      flammable-gas-service/src/main/java/com/zksy/gas/utils/DeviceOfflineCheckTask.java
  10. 6 0
      flammable-gas-service/src/main/java/com/zksy/gas/utils/MessageHandler.java
  11. 1 2
      manhole-service/pom.xml
  12. 16 6
      manhole-service/src/main/java/com/zksy/manhole/service/impl/BaseDevicesManholeServiceImpl.java
  13. 54 11
      manhole-service/src/main/java/com/zksy/manhole/utils/DeviceOfflineCheckTask.java
  14. 5 1
      manhole-service/src/main/java/com/zksy/manhole/utils/MessageHandler.java
  15. 12 2
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/base/alarm/WarningThresholdController.java
  16. 183 2
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningController.java
  17. 13 4
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningDisposeEfficiencyController.java
  18. 18 1
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningDisposeStatusController.java
  19. 11 3
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningReceiveEfficiencyController.java
  20. 4 1
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningTodayController.java
  21. 4 1
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningTrendController.java
  22. 94 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/WarningDashboardController.java
  23. 14 3
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/WarningItemController.java
  24. 2 2
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/WarningReasonController.java
  25. 20 16
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusRedisListener.java
  26. 173 8
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/DeviceStatusWebSocketHandler.java
  27. 3 1
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/RedisPubSubConfig.java
  28. 153 0
      pipe-network-service/zksy-admin/src/main/java/com/zksy/web/websocket/WorkOrderWebSocketPushTask.java
  29. 2 2
      pipe-network-service/zksy-admin/src/main/resources/mybatis/mybatis-config.xml
  30. 26 0
      pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/web/service/TokenService.java
  31. 5 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/WarningThresholdService.java
  32. 21 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/impl/WarningThresholdServiceImpl.java
  33. 74 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/domain/vo/WorkOrderPushVO.java
  34. 80 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/event/DeviceStatusChangePublisher.java
  35. 8 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/mapper/WorkOrderMapper.java
  36. 7 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/BusinessWorkOrderService.java
  37. 48 44
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/BusinessWorkOrderServiceImpl.java
  38. 41 9
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/EquipmentBaseServiceImpl.java
  39. 74 4
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/EquipmentStatusServiceImpl.java
  40. 21 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/WaterSupplyDeviceServiceImpl.java
  41. 26 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/WorkOrderServiceImpl.java
  42. 10 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/EarlyWarning.java
  43. 0 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningArchive.java
  44. 0 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningAttachment.java
  45. 13 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningClearDTO.java
  46. 13 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningConfirmDTO.java
  47. 0 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningItem.java
  48. 13 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningProcessDTO.java
  49. 0 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningReason.java
  50. 39 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningSupervision.java
  51. 0 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningTodo.java
  52. 83 6
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/mapper/EarlyWarningMapper.java
  53. 13 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/mapper/WarningSupervisionMapper.java
  54. 15 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/EarlyWarningService.java
  55. 5 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/IEarlyWarningDisposeEfficiencyService.java
  56. 5 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/IEarlyWarningDisposeStatusService.java
  57. 3 2
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/IEarlyWarningReceiveEfficiencyService.java
  58. 82 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/WarningDashboardService.java
  59. 10 7
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningDisposeEfficiencyServiceImpl.java
  60. 8 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningDisposeStatusServiceImpl.java
  61. 7 8
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningReceiveEfficiencyServiceImpl.java
  62. 202 10
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningServiceImpl.java
  63. 2 6
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningTodayServiceImpl.java
  64. 305 0
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/WarningDashboardServiceImpl.java
  65. 64 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/WarningItemServiceImpl.java
  66. 72 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/WarningReasonServiceImpl.java
  67. 4 1
      pipe-network-service/zksy-system/src/main/java/com/zksy/manhole/dto/in/WorkOrderAddInDTO.java
  68. 8 5
      pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/impl/SysMenuServiceImpl.java
  69. 15 0
      pipe-network-service/zksy-system/src/main/resources/mapper/WorkOrderMapper.xml
  70. 531 76
      pipe-network-service/zksy-system/src/main/resources/mapper/warning/EarlyWarningMapper.xml
  71. 4 0
      pipe-network-service/zksy-system/src/main/resources/mapper/warning/WarningDisposalMapper.xml
  72. 9 0
      pipe-network-service/zksy-system/src/main/resources/mapper/warning/WarningSupervisionMapper.xml
  73. 1 1
      pom.xml
  74. 42 12
      radar-service/src/main/java/com/zksy/radar/utils/DeviceOfflineCheckTask.java
  75. 6 0
      radar-service/src/main/java/com/zksy/radar/utils/MessageHandler.java
  76. 42 12
      telemetry-service/src/main/java/com/zksy/telemetry/utils/DeviceOfflineCheckTask.java
  77. 7 0
      telemetry-service/src/main/java/com/zksy/telemetry/utils/MessageHandler.java
  78. 42 12
      water-level-service/src/main/java/com/zksy/water/utils/DeviceOfflineCheckTask.java
  79. 6 0
      water-level-service/src/main/java/com/zksy/water/utils/MessageHandler.java
  80. 6 1
      zk-api-service/pom.xml
  81. 35 0
      zk-api-service/src/main/java/com/zksy/api/controller/EquipmentStatusController.java
  82. 10 0
      zk-api-service/src/main/java/com/zksy/api/domain/EarlyWarning.java
  83. 73 0
      zk-api-service/src/main/java/com/zksy/api/event/DeviceStatusChangePublisher.java
  84. 9 1
      zk-api-service/src/main/java/com/zksy/api/service/EquipmentStatusApiService.java
  85. 12 2
      zk-api-service/src/main/java/com/zksy/api/service/impl/EarlyWarningAutoServiceImpl.java
  86. 83 3
      zk-api-service/src/main/java/com/zksy/api/service/impl/EquipmentStatusApiServiceImpl.java

+ 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);
+        }
+    }
 }

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

@@ -24,6 +24,9 @@ public class MessageHandler extends ChannelInboundHandlerAdapter {
 
 	private final ManholeDataService messageParseResultService;
 
+	@Autowired
+	private DeviceOfflineCheckTask deviceOfflineCheckTask;
+
 	public MessageHandler(ManholeDataService messageParseResultService) {
 		this.messageParseResultService = messageParseResultService;
 	}
@@ -49,9 +52,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) {

+ 12 - 2
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/base/alarm/WarningThresholdController.java

@@ -64,12 +64,22 @@ public class WarningThresholdController {
     }
     @PostMapping("/save")
     @ApiOperation(value = "预警阈值信息保存")
-    public AjaxResult save(@RequestBody WarningThreshold entity) {
+    public AjaxResult save(@RequestBody WarningThreshold entity,
+                           @RequestParam(required = false) String moduleType) {
+        if (org.springframework.util.StringUtils.hasText(moduleType)
+                && !service.isDeviceInModule(entity.getDeviceCode(), moduleType)) {
+            return AjaxResult.error("设备不属于指定模块,无法保存阈值");
+        }
         return service.save(entity) ? AjaxResult.success(entity): AjaxResult.error("保存失败");
     }
     @PostMapping("/update")
     @ApiOperation(value = "预警阈值信息修改")
-    public AjaxResult update(@RequestBody WarningThreshold entity) {
+    public AjaxResult update(@RequestBody WarningThreshold entity,
+                             @RequestParam(required = false) String moduleType) {
+        if (org.springframework.util.StringUtils.hasText(moduleType)
+                && !service.isDeviceInModule(entity.getDeviceCode(), moduleType)) {
+            return AjaxResult.error("设备不属于指定模块,无法保存阈值");
+        }
         entity.setUpdateTime(LocalDateTime.now());
         return service.updateById(entity) ? AjaxResult.success(entity): AjaxResult.error("修改失败");
     }

+ 183 - 2
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningController.java

@@ -5,8 +5,13 @@ import com.baomidou.mybatisplus.core.metadata.IPage;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.domain.WarningArchive;
+import com.zksy.base.warning.domain.WarningAttachment;
 import com.zksy.base.warning.domain.WarningDisposal;
 import com.zksy.base.warning.domain.WarningSaveDTO;
+import com.zksy.base.warning.domain.WarningConfirmDTO;
+import com.zksy.base.warning.domain.WarningProcessDTO;
+import com.zksy.base.warning.domain.WarningClearDTO;
+import com.zksy.base.warning.domain.WarningSupervision;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.common.annotation.Log;
 import com.zksy.common.core.domain.AjaxResult;
@@ -15,13 +20,21 @@ import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import io.swagger.annotations.ApiParam;
 import lombok.extern.slf4j.Slf4j;
+import io.minio.GetObjectArgs;
+import io.minio.MinioClient;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
 import java.time.LocalDateTime;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -35,6 +48,12 @@ public class EarlyWarningController {
     @Autowired
     private EarlyWarningService earlyWarningService;
 
+    @Autowired
+    private MinioClient minioClient;
+
+    @Value("${minio.bucket}")
+    private String minioBucket;
+
     @GetMapping("/list")
     @ApiOperation("分页查询预警列表")
     @PreAuthorize("@ss.hasPermi('warning:warning:list')")
@@ -146,6 +165,49 @@ List<String> alarmIds = alarmIdsJson != null && !alarmIdsJson.isEmpty()
         return result ? AjaxResult.success("发布成功") : AjaxResult.error("发布失败");
     }
 
+    @PostMapping("/confirm")
+    @ApiOperation("确认异常并分配处理人")
+    @PreAuthorize("@ss.hasPermi('warning:warning:handle')")
+    public AjaxResult confirmWarning(
+            @RequestParam String warningId,
+            @RequestParam(required = false) String assignedUser,
+            @RequestParam(required = false) String confirmRemark) {
+        boolean result = earlyWarningService.confirmWarning(warningId, assignedUser, confirmRemark);
+        return result ? AjaxResult.success("预警已确认并进入处理") : AjaxResult.error("预警确认失败");
+    }
+
+    @PostMapping("/misreport")
+    @ApiOperation("确认误报并清除预警")
+    @PreAuthorize("@ss.hasPermi('warning:warning:handle')")
+    public AjaxResult misreportWarning(@RequestParam String warningId, @RequestParam String reason) {
+        boolean result = earlyWarningService.misreportWarning(warningId, reason);
+        return result ? AjaxResult.success("误报已清除") : AjaxResult.error("误报处理失败");
+    }
+
+    @PostMapping("/process")
+    @ApiOperation("提交预警处理反馈")
+    @PreAuthorize("@ss.hasPermi('warning:warning:handle')")
+    public AjaxResult submitWarningProcess(
+            @RequestPart("process") String processJson,
+            @RequestPart(value = "files", required = false) List<MultipartFile> files) {
+        try {
+            WarningProcessDTO process = JSON.parseObject(processJson, WarningProcessDTO.class);
+            boolean result = earlyWarningService.submitWarningProcess(process.getWarningId(), process.getProcessContent(), files);
+            return result ? AjaxResult.success("处理反馈已提交") : AjaxResult.error("处理反馈提交失败");
+        } catch (Exception e) {
+            log.error("提交预警处理反馈失败", e);
+            return AjaxResult.error("处理反馈提交失败: " + e.getMessage());
+        }
+    }
+
+    @PostMapping("/clear")
+    @ApiOperation("清除预警")
+    @PreAuthorize("@ss.hasPermi('warning:warning:handle')")
+    public AjaxResult clearWarning(@RequestParam String warningId, @RequestParam String clearRemark) {
+        boolean result = earlyWarningService.clearWarning(warningId, clearRemark);
+        return result ? AjaxResult.success("预警已清除") : AjaxResult.error("预警清除失败");
+    }
+
     @PostMapping("/upgrade")
     @ApiOperation("预警升级")
     @Log(title = "预警升级", businessType = BusinessType.UPDATE)
@@ -265,9 +327,11 @@ List<String> alarmIds = alarmIdsJson != null && !alarmIdsJson.isEmpty()
             @ApiParam("排除发布人(如:系统自动)") @RequestParam(required = false) String excludePublisher,
             @ApiParam("权属单位") @RequestParam(required = false) String ownershipUnit,
             @ApiParam("开始时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
-            @ApiParam("结束时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
+            @ApiParam("结束时间") @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
+            @ApiParam("设备编码") @RequestParam(required = false) String deviceCode,
+            @ApiParam("设备名称") @RequestParam(required = false) String deviceName) {
         Page<?> page = new Page<>(pageNum, pageSize);
-        IPage<Map<String, Object>> result = earlyWarningService.searchWarnings(page, warningName, warningNo, warningType, warningLevel, status, location, publisher, ownershipUnit, startTime, endTime, excludePublisher);
+        IPage<Map<String, Object>> result = earlyWarningService.searchWarnings(page, warningName, warningNo, warningType, warningLevel, status, location, publisher, ownershipUnit, startTime, endTime, excludePublisher, deviceCode, deviceName);
         return AjaxResult.success(result);
     }
 
@@ -326,6 +390,123 @@ List<String> alarmIds = alarmIdsJson != null && !alarmIdsJson.isEmpty()
         return AjaxResult.success(result);
     }
 
+    @GetMapping("/attachments/{attachmentId}/preview")
+    @ApiOperation("预览预警附件")
+    @PreAuthorize("@ss.hasPermi('warning:warning:query')")
+    public void previewAttachment(@PathVariable String attachmentId, javax.servlet.http.HttpServletResponse response) {
+        streamAttachment(attachmentId, response, false);
+    }
+
+    @GetMapping("/attachments/{attachmentId}/download")
+    @ApiOperation("下载预警附件")
+    @PreAuthorize("@ss.hasPermi('warning:warning:query')")
+    public void downloadAttachment(@PathVariable String attachmentId, javax.servlet.http.HttpServletResponse response) {
+        streamAttachment(attachmentId, response, true);
+    }
+
+    private void streamAttachment(String attachmentId, javax.servlet.http.HttpServletResponse response, boolean download) {
+        WarningAttachment attachment = earlyWarningService.getAttachment(attachmentId);
+        if (attachment == null || attachment.getAttachmentUrl() == null || attachment.getAttachmentUrl().trim().isEmpty()) {
+            sendAttachmentError(response, javax.servlet.http.HttpServletResponse.SC_NOT_FOUND, "附件不存在");
+            return;
+        }
+
+        String objectName = normalizeObjectName(attachment.getAttachmentUrl());
+        if (objectName == null) {
+            sendAttachmentError(response, javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST, "附件地址无效");
+            return;
+        }
+
+        String fileName = displayAttachmentName(attachment.getAttachmentName());
+        try (InputStream input = minioClient.getObject(GetObjectArgs.builder()
+                .bucket(minioBucket)
+                .object(objectName)
+                .build())) {
+            response.reset();
+            response.setContentType(resolveContentType(attachment.getAttachmentType(), fileName));
+            if (attachment.getAttachmentSize() != null && attachment.getAttachmentSize() >= 0) {
+                response.setContentLengthLong(attachment.getAttachmentSize());
+            }
+            response.setHeader("X-Content-Type-Options", "nosniff");
+            response.setHeader("Content-Disposition", (download ? "attachment" : "inline")
+                    + "; filename*=UTF-8''" + URLEncoder.encode(fileName, StandardCharsets.UTF_8.name()).replace("+", "%20"));
+            byte[] buffer = new byte[8192];
+            int length;
+            while ((length = input.read(buffer)) != -1) {
+                response.getOutputStream().write(buffer, 0, length);
+            }
+            response.getOutputStream().flush();
+        } catch (Exception e) {
+            log.error("读取预警附件失败,attachmentId={}", attachmentId, e);
+            sendAttachmentError(response, javax.servlet.http.HttpServletResponse.SC_NOT_FOUND, "附件读取失败");
+        }
+    }
+
+    private String normalizeObjectName(String attachmentUrl) {
+        String objectName = attachmentUrl.trim();
+        int bucketIndex = objectName.indexOf("/" + minioBucket + "/");
+        if (objectName.startsWith("http://") || objectName.startsWith("https://")) {
+            try {
+                objectName = new java.net.URI(objectName).getPath();
+            } catch (Exception e) {
+                return null;
+            }
+        }
+        if (bucketIndex >= 0) {
+            objectName = objectName.substring(bucketIndex + minioBucket.length() + 2);
+        } else {
+            objectName = objectName.replaceFirst("^/", "");
+            if (objectName.startsWith(minioBucket + "/")) {
+                objectName = objectName.substring(minioBucket.length() + 1);
+            }
+        }
+        return objectName.isEmpty() || objectName.contains("..") ? null : objectName;
+    }
+
+    private String displayAttachmentName(String attachmentName) {
+        if (attachmentName == null || attachmentName.trim().isEmpty()) {
+            return "预警附件";
+        }
+        return attachmentName.replaceFirst("^\\[(预警报告|处理反馈)\\]\\s*", "");
+    }
+
+    private String resolveContentType(String savedType, String fileName) {
+        String extension = fileName.substring(fileName.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
+        switch (extension) {
+            case "jpg":
+            case "jpeg": return "image/jpeg";
+            case "png": return "image/png";
+            case "gif": return "image/gif";
+            case "webp": return "image/webp";
+            case "pdf": return "application/pdf";
+            case "xls": return "application/vnd.ms-excel";
+            case "xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+            case "doc": return "application/msword";
+            case "docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+            default:
+                return savedType != null && !savedType.trim().isEmpty()
+                        ? savedType : "application/octet-stream";
+        }
+    }
+
+    private void sendAttachmentError(javax.servlet.http.HttpServletResponse response, int status, String message) {
+        try {
+            if (!response.isCommitted()) {
+                response.sendError(status, message);
+            }
+        } catch (IOException ignored) {
+            log.warn("返回附件错误信息失败", ignored);
+        }
+    }
+
+    @GetMapping("/supervision/{warningId}")
+    @ApiOperation("获取预警督办记录")
+    @PreAuthorize("@ss.hasPermi('warning:warning:query')")
+    public AjaxResult getSupervisionList(@PathVariable String warningId) {
+        List<WarningSupervision> result = earlyWarningService.getSupervisionList(warningId);
+        return AjaxResult.success(result);
+    }
+
     @GetMapping("/dashboard/statistics")
     @ApiOperation("获取仪表盘统计数据")
     @PreAuthorize("@ss.hasPermi('warning:warning:query')")

+ 13 - 4
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningDisposeEfficiencyController.java

@@ -14,6 +14,7 @@ import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.security.access.prepost.PreAuthorize;
 
 import java.time.LocalDateTime;
 import java.util.List;
@@ -23,7 +24,7 @@ import java.util.Map;
  * 预警统计-处置时效分析接口
  */
 @RestController
-@RequestMapping("/api/warning/disposeEfficiency")
+@RequestMapping("/warning/disposeEfficiency")
 @Api(tags = "预警信息-处置时效分析模块")
 public class EarlyWarningDisposeEfficiencyController {
 
@@ -33,6 +34,7 @@ public class EarlyWarningDisposeEfficiencyController {
     private IEarlyWarningDisposeEfficiencyService efficiencyService;
 
     @GetMapping("/overview")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:efficiency')")
     @ApiOperation(value = "处置时效概览统计", notes = "返回平均、最长、最短处置时长及总数量核心指标")
     @ApiResponses({
             @ApiResponse(code = 200, message = "统计成功", response = AjaxResult.class),
@@ -59,6 +61,7 @@ public class EarlyWarningDisposeEfficiencyController {
     }
 
     @GetMapping("/range")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:efficiency')")
     @ApiOperation(value = "处置时效区间分布", notes = "按时长区间统计预警数量,适配柱状图展示")
     @ApiResponses({
             @ApiResponse(code = 200, message = "统计成功", response = AjaxResult.class),
@@ -85,6 +88,7 @@ public class EarlyWarningDisposeEfficiencyController {
     }
 
     @GetMapping("/special")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:efficiency')")
     @ApiOperation(value = "按专项统计处置时效", notes = "按预警专项维度统计平均处置时长,从高到低排序")
     @ApiResponses({
             @ApiResponse(code = 200, message = "统计成功", response = AjaxResult.class),
@@ -97,9 +101,13 @@ public class EarlyWarningDisposeEfficiencyController {
 
             @ApiParam(value = "发布结束时间 yyyy-MM-dd HH:mm:ss", example = "2026-06-01 23:59:59")
             @RequestParam(required = false)
-            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
+            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
+
+            @ApiParam(value = "预警专项", example = "管网压力专项")
+            @RequestParam(required = false) String warningSpecial) {
         try {
-            List<Map<String, Object>> result = efficiencyService.getEfficiencyBySpecial(startTime, endTime);
+            List<Map<String, Object>> result = efficiencyService.getEfficiencyBySpecial(
+                    startTime, endTime, warningSpecial);
             return AjaxResult.success("专项时效统计查询成功", result);
         } catch (Exception e) {
             log.error("专项处置时效统计异常", e);
@@ -108,6 +116,7 @@ public class EarlyWarningDisposeEfficiencyController {
     }
 
     @GetMapping("/page")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:efficiency')")
     @ApiOperation(value = "处置时效明细列表", notes = "分页查询预警处置时效明细,支持按时长排序,定位低效案例")
     @ApiResponses({
             @ApiResponse(code = 200, message = "查询成功", response = AjaxResult.class),
@@ -139,4 +148,4 @@ public class EarlyWarningDisposeEfficiencyController {
             return AjaxResult.error("查询失败:" + e.getMessage());
         }
     }
-}
+}

+ 18 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningDisposeStatusController.java

@@ -15,6 +15,7 @@ import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
+import org.springframework.security.access.prepost.PreAuthorize;
 
 import java.time.LocalDateTime;
 import java.util.List;
@@ -34,6 +35,7 @@ public class EarlyWarningDisposeStatusController {
     private IEarlyWarningDisposeStatusService disposeStatusService;
 
     @GetMapping("/stat")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:status')")
     @ApiOperation(value = "处置状态统计分析",
             notes = "按预警处置状态分组统计数量,支持时间范围、预警专项筛选,返回数据适配饼图/柱状图")
     @ApiResponses({
@@ -59,4 +61,19 @@ public class EarlyWarningDisposeStatusController {
             return AjaxResult.error("统计失败:" + e.getMessage());
         }
     }
-}
+
+    @GetMapping("/disposal-type")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:status')")
+    @ApiOperation("按处置类型统计预警数量")
+    public AjaxResult getDisposalTypeStat(
+            @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,
+            @RequestParam(required = false) String warningSpecial) {
+        try {
+            return AjaxResult.success(disposeStatusService.getDisposalTypeStat(startTime, endTime, warningSpecial));
+        } catch (Exception e) {
+            log.error("按处置类型统计异常", e);
+            return AjaxResult.error("统计失败:" + e.getMessage());
+        }
+    }
+}

+ 11 - 3
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningReceiveEfficiencyController.java

@@ -14,6 +14,7 @@ import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.security.access.prepost.PreAuthorize;
 
 import java.time.LocalDateTime;
 import java.util.List;
@@ -33,6 +34,7 @@ public class EarlyWarningReceiveEfficiencyController {
     private IEarlyWarningReceiveEfficiencyService receiveEfficiencyService;
 
     @GetMapping("/overview")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:receive')")
     @ApiOperation(value = "接警效率概览统计", notes = "返回平均、最长、最短接警时长及接警总数核心指标")
     @ApiResponses({
             @ApiResponse(code = 200, message = "统计成功", response = AjaxResult.class),
@@ -66,6 +68,7 @@ public class EarlyWarningReceiveEfficiencyController {
     }
 
     @GetMapping("/range")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:receive')")
     @ApiOperation(value = "接警时效区间分布", notes = "按响应时长区间统计预警数量,适配柱状图展示")
     @ApiResponses({
             @ApiResponse(code = 200, message = "统计成功", response = AjaxResult.class),
@@ -99,6 +102,7 @@ public class EarlyWarningReceiveEfficiencyController {
     }
 
     @GetMapping("/type")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:receive')")
     @ApiOperation(value = "按类型接警统计接警效率", notes = "按预警类型维度统计平均接警时长,从高到低排序")
     @ApiResponses({
             @ApiResponse(code = 200, message = "统计成功", response = AjaxResult.class),
@@ -111,9 +115,12 @@ public class EarlyWarningReceiveEfficiencyController {
 
             @ApiParam(value = "发布结束时间 yyyy-MM-dd HH:mm:ss", example = "2026-06-01 23:59:59")
             @RequestParam(required = false)
-            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime) {
+            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
+            @RequestParam(required = false) String warningType,
+            @RequestParam(required = false) String warningSpecial,
+            @RequestParam(required = false) String status) {
         try {
-            List<Map<String, Object>> result = receiveEfficiencyService.getEfficiencyByType(startTime, endTime);
+            List<Map<String, Object>> result = receiveEfficiencyService.getEfficiencyByType(startTime, endTime, warningType, warningSpecial, status);
             return AjaxResult.success("类型维度统计查询成功", result);
         } catch (Exception e) {
             log.error("按类型统计接警效率异常", e);
@@ -122,6 +129,7 @@ public class EarlyWarningReceiveEfficiencyController {
     }
 
     @GetMapping("/page")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:receive')")
     @ApiOperation(value = "接警时效明细列表", notes = "分页查询预警接警时效明细,支持按时长排序,定位低效响应案例")
     @ApiResponses({
             @ApiResponse(code = 200, message = "查询成功", response = AjaxResult.class),
@@ -160,4 +168,4 @@ public class EarlyWarningReceiveEfficiencyController {
             return AjaxResult.error("查询失败:" + e.getMessage());
         }
     }
-}
+}

+ 4 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningTodayController.java

@@ -14,6 +14,7 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.security.access.prepost.PreAuthorize;
 
 import java.util.Map;
 
@@ -31,6 +32,7 @@ public class EarlyWarningTodayController {
     private IEarlyWarningTodayService earlyWarningTodayService;
 
     @GetMapping("/stats")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:today')")
     @ApiOperation(value = "获取今日预警统计指标", notes = "返回今日预警总数、已处置数、已解除数三个核心指标")
     @ApiResponses({
             @ApiResponse(code = 200, message = "查询成功", response = AjaxResult.class),
@@ -47,6 +49,7 @@ public class EarlyWarningTodayController {
     }
 
     @GetMapping("/page")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:today')")
     @ApiOperation(value = "分页查询今日预警列表", notes = "支持按状态筛选,为空则查询所有非草稿状态的今日预警")
     @ApiResponses({
             @ApiResponse(code = 200, message = "查询成功", response = AjaxResult.class),
@@ -65,4 +68,4 @@ public class EarlyWarningTodayController {
             return AjaxResult.error("查询失败:" + e.getMessage());
         }
     }
-}
+}

+ 4 - 1
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/EarlyWarningTrendController.java

@@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.format.annotation.DateTimeFormat;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.security.access.prepost.PreAuthorize;
 
 import java.time.LocalDateTime;
 import java.util.List;
@@ -31,6 +32,7 @@ public class EarlyWarningTrendController {
     private IEarlyWarningTrendService earlyWarningTrendService;
 
     @GetMapping("/day")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:trend')")
     @ApiOperation(value = "按日统计预警发展趋势", notes = "按日期维度统计各专项预警数量趋势,支持时间范围、预警专项筛选")
     @ApiResponses({
             @ApiResponse(code = 200, message = "统计成功", response = AjaxResult.class),
@@ -55,6 +57,7 @@ public class EarlyWarningTrendController {
     }
 
     @GetMapping("/month")
+    @PreAuthorize("@ss.hasPermi('warning:statistics:trend')")
     @ApiOperation(value = "按月统计预警发展趋势", notes = "按月份维度统计各专项预警数量趋势,支持时间范围、预警专项筛选")
     @ApiResponses({
             @ApiResponse(code = 200, message = "统计成功", response = AjaxResult.class),
@@ -77,4 +80,4 @@ public class EarlyWarningTrendController {
             return AjaxResult.error("统计失败:" + e.getMessage());
         }
     }
-}
+}

+ 94 - 0
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/WarningDashboardController.java

@@ -0,0 +1,94 @@
+package com.zksy.web.controller.warning;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.warning.service.WarningDashboardService;
+import com.zksy.common.core.domain.AjaxResult;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import io.swagger.annotations.ApiParam;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * Warning overview APIs for the three dynamic-menu pages.
+ */
+@RestController
+@RequestMapping("/api/warning-dashboard")
+@Api(tags = "预警总览")
+public class WarningDashboardController {
+
+    private static final int MAX_PAGE_SIZE = 100;
+
+    @Autowired
+    private WarningDashboardService warningDashboardService;
+
+    @GetMapping("/today-overview")
+    @ApiOperation("今日预警概况")
+    @PreAuthorize("@ss.hasAnyPermi('warning:overview:today,warning:warning:query')")
+    public AjaxResult getTodayOverview() {
+        return AjaxResult.success(warningDashboardService.getTodayOverview());
+    }
+
+    @GetMapping("/today-overview/page")
+    @ApiOperation("分页查询未解除、今日已解除或今日全部预警")
+    @PreAuthorize("@ss.hasAnyPermi('warning:overview:today,warning:warning:query')")
+    public AjaxResult getTodayOverviewPage(
+            @ApiParam("unresolved-未解除,resolved-今日已解除,all-今日全部")
+            @RequestParam(defaultValue = "unresolved") String category,
+            @RequestParam(defaultValue = "1") Integer pageNum,
+            @RequestParam(defaultValue = "10") Integer pageSize,
+            @RequestParam(required = false) String warningType,
+            @RequestParam(required = false) String warningLevel,
+            @RequestParam(required = false) String keyword) {
+        Page<Map<String, Object>> page = createPage(pageNum, pageSize);
+        return AjaxResult.success(warningDashboardService.getTodayOverviewPage(
+                page, category, warningType, warningLevel, keyword));
+    }
+
+    @GetMapping("/history-overview")
+    @ApiOperation("历史预警概况")
+    @PreAuthorize("@ss.hasAnyPermi('warning:overview:history,warning:warning:query')")
+    public AjaxResult getHistoryOverview() {
+        return AjaxResult.success(warningDashboardService.getHistoryOverview());
+    }
+
+    @GetMapping("/history-overview/page")
+    @ApiOperation("分页查询历史预警明细")
+    @PreAuthorize("@ss.hasAnyPermi('warning:overview:history,warning:warning:query')")
+    public AjaxResult getHistoryOverviewPage(
+            @RequestParam(defaultValue = "1") Integer pageNum,
+            @RequestParam(defaultValue = "10") Integer pageSize,
+            @RequestParam(required = false) String warningType,
+            @RequestParam(required = false) String warningSpecial,
+            @RequestParam(required = false) String keyword) {
+        Page<Map<String, Object>> page = createPage(pageNum, pageSize);
+        return AjaxResult.success(warningDashboardService.getHistoryWarningPage(
+                page, warningType, warningSpecial, keyword));
+    }
+
+    @GetMapping("/today-unprocessed")
+    @ApiOperation("今日未处置预警(截至当前未处置积压)")
+    @PreAuthorize("@ss.hasAnyPermi('warning:overview:unprocessed,warning:warning:query')")
+    public AjaxResult getTodayUnprocessed(
+            @RequestParam(defaultValue = "1") Integer pageNum,
+            @RequestParam(defaultValue = "10") Integer pageSize,
+            @RequestParam(required = false) String warningType,
+            @RequestParam(required = false) String warningLevel,
+            @RequestParam(required = false) String keyword) {
+        Page<Map<String, Object>> page = createPage(pageNum, pageSize);
+        return AjaxResult.success(warningDashboardService.getTodayUnprocessed(
+                page, warningType, warningLevel, keyword));
+    }
+
+    private Page<Map<String, Object>> createPage(Integer pageNum, Integer pageSize) {
+        int safePageNum = pageNum == null || pageNum < 1 ? 1 : pageNum;
+        int safePageSize = pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, MAX_PAGE_SIZE);
+        return new Page<>(safePageNum, safePageSize);
+    }
+}

+ 14 - 3
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/WarningItemController.java

@@ -80,7 +80,7 @@ public class WarningItemController {
 
     @GetMapping("/detail/{itemId}")
     @ApiOperation("预警事项详情-获取事项详细信息")
-    @PreAuthorize("@ss.hasPermi('warning:item:query')")
+    @PreAuthorize("@ss.hasAnyPermi('warning:item:list,warning:item:query,warning:item:edit')")
     public AjaxResult getItemDetail(@PathVariable String itemId) {
         WarningItem item = warningItemService.getItemDetail(itemId);
         if (item != null) {
@@ -90,13 +90,24 @@ public class WarningItemController {
         }
     }
 
+    @GetMapping("/detail/code/{itemCode}")
+    @ApiOperation("预警事项详情-根据事项编码获取详细信息")
+    @PreAuthorize("@ss.hasAnyPermi('warning:item:list,warning:item:query,warning:item:edit')")
+    public AjaxResult getItemDetailByCode(@PathVariable String itemCode) {
+        WarningItem item = warningItemService.getItemByCode(itemCode);
+        if (item != null) {
+            return AjaxResult.success(item);
+        }
+        return AjaxResult.error("未查询到事项编码为 " + itemCode + " 的预警事项");
+    }
+
     @GetMapping("/code/generate")
     @ApiOperation("事项编码生成-自动生成事项编码")
-    @PreAuthorize("@ss.hasPermi('warning:item:query')")
+    @PreAuthorize("@ss.hasAnyPermi('warning:item:query,warning:item:add,warning:item:edit')")
     public AjaxResult generateItemCode(
             @ApiParam("预警类型") @RequestParam String warningType,
             @ApiParam("预警级别") @RequestParam String warningLevel) {
         String itemCode = warningItemService.generateItemCode(warningType, warningLevel);
-        return AjaxResult.success(itemCode);
+        return AjaxResult.success("事项编码生成成功", itemCode);
     }
 }

+ 2 - 2
pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/warning/WarningReasonController.java

@@ -43,7 +43,7 @@ public class WarningReasonController {
 
     @GetMapping("/query")
     @ApiOperation("预警原因库管理-根据类型和级别查询原因列表")
-    @PreAuthorize("@ss.hasPermi('warning:reason:query')")
+    @PreAuthorize("@ss.hasAnyPermi('warning:reason:query,warning:item:query')")
     public AjaxResult getReasonsByTypeAndLevel(
             @ApiParam("预警类型") @RequestParam String warningType,
             @ApiParam("预警级别") @RequestParam String warningLevel) {
@@ -91,7 +91,7 @@ public class WarningReasonController {
 
     @GetMapping("/detail/{reasonId}")
     @ApiOperation("预警原因库管理-获取原因详细信息")
-    @PreAuthorize("@ss.hasPermi('warning:reason:query')")
+    @PreAuthorize("@ss.hasAnyPermi('warning:reason:list,warning:reason:query,warning:reason:edit')")
     public AjaxResult getReasonDetail(@PathVariable String reasonId) {
         WarningReason reason = warningReasonService.getReasonDetail(reasonId);
         if (reason != null) {

+ 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);
+    }
+}

+ 2 - 2
pipe-network-service/zksy-admin/src/main/resources/mybatis/mybatis-config.xml

@@ -7,8 +7,8 @@ PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
     <settings>
         <!-- 使全局的映射器启用或禁用缓存 -->
         <setting name="cacheEnabled"             value="true"   />
-        <!-- 允许JDBC 支持自动生成主键 -->
-        <setting name="useGeneratedKeys"         value="true"   />
+        <!-- 允许JDBC 支持自动生成主键(Kingbase不支持,已关闭) -->
+        <setting name="useGeneratedKeys"         value="false"   />
         <!-- 配置默认的执行器.SIMPLE就是普通执行器;REUSE执行器会重用预处理语句(prepared statements);BATCH执行器将重用语句并执行批量更新 -->
         <setting name="defaultExecutorType"      value="SIMPLE" />
 		<!-- 指定 MyBatis 所用日志的具体实现 -->

+ 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;
+        }
+    }
+
     /**
      * 设置用户身份信息
      */

+ 5 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/alarm/service/WarningThresholdService.java

@@ -26,4 +26,9 @@ public interface WarningThresholdService extends IService<WarningThreshold> {
      * @param deviceCode 设备编码,可选,用于在模块内精确检索单个设备
      */
     Page<WarningThreshold> findByModulePage(long pageNum, long pageSize, String typeName, String deviceCode, String warningType, String warningCode);
+
+    /**
+     * 校验设备编码是否全部属于指定设备模块。
+     */
+    boolean isDeviceInModule(String deviceCode, String typeName);
 }

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

@@ -14,6 +14,8 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.util.List;
+import java.util.Set;
+import java.util.Arrays;
 import java.util.stream.Collectors;
 
 /**
@@ -78,6 +80,25 @@ public class WarningThresholdServiceImpl extends ServiceImpl<WarningThresholdMap
         return this.page(page, queryWrapper);
     }
 
+    @Override
+    public boolean isDeviceInModule(String deviceCode, String typeName) {
+        if (deviceCode == null || deviceCode.trim().isEmpty() || typeName == null || typeName.trim().isEmpty()) {
+            return false;
+        }
+        Set<String> moduleCodes = equipmentBaseService.findByTopLevelType(typeName).stream()
+                .map(EquipmentBase::getEquipmentCode)
+                .filter(code -> code != null && !code.trim().isEmpty())
+                .map(String::trim)
+                .collect(Collectors.toSet());
+        if (moduleCodes.isEmpty()) {
+            return false;
+        }
+        return Arrays.stream(deviceCode.split(","))
+                .map(String::trim)
+                .filter(code -> !code.isEmpty())
+                .allMatch(moduleCodes::contains);
+    }
+
     @Override
     public List<WarningThreshold> getWarningThresholdList(String deviceCode, String warningType, String warningCode) {
         LambdaQueryWrapper<WarningThreshold> queryWrapper = new LambdaQueryWrapper<>();
@@ -105,5 +126,3 @@ public class WarningThresholdServiceImpl extends ServiceImpl<WarningThresholdMap
 }
 
 
-
-

+ 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);
-        }
-    }
 }

+ 41 - 9
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,14 +779,32 @@ 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
     @Transactional
     public boolean testEquipment(String equipmentId, String testType, String testDetail, String testUser) {
@@ -1171,15 +1193,25 @@ public class EquipmentBaseServiceImpl extends ServiceImpl<EquipmentBaseMapper, E
             return new ArrayList<>();
         }
 
-        List<String> typeIds = new ArrayList<>();
-        List<EquipmentType> children = equipmentTypeMapper.selectList(
-                new LambdaQueryWrapper<EquipmentType>()
-                        .eq(EquipmentType::getParentTypeId, topType.getId()));
-        for (EquipmentType child : children) {
-            typeIds.add(child.getId());
+        // 设备类型可能存在多级子分类,查询整个模块的类型树,避免漏掉深层燃气设备。
+        Set<String> typeIds = new LinkedHashSet<>();
+        Set<String> parentIds = new LinkedHashSet<>();
+        typeIds.add(topType.getId());
+        parentIds.add(topType.getId());
+        while (!parentIds.isEmpty()) {
+            List<EquipmentType> children = equipmentTypeMapper.selectList(
+                    new LambdaQueryWrapper<EquipmentType>()
+                            .in(EquipmentType::getParentTypeId, parentIds));
+            Set<String> nextParentIds = new LinkedHashSet<>();
+            for (EquipmentType child : children) {
+                if (typeIds.add(child.getId())) {
+                    nextParentIds.add(child.getId());
+                }
+            }
+            parentIds = nextParentIds;
         }
         LambdaQueryWrapper<EquipmentBase> wrapper = new LambdaQueryWrapper<>();
-        wrapper.in(EquipmentBase::getEquipmentTypeId, typeIds);
+        wrapper.in(EquipmentBase::getEquipmentTypeId, new ArrayList<>(typeIds));
         wrapper.orderByDesc(EquipmentBase::getCreateTime);
         return this.list(wrapper);
     }

+ 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) {

+ 26 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/service/impl/WorkOrderServiceImpl.java

@@ -115,6 +115,7 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
     @Override
     public Long addWorkOrder(WorkOrderAddInDTO inDTO) {
         log.info("新增工单-入参:{}", inDTO);
+        resolveWarningDevice(inDTO);
         // 同一设备存在进行中工单(状态非 6 已结案/7 已驳回)时禁止重复建单
         if (inDTO.getDeviceId() != null) {
             Long count = this.lambdaQuery()
@@ -166,6 +167,31 @@ public class WorkOrderServiceImpl extends ServiceImpl<WorkOrderMapper, WorkOrder
         return workOrder.getOrderId();
     }
 
+    /**
+     * 确认预警时前端不再重复选择设备,按预警已保存的设备编码回填工单设备信息。
+     */
+    private void resolveWarningDevice(WorkOrderAddInDTO inDTO) {
+        if (inDTO == null || StringUtils.isBlank(inDTO.getAlarmId())) {
+            return;
+        }
+        EarlyWarning warning = earlyWarningService.getById(inDTO.getAlarmId());
+        if (warning == null) {
+            return;
+        }
+        if (StringUtils.isBlank(inDTO.getDeviceCode())) {
+            inDTO.setDeviceCode(warning.getDeviceCode());
+        }
+        if (StringUtils.isNotBlank(inDTO.getDeviceId()) || StringUtils.isBlank(inDTO.getDeviceCode())) {
+            return;
+        }
+        EquipmentBase equipment = equipmentBaseMapper.selectOne(new LambdaQueryWrapper<EquipmentBase>()
+                .eq(EquipmentBase::getEquipmentCode, inDTO.getDeviceCode())
+                .last("LIMIT 1"));
+        if (equipment != null) {
+            inDTO.setDeviceId(equipment.getEquipmentId());
+        }
+    }
+
     /**
      * 生成工单编号
      * @return

+ 10 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/EarlyWarning.java

@@ -43,6 +43,15 @@ public class EarlyWarning implements Serializable {
     @ApiModelProperty("预警专项")
     private String warningSpecial;
 
+
+    @TableField("device_code")
+    @ApiModelProperty("关联设备编码")
+    private String deviceCode;
+
+    @TableField("device_name")
+    @ApiModelProperty("关联设备名称")
+    private String deviceName;
+
     @TableField("location")
     @ApiModelProperty("预警位置")
     private String location;
@@ -107,6 +116,7 @@ public class EarlyWarning implements Serializable {
     @ApiModelProperty("备注")
     private String remark;
 
+
     @TableField(exist = false)
     private static final long serialVersionUID = 1L;
 }

+ 0 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningArchive.java

@@ -44,7 +44,6 @@ public class WarningArchive implements Serializable {
     @TableField("warning_level")
     @ApiModelProperty("预警级别")
     private String warningLevel;
-
     @TableField("location")
     @ApiModelProperty("预警位置")
     private String location;

+ 0 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningAttachment.java

@@ -24,7 +24,6 @@ public class WarningAttachment implements Serializable {
     @TableField("warning_id")
     @ApiModelProperty("预警ID")
     private String warningId;
-
     @TableField("attachment_name")
     @ApiModelProperty("附件名称")
     private String attachmentName;

+ 13 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningClearDTO.java

@@ -0,0 +1,13 @@
+package com.zksy.base.warning.domain;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+@Data
+public class WarningClearDTO {
+    @NotBlank
+    private String warningId;
+    @NotBlank
+    private String clearRemark;
+}

+ 13 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningConfirmDTO.java

@@ -0,0 +1,13 @@
+package com.zksy.base.warning.domain;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+@Data
+public class WarningConfirmDTO {
+    @NotBlank
+    private String warningId;
+    private String assignedUser;
+    private String confirmRemark;
+}

+ 0 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningItem.java

@@ -38,7 +38,6 @@ public class WarningItem implements Serializable {
     @TableField("warning_special")
     @ApiModelProperty("预警专项")
     private String warningSpecial;
-
     @TableField("industry")
     @ApiModelProperty("所属行业")
     private String industry;

+ 13 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningProcessDTO.java

@@ -0,0 +1,13 @@
+package com.zksy.base.warning.domain;
+
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+
+@Data
+public class WarningProcessDTO {
+    @NotBlank
+    private String warningId;
+    @NotBlank
+    private String processContent;
+}

+ 0 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningReason.java

@@ -38,7 +38,6 @@ public class WarningReason implements Serializable {
     @TableField("description")
     @ApiModelProperty("原因描述")
     private String description;
-
     @TableField("sort_order")
     @ApiModelProperty("排序")
     private Integer sortOrder;

+ 39 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningSupervision.java

@@ -0,0 +1,39 @@
+package com.zksy.base.warning.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+@Data
+@TableName(value = "warning_supervision", schema = "app_user")
+public class WarningSupervision implements Serializable {
+    @TableId(value = "supervision_id", type = IdType.ASSIGN_UUID)
+    @ApiModelProperty("督办ID")
+    private String supervisionId;
+    @TableField("warning_id")
+    private String warningId;
+    @TableField("supervision_user")
+    private String supervisionUser;
+    @TableField("supervision_content")
+    private String supervisionContent;
+    @TableField("notification_type")
+    private String notificationType;
+    @TableField("notification_content")
+    private String notificationContent;
+    @TableField("attachment_url")
+    private String attachmentUrl;
+    @TableField("create_by")
+    private String createBy;
+    @TableField("create_time")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
+    private LocalDateTime createTime;
+    @TableField(exist = false)
+    private static final long serialVersionUID = 1L;
+}

+ 0 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/domain/WarningTodo.java

@@ -44,7 +44,6 @@ public class WarningTodo implements Serializable {
     @TableField("task_name")
     @ApiModelProperty("任务名称")
     private String taskName;
-
     @TableField("create_time")
     @ApiModelProperty("创建时间")
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")

+ 83 - 6
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/mapper/EarlyWarningMapper.java

@@ -16,7 +16,7 @@ public interface EarlyWarningMapper extends BaseMapper<EarlyWarning> {
 
     IPage<Map<String, Object>> selectWarningList(Page<?> page, @Param("warningName") String warningName, @Param("warningType") String warningType, @Param("warningLevel") String warningLevel, @Param("status") String status);
 
-    IPage<Map<String, Object>> searchWarnings(Page<?> page, @Param("warningName") String warningName, @Param("warningNo") String warningNo, @Param("warningType") String warningType, @Param("warningLevel") String warningLevel, @Param("status") String status, @Param("location") String location, @Param("publisher") String publisher, @Param("ownershipUnit") String ownershipUnit, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime, @Param("excludePublisher") String excludePublisher);
+    IPage<Map<String, Object>> searchWarnings(Page<?> page, @Param("warningName") String warningName, @Param("warningNo") String warningNo, @Param("warningType") String warningType, @Param("warningLevel") String warningLevel, @Param("status") String status, @Param("location") String location, @Param("publisher") String publisher, @Param("ownershipUnit") String ownershipUnit, @Param("startTime") LocalDateTime startTime, @Param("endTime") LocalDateTime endTime, @Param("excludePublisher") String excludePublisher, @Param("deviceCode") String deviceCode, @Param("deviceName") String deviceName);
 
     IPage<Map<String, Object>> selectTodoList(Page<?> page, @Param("userId") String userId, @Param("todoType") String todoType, @Param("warningName") String warningName);
 
@@ -38,7 +38,9 @@ public interface EarlyWarningMapper extends BaseMapper<EarlyWarning> {
      * 查询今日预警列表(支持按状态筛选)
      * @param status 预警状态,为空则查询所有非草稿状态
      */
-    List<EarlyWarning> selectTodayWarningList(@Param("status") String status);
+    IPage<EarlyWarning> selectTodayWarningList(
+            Page<EarlyWarning> page,
+            @Param("status") String status);
 
     /**
      * 按日统计预警发展趋势(按专项分组)
@@ -74,6 +76,11 @@ public interface EarlyWarningMapper extends BaseMapper<EarlyWarning> {
             @Param("endTime") LocalDateTime endTime,
             @Param("warningSpecial") String warningSpecial);
 
+    List<Map<String, Object>> selectDisposalGroupByType(
+            @Param("startTime") LocalDateTime startTime,
+            @Param("endTime") LocalDateTime endTime,
+            @Param("warningSpecial") String warningSpecial);
+
     /**
      * 处置时效分析-概览指标统计
      * @param startTime 发布开始时间
@@ -102,11 +109,13 @@ public interface EarlyWarningMapper extends BaseMapper<EarlyWarning> {
      * 处置时效分析-按预警专项统计平均时效
      * @param startTime 发布开始时间
      * @param endTime 发布结束时间
+     * @param warningSpecial 预警专项(可选)
      * @return 各专项平均处置时长
      */
     List<Map<String, Object>> selectDisposeEfficiencyBySpecial(
             @Param("startTime") LocalDateTime startTime,
-            @Param("endTime") LocalDateTime endTime);
+            @Param("endTime") LocalDateTime endTime,
+            @Param("warningSpecial") String warningSpecial);
 
     /**
      * 处置时效分析-明细列表
@@ -116,7 +125,8 @@ public interface EarlyWarningMapper extends BaseMapper<EarlyWarning> {
      * @param sortType 排序方式 asc-时效从短到长 desc-时效从长到短
      * @return 预警时效明细
      */
-    List<Map<String, Object>> selectDisposeEfficiencyList(
+    IPage<Map<String, Object>> selectDisposeEfficiencyList(
+            Page<Map<String, Object>> page,
             @Param("startTime") LocalDateTime startTime,
             @Param("endTime") LocalDateTime endTime,
             @Param("warningSpecial") String warningSpecial,
@@ -162,7 +172,10 @@ public interface EarlyWarningMapper extends BaseMapper<EarlyWarning> {
      */
     List<Map<String, Object>> selectReceiveEfficiencyByType(
             @Param("startTime") LocalDateTime startTime,
-            @Param("endTime") LocalDateTime endTime);
+            @Param("endTime") LocalDateTime endTime,
+            @Param("warningType") String warningType,
+            @Param("warningSpecial") String warningSpecial,
+            @Param("status") String status);
 
     /**
      * 接警效率分析-明细列表
@@ -174,11 +187,75 @@ public interface EarlyWarningMapper extends BaseMapper<EarlyWarning> {
      * @param sortType 排序方式 asc-时效从短到长 desc-时效从长到短
      * @return 预警接警时效明细
      */
-    List<Map<String, Object>> selectReceiveEfficiencyList(
+    IPage<Map<String, Object>> selectReceiveEfficiencyList(
+            Page<Map<String, Object>> page,
             @Param("startTime") LocalDateTime startTime,
             @Param("endTime") LocalDateTime endTime,
             @Param("warningType") String warningType,
             @Param("warningSpecial") String warningSpecial,
             @Param("status") String status,
             @Param("sortType") String sortType);
+
+    /**
+     * Warning overview: current active warnings, warnings resolved today and
+     * warnings published today. Drafts and unsupported facility types are excluded.
+     */
+    Map<String, Object> selectDashboardTodaySummary();
+
+    /**
+     * Warning overview preview list. category is controlled by the service and can
+     * only be unresolved or resolved.
+     */
+    List<Map<String, Object>> selectDashboardTodayPreview(
+            @Param("category") String category,
+            @Param("limit") Integer limit);
+
+    /**
+     * Warning overview paged details for the active or resolved-today category.
+     */
+    IPage<Map<String, Object>> selectDashboardTodayPage(
+            Page<Map<String, Object>> page,
+            @Param("category") String category,
+            @Param("warningType") String warningType,
+            @Param("warningLevel") String warningLevel,
+            @Param("keyword") String keyword);
+
+    /**
+     * Historical warning headline indicators.
+     */
+    Map<String, Object> selectDashboardHistorySummary();
+
+    /**
+     * Historical warning distribution grouped by warning special.
+     */
+    List<Map<String, Object>> selectDashboardHistorySpecialStats(
+            @Param("warningType") String warningType);
+
+    /**
+     * Historical warning detail page.
+     */
+    IPage<Map<String, Object>> selectDashboardHistoryPage(
+            Page<Map<String, Object>> page,
+            @Param("warningType") String warningType,
+            @Param("warningSpecial") String warningSpecial,
+            @Param("keyword") String keyword);
+
+    /**
+     * Current warnings which have not completed disposal.
+     */
+    IPage<Map<String, Object>> selectDashboardUnprocessedPage(
+            Page<Map<String, Object>> page,
+            @Param("warningType") String warningType,
+            @Param("warningLevel") String warningLevel,
+            @Param("keyword") String keyword);
+
+    /**
+     * Headline metrics for the current unprocessed-warning filter.  Keeping
+     * these aggregates in SQL prevents the dashboard cards from reflecting
+     * only the currently visible page.
+     */
+    Map<String, Object> selectDashboardUnprocessedSummary(
+            @Param("warningType") String warningType,
+            @Param("warningLevel") String warningLevel,
+            @Param("keyword") String keyword);
 }

+ 13 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/mapper/WarningSupervisionMapper.java

@@ -0,0 +1,13 @@
+package com.zksy.base.warning.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.zksy.base.warning.domain.WarningSupervision;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+@Mapper
+public interface WarningSupervisionMapper extends BaseMapper<WarningSupervision> {
+    List<WarningSupervision> selectByWarningId(@Param("warningId") String warningId);
+}

+ 15 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/EarlyWarningService.java

@@ -6,6 +6,8 @@ import com.baomidou.mybatisplus.extension.service.IService;
 import com.zksy.base.warning.domain.EarlyWarning;
 import com.zksy.base.warning.domain.WarningArchive;
 import com.zksy.base.warning.domain.WarningDisposal;
+import com.zksy.base.warning.domain.WarningSupervision;
+import com.zksy.base.warning.domain.WarningAttachment;
 import org.springframework.web.multipart.MultipartFile;
 
 import java.time.LocalDateTime;
@@ -16,7 +18,7 @@ public interface EarlyWarningService extends IService<EarlyWarning> {
 
     IPage<Map<String, Object>> getWarningList(Page<?> page, String warningName, String warningType, String warningLevel, String status);
 
-    IPage<Map<String, Object>> searchWarnings(Page<?> page, String warningName, String warningNo, String warningType, String warningLevel, String status, String location, String publisher, String ownershipUnit, LocalDateTime startTime, LocalDateTime endTime, String excludePublisher);
+    IPage<Map<String, Object>> searchWarnings(Page<?> page, String warningName, String warningNo, String warningType, String warningLevel, String status, String location, String publisher, String ownershipUnit, LocalDateTime startTime, LocalDateTime endTime, String excludePublisher, String deviceCode, String deviceName);
 
     IPage<Map<String, Object>> getTodoList(Page<?> page, String userName, String todoType, String warningName);
 
@@ -34,6 +36,14 @@ public interface EarlyWarningService extends IService<EarlyWarning> {
 
     boolean publishWarning(String warningId);
 
+    boolean confirmWarning(String warningId, String assignedUser, String confirmRemark);
+
+    boolean misreportWarning(String warningId, String reason);
+
+    boolean submitWarningProcess(String warningId, String processContent, List<MultipartFile> files);
+
+    boolean clearWarning(String warningId, String clearRemark);
+
     boolean upgradeWarning(String warningId, String newLevel, String disposalContent);
 
     boolean resolveWarning(String warningId, String disposalContent);
@@ -58,6 +68,10 @@ public interface EarlyWarningService extends IService<EarlyWarning> {
 
     List<Map<String, Object>> getAttachments(String warningId);
 
+    WarningAttachment getAttachment(String attachmentId);
+
+    List<WarningSupervision> getSupervisionList(String warningId);
+
     Map<String, Object> getStatistics();
 
     Map<String, Object> getDashboardStatistics();

+ 5 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/IEarlyWarningDisposeEfficiencyService.java

@@ -22,7 +22,10 @@ public interface IEarlyWarningDisposeEfficiencyService {
     /**
      * 按预警专项统计平均处置时效
      */
-    List<Map<String, Object>> getEfficiencyBySpecial(LocalDateTime startTime, LocalDateTime endTime);
+    List<Map<String, Object>> getEfficiencyBySpecial(
+            LocalDateTime startTime,
+            LocalDateTime endTime,
+            String warningSpecial);
 
     /**
      * 分页查询处置时效明细列表
@@ -32,4 +35,4 @@ public interface IEarlyWarningDisposeEfficiencyService {
                                                    LocalDateTime endTime,
                                                    String warningSpecial,
                                                    String sortType);
-}
+}

+ 5 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/IEarlyWarningDisposeStatusService.java

@@ -16,4 +16,8 @@ public interface IEarlyWarningDisposeStatusService {
     List<Map<String, Object>> getWarningStatusStat(LocalDateTime startTime,
                                                    LocalDateTime endTime,
                                                    String warningSpecial);
-}
+
+    List<Map<String, Object>> getDisposalTypeStat(LocalDateTime startTime,
+                                                  LocalDateTime endTime,
+                                                  String warningSpecial);
+}

+ 3 - 2
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/IEarlyWarningReceiveEfficiencyService.java

@@ -30,7 +30,8 @@ public interface IEarlyWarningReceiveEfficiencyService {
     /**
      * 按预警类型统计平均接警时效
      */
-    List<Map<String, Object>> getEfficiencyByType(LocalDateTime startTime, LocalDateTime endTime);
+    List<Map<String, Object>> getEfficiencyByType(LocalDateTime startTime, LocalDateTime endTime,
+                                                  String warningType, String warningSpecial, String status);
 
     /**
      * 分页查询接警时效明细列表
@@ -42,4 +43,4 @@ public interface IEarlyWarningReceiveEfficiencyService {
                                                    String warningSpecial,
                                                    String status,
                                                    String sortType);
-}
+}

+ 82 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/WarningDashboardService.java

@@ -0,0 +1,82 @@
+package com.zksy.base.warning.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+
+import java.util.Map;
+
+/**
+ * 预警仪表盘服务接口
+ * <p>
+ * 提供预警大屏可视化所需的统计数据,包括:
+ * <ul>
+ *   <li>今日预警概况 — 截至当前仍未解除 / 今日已解除的预警统计</li>
+ *   <li>历史预警概况 — 各专项预警累计数量和分布</li>
+ *   <li>今日未处置预警 — 截至当前未处置积压的预警列表</li>
+ * </ul>
+ *
+ * @author zksy
+ */
+public interface WarningDashboardService {
+
+    /**
+     * 今日预警概况
+     * <p>
+     * 以当前日期为维度,统计截止目前仍未解除的预警数量和今日已解除的预警数量,
+     * 并返回预警详情描述信息。
+     *
+     * @return { unresolvedCount, resolvedCount, unresolvedList, resolvedList }
+     */
+    Map<String, Object> getTodayOverview();
+
+    /**
+     * Query active or resolved-today warning details with database pagination.
+     */
+    IPage<Map<String, Object>> getTodayOverviewPage(
+            Page<Map<String, Object>> page,
+            String category,
+            String warningType,
+            String warningLevel,
+            String keyword);
+
+    /**
+     * 历史预警概况
+     * <p>
+     * 以城市设施运行的历史预警事件累计维度,展示各专项预警总体数量和分布情况,
+     * 支持对具体预警事件进行详情查看。
+     *
+     * @return { totalCount, specialStats: [{specialName, count, percentage}], detailList }
+     */
+    Map<String, Object> getHistoryOverview();
+
+    /**
+     * Query historical warning details with database pagination.
+     */
+    IPage<Map<String, Object>> getHistoryWarningPage(
+            Page<Map<String, Object>> page,
+            String warningType,
+            String warningSpecial,
+            String keyword);
+
+    /**
+     * 今日未处置预警
+     * <p>
+     * 针对截至当前仍未及时处置的预警事件进行集中展示,让监管者重点关注。
+     *
+     * @return { total, list: [{warningId, warningName, warningType, warningLevel, warningSpecial,
+     *          location, ownershipUnit, publisher, publishTime, status, warningContent,
+     *          todoInfo: {userName, taskName, createTime}}] }
+     */
+    Map<String, Object> getTodayUnprocessed(
+            Page<Map<String, Object>> page,
+            String warningType,
+            String warningLevel,
+            String keyword);
+
+    /**
+     * Backward-compatible unpaged form for existing callers.
+     */
+    default Map<String, Object> getTodayUnprocessed() {
+        return getTodayUnprocessed(new Page<>(1, 100), null, null, null);
+    }
+}

+ 10 - 7
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningDisposeEfficiencyServiceImpl.java

@@ -28,8 +28,12 @@ public class EarlyWarningDisposeEfficiencyServiceImpl implements IEarlyWarningDi
     }
 
     @Override
-    public List<Map<String, Object>> getEfficiencyBySpecial(LocalDateTime startTime, LocalDateTime endTime) {
-        return earlyWarningMapper.selectDisposeEfficiencyBySpecial(startTime, endTime);
+    public List<Map<String, Object>> getEfficiencyBySpecial(
+            LocalDateTime startTime,
+            LocalDateTime endTime,
+            String warningSpecial) {
+        return earlyWarningMapper.selectDisposeEfficiencyBySpecial(
+                startTime, endTime, warningSpecial);
     }
 
     @Override
@@ -38,9 +42,8 @@ public class EarlyWarningDisposeEfficiencyServiceImpl implements IEarlyWarningDi
                                                           LocalDateTime endTime,
                                                           String warningSpecial,
                                                           String sortType) {
-        List<Map<String, Object>> list = earlyWarningMapper.selectDisposeEfficiencyList(startTime, endTime, warningSpecial, sortType);
-        page.setRecords(list);
-        page.setTotal(list.size());
-        return page;
+        String safeSortType = "asc".equalsIgnoreCase(sortType) ? "asc" : "desc";
+        return earlyWarningMapper.selectDisposeEfficiencyList(
+                page, startTime, endTime, warningSpecial, safeSortType);
     }
-}
+}

+ 8 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningDisposeStatusServiceImpl.java

@@ -21,4 +21,11 @@ public class EarlyWarningDisposeStatusServiceImpl implements IEarlyWarningDispos
                                                           String warningSpecial) {
         return earlyWarningMapper.selectWarningGroupByStatus(startTime, endTime, warningSpecial);
     }
-}
+
+    @Override
+    public List<Map<String, Object>> getDisposalTypeStat(LocalDateTime startTime,
+                                                         LocalDateTime endTime,
+                                                         String warningSpecial) {
+        return earlyWarningMapper.selectDisposalGroupByType(startTime, endTime, warningSpecial);
+    }
+}

+ 7 - 8
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningReceiveEfficiencyServiceImpl.java

@@ -36,8 +36,9 @@ public class EarlyWarningReceiveEfficiencyServiceImpl implements IEarlyWarningRe
     }
 
     @Override
-    public List<Map<String, Object>> getEfficiencyByType(LocalDateTime startTime, LocalDateTime endTime) {
-        return earlyWarningMapper.selectReceiveEfficiencyByType(startTime, endTime);
+    public List<Map<String, Object>> getEfficiencyByType(LocalDateTime startTime, LocalDateTime endTime,
+                                                         String warningType, String warningSpecial, String status) {
+        return earlyWarningMapper.selectReceiveEfficiencyByType(startTime, endTime, warningType, warningSpecial, status);
     }
 
     @Override
@@ -48,10 +49,8 @@ public class EarlyWarningReceiveEfficiencyServiceImpl implements IEarlyWarningRe
                                                           String warningSpecial,
                                                           String status,
                                                           String sortType) {
-        List<Map<String, Object>> list = earlyWarningMapper.selectReceiveEfficiencyList(
-                startTime, endTime, warningType, warningSpecial, status, sortType);
-        page.setRecords(list);
-        page.setTotal(list.size());
-        return page;
+        String safeSortType = "asc".equalsIgnoreCase(sortType) ? "asc" : "desc";
+        return earlyWarningMapper.selectReceiveEfficiencyList(
+                page, startTime, endTime, warningType, warningSpecial, status, safeSortType);
     }
-}
+}

+ 202 - 10
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningServiceImpl.java

@@ -9,6 +9,7 @@ import com.zksy.base.warning.mapper.*;
 import com.zksy.base.warning.service.EarlyWarningService;
 import com.zksy.common.core.domain.entity.SysDictData;
 import com.zksy.common.utils.DictUtils;
+import com.zksy.common.utils.SecurityUtils;
 import com.zksy.service.MinioFileStorageService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
@@ -34,6 +35,9 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Autowired
     private WarningTodoMapper warningTodoMapper;
 
+    @Autowired
+    private WarningSupervisionMapper warningSupervisionMapper;
+
     @Autowired
     private WarningArchiveMapper warningArchiveMapper;
     @Autowired
@@ -45,8 +49,8 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     }
 
     @Override
-    public IPage<Map<String, Object>> searchWarnings(Page<?> page, String warningName, String warningNo, String warningType, String warningLevel, String status, String location, String publisher, String ownershipUnit, LocalDateTime startTime, LocalDateTime endTime, String excludePublisher) {
-        return earlyWarningMapper.searchWarnings(page, warningName, warningNo, warningType, warningLevel, status, location, publisher, ownershipUnit, startTime, endTime, excludePublisher);
+    public IPage<Map<String, Object>> searchWarnings(Page<?> page, String warningName, String warningNo, String warningType, String warningLevel, String status, String location, String publisher, String ownershipUnit, LocalDateTime startTime, LocalDateTime endTime, String excludePublisher, String deviceCode, String deviceName) {
+        return earlyWarningMapper.searchWarnings(page, warningName, warningNo, warningType, warningLevel, status, location, publisher, ownershipUnit, startTime, endTime, excludePublisher, deviceCode, deviceName);
     }
 
     @Override
@@ -77,6 +81,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         result.put("gisLocation", getGisLocation(basicInfo));
         result.put("disposalHistory", getDisposalHistory(warningId));
         result.put("attachmentList", getAttachmentList(warningId));
+        result.put("supervisionList", getSupervisionList(warningId));
         result.put("alarmList", getLinkedAlarms(warningId));
         result.put("electronicArchive", generateElectronicArchive(basicInfo));
 
@@ -123,7 +128,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         archive.setLatitude(warningInfo.get("latitude") != null ? ((Number) warningInfo.get("latitude")).doubleValue() : null);
         archive.setOwnershipUnit((String) warningInfo.get("ownership_unit"));
         archive.setPublisher((String) warningInfo.get("publisher"));
-        archive.setPublishTime(warningInfo.get("publish_time") instanceof LocalDateTime ? (LocalDateTime) warningInfo.get("publishTime") : null);
+        archive.setPublishTime(warningInfo.get("publish_time") instanceof LocalDateTime ? (LocalDateTime) warningInfo.get("publish_time") : null);
         archive.setHandler((String) warningInfo.get("handler"));
         archive.setWarningContent((String) warningInfo.get("warning_content"));
         archive.setArchiveContent((String) warningInfo.get("warning_content"));
@@ -156,7 +161,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
                 linkAlarms(warning.getWarningId(), alarmIds);
             }
             if (files != null && !files.isEmpty()) {
-                saveAttachments(warning.getWarningId(), files);
+              saveAttachments(warning.getWarningId(), null, "REPORT", files);
             }
         }
         return result;
@@ -166,15 +171,23 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
      * 保存附件
      */
     private void saveAttachments(String warningId, List<MultipartFile> files) {
+        saveAttachments(warningId, null, "REPORT", files);
+    }
+
+    private void saveAttachments(String warningId, String disposalId, String stage, List<MultipartFile> files) {
         try {
             for (MultipartFile file : files) {
+                validateAttachment(file);
                 String path = minioFileStorageService.uploadFile(file, "warning");
                 // 上传文件
-                String fileName = path.substring(path.lastIndexOf("/") + 1);
+                String fileName = file.getOriginalFilename();
+                if (fileName == null || fileName.trim().isEmpty()) {
+                    fileName = path.substring(path.lastIndexOf("/") + 1);
+                }
                 // 保存附件信息
                 WarningAttachment attachment = new WarningAttachment();
                 attachment.setWarningId(warningId);
-                attachment.setAttachmentName(fileName);
+                attachment.setAttachmentName(("PROCESS".equals(stage) ? "[处理反馈] " : "[预警报告] ") + fileName);
                 attachment.setAttachmentUrl(path);
                 // 部分 MIME(如 vnd.openxmlformats-officedocument.*)长度超过 64,写入前按 DB 列宽截断兜底
                 attachment.setAttachmentType(truncateAttachmentType(file.getContentType()));
@@ -197,7 +210,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
                 linkAlarms(warning.getWarningId(), alarmIds);
             }
             if (files != null && !files.isEmpty()) {
-                saveAttachments(warning.getWarningId(), files);
+                saveAttachments(warning.getWarningId(), null, "REPORT", files);
             }
         }
         return result;
@@ -240,6 +253,10 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         archiveWrapper.eq(WarningArchive::getWarningId, warningId);
         warningArchiveMapper.delete(archiveWrapper);
 
+        LambdaQueryWrapper<WarningSupervision> supervisionWrapper = new LambdaQueryWrapper<>();
+        supervisionWrapper.eq(WarningSupervision::getWarningId, warningId);
+        warningSupervisionMapper.delete(supervisionWrapper);
+
         // 删除主表记录
         return removeById(warningId);
     }
@@ -266,13 +283,112 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         boolean result = updateById(warning);
 
         if (result) {
-            addDisposalRecord(warningId, "RELEASE", null, null, warning.getHandler(), "系统发布预警信息");
-            createTodo(warningId, warning.getHandler(), "待办预警", "TODO");
+            String assignee = isBlank(warning.getHandler()) ? currentUser() : warning.getHandler();
+            warning.setHandler(assignee);
+            updateById(warning);
+            addDisposalRecord(warningId, "RELEASE", null, null, assignee, "系统发布预警信息");
+            createTodo(warningId, assignee, "待办预警", "TODO");
         }
 
         return result;
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean confirmWarning(String warningId, String assignedUser, String confirmRemark) {
+        EarlyWarning warning = getById(warningId);
+        if (warning == null || "CLOSED".equals(warning.getStatus())) return false;
+        LocalDateTime now = LocalDateTime.now();
+        String operator = currentUser();
+        String assignee = isBlank(assignedUser) ? warning.getHandler() : assignedUser;
+        if (isBlank(assignee)) assignee = operator;
+        warning.setStatus("PROCESSING");
+        warning.setHandler(assignee);
+        warning.setPublishTime(warning.getPublishTime() == null ? now : warning.getPublishTime());
+        warning.setUpdateTime(now);
+        if (!updateById(warning)) return false;
+        addDisposalRecord(warningId, "CONFIRM", null, null, operator, confirmRemark);
+        createTodo(warningId, assignee, "预警处理", "TODO", "PROCESS");
+        return true;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean misreportWarning(String warningId, String reason) {
+        EarlyWarning warning = getById(warningId);
+        if (warning == null) return false;
+        LocalDateTime now = LocalDateTime.now();
+        String operator = currentUser();
+        warning.setStatus("CLOSED");
+        warning.setRemark(reason);
+        warning.setUpdateTime(now);
+        if (!updateById(warning)) return false;
+        addDisposalRecord(warningId, "MISREPORT", null, null, operator, reason);
+        completeTodo(warningId);
+        generateArchive(warningId);
+        return true;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean submitWarningProcess(String warningId, String processContent, List<MultipartFile> files) {
+        EarlyWarning warning = getById(warningId);
+        if (warning == null || "CLOSED".equals(warning.getStatus())) return false;
+        LocalDateTime now = LocalDateTime.now();
+        String operator = currentUser();
+        warning.setStatus("HANDLED");
+        warning.setRemark(processContent);
+        warning.setUpdateTime(now);
+        if (!updateById(warning)) return false;
+        WarningDisposal disposal = new WarningDisposal();
+        disposal.setWarningId(warningId);
+        disposal.setDisposalType("HANDLE");
+        disposal.setDisposalUser(operator);
+        disposal.setDisposalContent(processContent);
+        disposal.setCreateTime(now);
+        warningDisposalMapper.insert(disposal);
+        if (files != null && !files.isEmpty()) {
+            saveAttachments(warningId, disposal.getDisposalId(), "PROCESS", files);
+        }
+        completeTodo(warningId);
+        return true;
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public boolean clearWarning(String warningId, String clearRemark) {
+        EarlyWarning warning = getById(warningId);
+        if (warning == null || "CLOSED".equals(warning.getStatus())) return false;
+        LocalDateTime now = LocalDateTime.now();
+        String operator = currentUser();
+        warning.setStatus("CLOSED");
+        warning.setRemark(clearRemark);
+        warning.setUpdateTime(now);
+        if (!updateById(warning)) return false;
+        addDisposalRecord(warningId, "CLEAR", null, null, operator, clearRemark);
+        completeTodo(warningId);
+        generateArchive(warningId);
+        return true;
+    }
+
+    private void generateArchive(String warningId) {
+        Map<String, Object> detail = earlyWarningMapper.selectWarningDetail(warningId);
+        if (detail != null) generateElectronicArchive(detail);
+    }
+
+    private String currentUser() {
+        try {
+            String username = SecurityUtils.getUsername();
+            return isBlank(username) ? "system" : username;
+        } catch (Exception ex) {
+            return "system";
+        }
+    }
+
+    private boolean isBlank(String value) {
+        return value == null || value.trim().isEmpty();
+    }
+
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean upgradeWarning(String warningId, String newLevel, String disposalContent) {
@@ -336,6 +452,16 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean supervisionWarning(String warningId, String supervisionUser, String supervisionContent, String notificationType, String notificationContent, String attachmentUrl) {
+        WarningSupervision supervision = new WarningSupervision();
+        supervision.setWarningId(warningId);
+        supervision.setSupervisionUser(supervisionUser);
+        supervision.setSupervisionContent(supervisionContent);
+        supervision.setNotificationType(notificationType);
+        supervision.setNotificationContent(notificationContent);
+        supervision.setAttachmentUrl(attachmentUrl);
+        supervision.setCreateBy(currentUser());
+        supervision.setCreateTime(LocalDateTime.now());
+        warningSupervisionMapper.insert(supervision);
         WarningDisposal disposal = new WarningDisposal();
         disposal.setWarningId(warningId);
         disposal.setDisposalType("SUPERVISION");
@@ -517,7 +643,7 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         disposal.setDisposalType(disposalType);
         disposal.setOldLevel(oldLevel);
         disposal.setNewLevel(newLevel);
-        disposal.setDisposalUser(disposalUser);
+        disposal.setDisposalUser(isBlank(disposalUser) ? currentUser() : disposalUser);
         disposal.setDisposalContent(content);
         disposal.setCreateTime(LocalDateTime.now());
         warningDisposalMapper.insert(disposal);
@@ -539,12 +665,23 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
             map.put("attachmentType", attachment.getAttachmentType());
             map.put("attachmentSize", attachment.getAttachmentSize());
             map.put("createTime", attachment.getCreateTime());
+            String attachmentName = attachment.getAttachmentName();
+            map.put("attachmentStage", attachmentName != null && attachmentName.startsWith("[处理反馈]") ? "PROCESS" : "REPORT");
             result.add(map);
         }
         return result;
     }
 
     private void createTodo(String warningId, String userId, String taskName, String todoType) {
+        createTodo(warningId, userId, taskName, todoType, todoType);
+    }
+
+    @Override
+    public WarningAttachment getAttachment(String attachmentId) {
+        return warningAttachmentMapper.selectById(attachmentId);
+    }
+
+    private void createTodo(String warningId, String userId, String taskName, String todoType, String actionType) {
         WarningTodo todo = new WarningTodo();
         todo.setWarningId(warningId);
         todo.setUserId(userId);
@@ -556,6 +693,11 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         warningTodoMapper.insert(todo);
     }
 
+    @Override
+    public List<WarningSupervision> getSupervisionList(String warningId) {
+        return warningSupervisionMapper.selectByWarningId(warningId);
+    }
+
     private void completeTodo(String warningId) {
         LambdaQueryWrapper<WarningTodo> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(WarningTodo::getWarningId, warningId)
@@ -787,4 +929,54 @@ public class EarlyWarningServiceImpl extends ServiceImpl<EarlyWarningMapper, Ear
         }
         return contentType.substring(0, ATTACHMENT_TYPE_MAX_LEN);
     }
+
+    private static final Set<String> ALLOWED_ATTACHMENT_EXTENSIONS = new HashSet<>(Arrays.asList(
+            "jpg", "jpeg", "png", "gif", "webp", "pdf", "xls", "xlsx", "doc", "docx"
+    ));
+
+    private void validateAttachment(MultipartFile file) {
+        if (file == null || file.isEmpty()) {
+            throw new IllegalArgumentException("上传文件不能为空");
+        }
+        String originalName = file.getOriginalFilename();
+        String extension = "";
+        if (originalName != null && originalName.lastIndexOf('.') >= 0) {
+            extension = originalName.substring(originalName.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
+        }
+        if (!ALLOWED_ATTACHMENT_EXTENSIONS.contains(extension)) {
+            throw new IllegalArgumentException("仅支持图片、PDF、Excel、Word文件");
+        }
+        String contentType = file.getContentType();
+        if (contentType != null && !contentType.trim().isEmpty()
+                && !"application/octet-stream".equalsIgnoreCase(contentType)
+                && !isCompatibleAttachmentType(extension, contentType)) {
+            throw new IllegalArgumentException("附件类型与文件扩展名不匹配");
+        }
+        if (file.getSize() > 20 * 1024 * 1024L) {
+            throw new IllegalArgumentException("单个附件不能超过20MB");
+        }
+    }
+
+    private boolean isCompatibleAttachmentType(String extension, String contentType) {
+        String type = contentType.toLowerCase(Locale.ROOT);
+        if ("jpg".equals(extension) || "jpeg".equals(extension) || "png".equals(extension)
+                || "gif".equals(extension) || "webp".equals(extension)) {
+            return type.startsWith("image/");
+        }
+        if ("pdf".equals(extension)) {
+            return "application/pdf".equals(type);
+        }
+        if ("xls".equals(extension)) {
+            return "application/vnd.ms-excel".equals(type) || "application/octet-stream".equals(type);
+        }
+        if ("xlsx".equals(extension)) {
+            return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".equals(type)
+                    || "application/zip".equals(type) || "application/octet-stream".equals(type);
+        }
+        if ("doc".equals(extension)) {
+            return "application/msword".equals(type) || "application/octet-stream".equals(type);
+        }
+        return "application/vnd.openxmlformats-officedocument.wordprocessingml.document".equals(type)
+                || "application/zip".equals(type) || "application/octet-stream".equals(type);
+    }
 }

+ 2 - 6
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/EarlyWarningTodayServiceImpl.java

@@ -8,7 +8,6 @@ import com.zksy.base.warning.service.IEarlyWarningTodayService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
-import java.util.List;
 import java.util.Map;
 
 @Service
@@ -24,9 +23,6 @@ public class EarlyWarningTodayServiceImpl implements IEarlyWarningTodayService {
 
     @Override
     public IPage<EarlyWarning> queryTodayWarningPage(Page<EarlyWarning> page, String status) {
-        List<EarlyWarning> list = earlyWarningMapper.selectTodayWarningList(status);
-        page.setRecords(list);
-        page.setTotal(list.size());
-        return page;
+        return earlyWarningMapper.selectTodayWarningList(page, status);
     }
-}
+}

+ 305 - 0
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/WarningDashboardServiceImpl.java

@@ -0,0 +1,305 @@
+package com.zksy.base.warning.service.impl;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.zksy.base.warning.mapper.EarlyWarningMapper;
+import com.zksy.base.warning.service.WarningDashboardService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Database-backed warning overview service.
+ *
+ * The overview deliberately excludes drafts and warning types outside the four
+ * city-life facilities. Aggregation and pagination stay in SQL so the dashboard
+ * does not load the complete warning table into application memory.
+ */
+@Slf4j
+@Service
+public class WarningDashboardServiceImpl implements WarningDashboardService {
+
+    static final String CATEGORY_UNRESOLVED = "unresolved";
+    static final String CATEGORY_RESOLVED = "resolved";
+    static final String CATEGORY_ALL = "all";
+    private static final int PREVIEW_LIMIT = 8;
+    private static final DateTimeFormatter DATE_TIME_FORMATTER =
+            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+    @Autowired
+    private EarlyWarningMapper earlyWarningMapper;
+
+    @Override
+    public Map<String, Object> getTodayOverview() {
+        Map<String, Object> summary = nullToEmpty(earlyWarningMapper.selectDashboardTodaySummary());
+        List<Map<String, Object>> unresolved = normalizeRecords(
+                earlyWarningMapper.selectDashboardTodayPreview(CATEGORY_UNRESOLVED, PREVIEW_LIMIT));
+        List<Map<String, Object>> resolved = normalizeRecords(
+                earlyWarningMapper.selectDashboardTodayPreview(CATEGORY_RESOLVED, PREVIEW_LIMIT));
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("todayTotal", numberValue(summary, "today_total", "todayTotal"));
+        result.put("unresolvedCount", numberValue(summary, "unresolved_count", "unresolvedCount"));
+        result.put("resolvedCount", numberValue(summary, "resolved_count", "resolvedCount"));
+        result.put("unresolvedList", unresolved);
+        result.put("resolvedList", resolved);
+        result.put("previewLimit", PREVIEW_LIMIT);
+        result.put("generatedAt", LocalDateTime.now().format(DATE_TIME_FORMATTER));
+        return result;
+    }
+
+    @Override
+    public IPage<Map<String, Object>> getTodayOverviewPage(
+            Page<Map<String, Object>> page,
+            String category,
+            String warningType,
+            String warningLevel,
+            String keyword) {
+        String safeCategory = normalizeCategory(category);
+        IPage<Map<String, Object>> result = earlyWarningMapper.selectDashboardTodayPage(
+                page,
+                safeCategory,
+                trimToNull(warningType),
+                trimToNull(warningLevel),
+                trimToNull(keyword));
+        if (result == null) {
+            result = page;
+            result.setTotal(0);
+        }
+        result.setRecords(normalizeRecords(result.getRecords()));
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> getHistoryOverview() {
+        Map<String, Object> summary = nullToEmpty(earlyWarningMapper.selectDashboardHistorySummary());
+        List<Map<String, Object>> rawSpecialStats =
+                earlyWarningMapper.selectDashboardHistorySpecialStats(null);
+        List<Map<String, Object>> specialStats = new ArrayList<>();
+        if (rawSpecialStats != null) {
+            for (Map<String, Object> row : rawSpecialStats) {
+                Map<String, Object> item = new LinkedHashMap<>();
+                item.put("specialName", stringValue(row, "special_name", "specialName"));
+                item.put("count", numberValue(row, "warning_count", "warningCount", "count"));
+                item.put("percentage", decimalValue(row, "percentage"));
+                specialStats.add(item);
+            }
+        }
+
+        IPage<Map<String, Object>> previewPage = earlyWarningMapper.selectDashboardHistoryPage(
+                new Page<>(1, PREVIEW_LIMIT), null, null, null);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("totalCount", numberValue(summary, "total_count", "totalCount"));
+        result.put("activeCount", numberValue(summary, "active_count", "activeCount"));
+        result.put("resolvedCount", numberValue(summary, "resolved_count", "resolvedCount"));
+        result.put("specialStats", specialStats);
+        result.put("detailList", normalizeRecords(previewPage == null ? null : previewPage.getRecords()));
+        result.put("previewLimit", PREVIEW_LIMIT);
+        result.put("generatedAt", LocalDateTime.now().format(DATE_TIME_FORMATTER));
+        return result;
+    }
+
+    @Override
+    public IPage<Map<String, Object>> getHistoryWarningPage(
+            Page<Map<String, Object>> page,
+            String warningType,
+            String warningSpecial,
+            String keyword) {
+        IPage<Map<String, Object>> result = earlyWarningMapper.selectDashboardHistoryPage(
+                page,
+                trimToNull(warningType),
+                trimToNull(warningSpecial),
+                trimToNull(keyword));
+        if (result == null) {
+            result = page;
+            result.setTotal(0);
+        }
+        result.setRecords(normalizeRecords(result.getRecords()));
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> getTodayUnprocessed(
+            Page<Map<String, Object>> page,
+            String warningType,
+            String warningLevel,
+            String keyword) {
+        IPage<Map<String, Object>> resultPage = earlyWarningMapper.selectDashboardUnprocessedPage(
+                page,
+                trimToNull(warningType),
+                trimToNull(warningLevel),
+                trimToNull(keyword));
+        if (resultPage == null) {
+            resultPage = page;
+            resultPage.setTotal(0);
+        }
+        List<Map<String, Object>> records = normalizeRecords(resultPage.getRecords());
+        resultPage.setRecords(records);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("total", resultPage.getTotal());
+        result.put("list", records);
+        result.put("records", records);
+        result.put("pageNum", resultPage.getCurrent());
+        result.put("current", resultPage.getCurrent());
+        result.put("pageSize", resultPage.getSize());
+        result.put("size", resultPage.getSize());
+        result.put("pages", resultPage.getPages());
+        Map<String, Object> summary = nullToEmpty(earlyWarningMapper.selectDashboardUnprocessedSummary(
+                trimToNull(warningType), trimToNull(warningLevel), trimToNull(keyword)));
+        result.put("ownershipCount", numberValue(summary, "ownership_count", "ownershipCount"));
+        result.put("highestLevel", numberValue(summary, "highest_level", "highestLevel"));
+        result.put("generatedAt", LocalDateTime.now().format(DATE_TIME_FORMATTER));
+        return result;
+    }
+
+    private String normalizeCategory(String category) {
+        String value = trimToNull(category);
+        if (value == null) {
+            return CATEGORY_UNRESOLVED;
+        }
+        value = value.toLowerCase(Locale.ROOT);
+        if (!CATEGORY_UNRESOLVED.equals(value) && !CATEGORY_RESOLVED.equals(value)
+                && !CATEGORY_ALL.equals(value)) {
+            throw new IllegalArgumentException("category仅支持unresolved、resolved或all");
+        }
+        return value;
+    }
+
+    private List<Map<String, Object>> normalizeRecords(List<Map<String, Object>> records) {
+        if (records == null || records.isEmpty()) {
+            return Collections.emptyList();
+        }
+        List<Map<String, Object>> result = new ArrayList<>(records.size());
+        for (Map<String, Object> record : records) {
+            result.add(normalizeRecord(record));
+        }
+        return result;
+    }
+
+    private Map<String, Object> normalizeRecord(Map<String, Object> source) {
+        Map<String, Object> record = new LinkedHashMap<>();
+        if (source != null) {
+            record.putAll(source);
+        }
+        putAlias(record, source, "warningId", "warning_id");
+        putAlias(record, source, "warningNo", "warning_no");
+        putAlias(record, source, "warningName", "warning_name");
+        putAlias(record, source, "warningType", "warning_type");
+        putAlias(record, source, "warningTypeName", "warning_type_name");
+        putAlias(record, source, "warningLevel", "warning_level");
+        putAlias(record, source, "warningSpecial", "warning_special");
+        putAlias(record, source, "ownershipUnit", "ownership_unit");
+        putAlias(record, source, "publishTime", "publish_time");
+        putAlias(record, source, "warningContent", "warning_content");
+        putAlias(record, source, "createTime", "create_time");
+        putAlias(record, source, "updateTime", "update_time");
+        putAlias(record, source, "deviceCode", "device_code");
+        putAlias(record, source, "deviceName", "device_name");
+        putAlias(record, source, "resolvedTime", "resolved_time");
+        putAlias(record, source, "waitingHours", "waiting_hours");
+
+        Object todoId = value(source, "todo_id", "todoId");
+        if (todoId != null) {
+            Map<String, Object> todoInfo = new LinkedHashMap<>();
+            todoInfo.put("todoId", todoId);
+            todoInfo.put("userId", value(source, "todo_user_id", "todoUserId"));
+            todoInfo.put("userName", value(source, "todo_user_name", "todoUserName"));
+            todoInfo.put("todoType", value(source, "todo_type", "todoType"));
+            todoInfo.put("readStatus", value(source, "read_status", "readStatus"));
+            todoInfo.put("taskName", value(source, "task_name", "taskName"));
+            todoInfo.put("createTime", value(source, "todo_create_time", "todoCreateTime"));
+            record.put("todoInfo", todoInfo);
+        }
+        return record;
+    }
+
+    private void putAlias(
+            Map<String, Object> target,
+            Map<String, Object> source,
+            String alias,
+            String... names) {
+        Object found = value(source, names);
+        if (found != null) {
+            target.put(alias, found);
+        }
+    }
+
+    private Map<String, Object> nullToEmpty(Map<String, Object> value) {
+        return value == null ? Collections.emptyMap() : value;
+    }
+
+    private long numberValue(Map<String, Object> source, String... names) {
+        Object found = value(source, names);
+        if (found instanceof Number) {
+            return ((Number) found).longValue();
+        }
+        if (found != null) {
+            try {
+                return new BigDecimal(found.toString()).longValue();
+            } catch (NumberFormatException ignored) {
+                log.debug("Unable to parse dashboard count: {}", found);
+            }
+        }
+        return 0L;
+    }
+
+    private BigDecimal decimalValue(Map<String, Object> source, String... names) {
+        Object found = value(source, names);
+        if (found instanceof BigDecimal) {
+            return (BigDecimal) found;
+        }
+        if (found instanceof Number) {
+            return BigDecimal.valueOf(((Number) found).doubleValue());
+        }
+        if (found != null) {
+            try {
+                return new BigDecimal(found.toString());
+            } catch (NumberFormatException ignored) {
+                log.debug("Unable to parse dashboard decimal: {}", found);
+            }
+        }
+        return BigDecimal.ZERO;
+    }
+
+    private String stringValue(Map<String, Object> source, String... names) {
+        Object found = value(source, names);
+        return found == null ? "" : found.toString();
+    }
+
+    private Object value(Map<String, Object> source, String... names) {
+        if (source == null || source.isEmpty()) {
+            return null;
+        }
+        for (String name : names) {
+            if (source.containsKey(name)) {
+                return source.get(name);
+            }
+            for (Map.Entry<String, Object> entry : source.entrySet()) {
+                if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(name)) {
+                    return entry.getValue();
+                }
+            }
+        }
+        return null;
+    }
+
+    private String trimToNull(String value) {
+        if (value == null) {
+            return null;
+        }
+        String trimmed = value.trim();
+        return trimmed.isEmpty() ? null : trimmed;
+    }
+}

+ 64 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/WarningItemServiceImpl.java

@@ -12,12 +12,19 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
+import java.util.Arrays;
 import java.util.Date;
+import java.util.HashSet;
+import java.util.Set;
 
 @Slf4j
 @Service
 public class WarningItemServiceImpl extends ServiceImpl<WarningItemMapper, WarningItem> implements WarningItemService {
 
+    private static final Set<String> ALLOWED_STATUSES =
+            new HashSet<>(Arrays.asList("ENABLED", "DISABLED", "INVALID"));
+
     @Override
     public IPage<WarningItem> queryWarningItems(Page<WarningItem> page, String itemCode, String itemName, 
                                                  String warningType, String warningLevel, String warningSpecial, String status) {
@@ -49,27 +56,45 @@ public class WarningItemServiceImpl extends ServiceImpl<WarningItemMapper, Warni
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean addWarningItem(WarningItem warningItem) {
+        validateWarningItem(warningItem, false);
         if (warningItem.getItemCode() == null || warningItem.getItemCode().isEmpty()) {
             warningItem.setItemCode(generateItemCode(warningItem.getWarningType(), warningItem.getWarningLevel()));
         }
+        if (getItemByCode(warningItem.getItemCode()) != null) {
+            throw new IllegalArgumentException("事项编码已存在:" + warningItem.getItemCode());
+        }
         if (warningItem.getStatus() == null) {
             warningItem.setStatus("ENABLED");
         }
+        warningItem.setCreateTime(LocalDateTime.now());
         return this.save(warningItem);
     }
 
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean updateWarningItem(WarningItem warningItem) {
+        validateWarningItem(warningItem, true);
+        WarningItem sameCodeItem = getItemByCode(warningItem.getItemCode());
+        if (sameCodeItem != null && !sameCodeItem.getItemId().equals(warningItem.getItemId())) {
+            throw new IllegalArgumentException("事项编码已存在:" + warningItem.getItemCode());
+        }
+        warningItem.setUpdateTime(LocalDateTime.now());
         return this.updateById(warningItem);
     }
 
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean updateItemStatus(String itemId, String status) {
+        if (itemId == null || itemId.trim().isEmpty()) {
+            throw new IllegalArgumentException("事项ID不能为空");
+        }
+        if (!ALLOWED_STATUSES.contains(status)) {
+            throw new IllegalArgumentException("不支持的事项状态:" + status);
+        }
         WarningItem item = new WarningItem();
         item.setItemId(itemId);
         item.setStatus(status);
+        item.setUpdateTime(LocalDateTime.now());
         return this.updateById(item);
     }
 
@@ -87,6 +112,14 @@ public class WarningItemServiceImpl extends ServiceImpl<WarningItemMapper, Warni
 
     @Override
     public String generateItemCode(String warningType, String warningLevel) {
+        if (warningType == null || warningType.trim().isEmpty()) {
+            throw new IllegalArgumentException("预警类型不能为空");
+        }
+        if (warningLevel == null || warningLevel.trim().isEmpty()) {
+            throw new IllegalArgumentException("预警级别不能为空");
+        }
+        warningType = warningType.trim();
+        warningLevel = warningLevel.trim();
         String dateStr = new SimpleDateFormat("yyyyMMdd").format(new Date());
         String prefix = "WI" + warningType.substring(0, Math.min(3, warningType.length())).toUpperCase() + warningLevel;
         
@@ -100,14 +133,40 @@ public class WarningItemServiceImpl extends ServiceImpl<WarningItemMapper, Warni
         
         if (lastItem != null) {
             String lastCode = lastItem.getItemCode();
-            String lastSeqStr = lastCode.substring(lastCode.length() - 4);
-            try {
-                sequence = Integer.parseInt(lastSeqStr) + 1;
-            } catch (NumberFormatException e) {
-                sequence = 1;
+            if (lastCode != null && lastCode.startsWith(prefix + dateStr) && lastCode.length() >= 4) {
+                String lastSeqStr = lastCode.substring(lastCode.length() - 4);
+                try {
+                    sequence = Integer.parseInt(lastSeqStr) + 1;
+                } catch (NumberFormatException ignored) {
+                    sequence = 1;
+                }
             }
         }
         
         return prefix + dateStr + String.format("%04d", sequence);
     }
+
+    private void validateWarningItem(WarningItem warningItem, boolean update) {
+        if (warningItem == null) {
+            throw new IllegalArgumentException("预警事项不能为空");
+        }
+        if (update && (warningItem.getItemId() == null || warningItem.getItemId().trim().isEmpty())) {
+            throw new IllegalArgumentException("事项ID不能为空");
+        }
+        if (warningItem.getItemName() == null || warningItem.getItemName().trim().isEmpty()) {
+            throw new IllegalArgumentException("事项名称不能为空");
+        }
+        if (warningItem.getWarningType() == null || warningItem.getWarningType().trim().isEmpty()) {
+            throw new IllegalArgumentException("预警类型不能为空");
+        }
+        if (warningItem.getWarningLevel() == null || warningItem.getWarningLevel().trim().isEmpty()) {
+            throw new IllegalArgumentException("预警级别不能为空");
+        }
+        if (warningItem.getDuration() != null && warningItem.getDuration() <= 0) {
+            throw new IllegalArgumentException("预警时长必须大于0");
+        }
+        if (warningItem.getStatus() != null && !ALLOWED_STATUSES.contains(warningItem.getStatus())) {
+            throw new IllegalArgumentException("不支持的事项状态:" + warningItem.getStatus());
+        }
+    }
 }

+ 72 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/base/warning/service/impl/WarningReasonServiceImpl.java

@@ -12,13 +12,19 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.text.SimpleDateFormat;
+import java.time.LocalDateTime;
+import java.util.Arrays;
 import java.util.Date;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 @Slf4j
 @Service
 public class WarningReasonServiceImpl extends ServiceImpl<WarningReasonMapper, WarningReason> implements WarningReasonService {
 
+    private static final Set<String> ALLOWED_STATUSES = new HashSet<>(Arrays.asList("0", "1"));
+
     @Override
     public IPage<WarningReason> queryWarningReasons(Page<WarningReason> page, String reasonCode, String reasonName, 
                                                      String warningType, String warningLevel, String status) {
@@ -57,30 +63,48 @@ public class WarningReasonServiceImpl extends ServiceImpl<WarningReasonMapper, W
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean addWarningReason(WarningReason warningReason) {
+        validateWarningReason(warningReason, false);
         if (warningReason.getReasonCode() == null || warningReason.getReasonCode().isEmpty()) {
             warningReason.setReasonCode(generateReasonCode(warningReason.getWarningType(), warningReason.getWarningLevel()));
         }
+        if (getReasonByCode(warningReason.getReasonCode()) != null) {
+            throw new IllegalArgumentException("原因编码已存在:" + warningReason.getReasonCode());
+        }
         if (warningReason.getStatus() == null) {
             warningReason.setStatus("0");
         }
         if (warningReason.getSortOrder() == null) {
             warningReason.setSortOrder(0);
         }
+        warningReason.setCreateTime(LocalDateTime.now());
         return this.save(warningReason);
     }
 
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean updateWarningReason(WarningReason warningReason) {
+        validateWarningReason(warningReason, true);
+        WarningReason sameCodeReason = getReasonByCode(warningReason.getReasonCode());
+        if (sameCodeReason != null && !sameCodeReason.getReasonId().equals(warningReason.getReasonId())) {
+            throw new IllegalArgumentException("原因编码已存在:" + warningReason.getReasonCode());
+        }
+        warningReason.setUpdateTime(LocalDateTime.now());
         return this.updateById(warningReason);
     }
 
     @Override
     @Transactional(rollbackFor = Exception.class)
     public boolean updateReasonStatus(String reasonId, String status) {
+        if (reasonId == null || reasonId.trim().isEmpty()) {
+            throw new IllegalArgumentException("原因ID不能为空");
+        }
+        if (!ALLOWED_STATUSES.contains(status)) {
+            throw new IllegalArgumentException("不支持的原因状态:" + status);
+        }
         WarningReason reason = new WarningReason();
         reason.setReasonId(reasonId);
         reason.setStatus(status);
+        reason.setUpdateTime(LocalDateTime.now());
         return this.updateById(reason);
     }
 
@@ -90,6 +114,14 @@ public class WarningReasonServiceImpl extends ServiceImpl<WarningReasonMapper, W
     }
 
     private String generateReasonCode(String warningType, String warningLevel) {
+        if (warningType == null || warningType.trim().isEmpty()) {
+            throw new IllegalArgumentException("预警类型不能为空");
+        }
+        if (warningLevel == null || warningLevel.trim().isEmpty()) {
+            throw new IllegalArgumentException("预警级别不能为空");
+        }
+        warningType = warningType.trim();
+        warningLevel = warningLevel.trim();
         String dateStr = new SimpleDateFormat("yyyyMMdd").format(new Date());
         String prefix = "WR" + warningType.substring(0, Math.min(3, warningType.length())).toUpperCase() + warningLevel;
         
@@ -103,14 +135,49 @@ public class WarningReasonServiceImpl extends ServiceImpl<WarningReasonMapper, W
         
         if (lastReason != null) {
             String lastCode = lastReason.getReasonCode();
-            String lastSeqStr = lastCode.substring(lastCode.length() - 4);
-            try {
-                sequence = Integer.parseInt(lastSeqStr) + 1;
-            } catch (NumberFormatException e) {
-                sequence = 1;
+            if (lastCode != null && lastCode.startsWith(prefix + dateStr) && lastCode.length() >= 4) {
+                String lastSeqStr = lastCode.substring(lastCode.length() - 4);
+                try {
+                    sequence = Integer.parseInt(lastSeqStr) + 1;
+                } catch (NumberFormatException ignored) {
+                    sequence = 1;
+                }
             }
         }
         
         return prefix + dateStr + String.format("%04d", sequence);
     }
+
+    private WarningReason getReasonByCode(String reasonCode) {
+        if (reasonCode == null || reasonCode.trim().isEmpty()) {
+            return null;
+        }
+        LambdaQueryWrapper<WarningReason> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(WarningReason::getReasonCode, reasonCode.trim());
+        return this.getOne(wrapper);
+    }
+
+    private void validateWarningReason(WarningReason warningReason, boolean update) {
+        if (warningReason == null) {
+            throw new IllegalArgumentException("预警原因不能为空");
+        }
+        if (update && (warningReason.getReasonId() == null || warningReason.getReasonId().trim().isEmpty())) {
+            throw new IllegalArgumentException("原因ID不能为空");
+        }
+        if (warningReason.getReasonName() == null || warningReason.getReasonName().trim().isEmpty()) {
+            throw new IllegalArgumentException("原因名称不能为空");
+        }
+        if (warningReason.getWarningType() == null || warningReason.getWarningType().trim().isEmpty()) {
+            throw new IllegalArgumentException("预警类型不能为空");
+        }
+        if (warningReason.getWarningLevel() == null || warningReason.getWarningLevel().trim().isEmpty()) {
+            throw new IllegalArgumentException("预警级别不能为空");
+        }
+        if (warningReason.getSortOrder() != null && warningReason.getSortOrder() < 0) {
+            throw new IllegalArgumentException("排序值不能小于0");
+        }
+        if (warningReason.getStatus() != null && !ALLOWED_STATUSES.contains(warningReason.getStatus())) {
+            throw new IllegalArgumentException("不支持的原因状态:" + warningReason.getStatus());
+        }
+    }
 }

+ 4 - 1
pipe-network-service/zksy-system/src/main/java/com/zksy/manhole/dto/in/WorkOrderAddInDTO.java

@@ -24,9 +24,12 @@ public class WorkOrderAddInDTO implements Serializable {
     @ApiModelProperty(value = "案件ID")
     private Long caseId;
 
-    @ApiModelProperty(value = "设备ID", required = true)
+    @ApiModelProperty(value = "设备ID", required = false)
     private String deviceId;
 
+    @ApiModelProperty(value = "设备编码")
+    private String deviceCode;
+
     @ApiModelProperty(value = "工单类型:1-故障维修 2-日常巡检 3-设备保养", required = true)
     private Integer orderType;
 

+ 8 - 5
pipe-network-service/zksy-system/src/main/java/com/zksy/system/service/impl/SysMenuServiceImpl.java

@@ -412,7 +412,14 @@ public class SysMenuServiceImpl implements ISysMenuService
     public String getComponent(SysMenu menu)
     {
         String component = UserConstants.LAYOUT;
-        if (StringUtils.isNotEmpty(menu.getComponent()) && !isMenuFrame(menu))
+        // 非顶级目录不能再次加载 Layout,否则会在现有系统框架内嵌套一整套导航和标签栏。
+        // 对历史菜单中误配置为 Layout 的记录做兼容,统一按 ParentView 构建嵌套路由。
+        if (isParentView(menu) && (StringUtils.isEmpty(menu.getComponent())
+                || UserConstants.LAYOUT.equals(menu.getComponent())))
+        {
+            component = UserConstants.PARENT_VIEW;
+        }
+        else if (StringUtils.isNotEmpty(menu.getComponent()) && !isMenuFrame(menu))
         {
             component = menu.getComponent();
         }
@@ -420,10 +427,6 @@ public class SysMenuServiceImpl implements ISysMenuService
         {
             component = UserConstants.INNER_LINK;
         }
-        else if (StringUtils.isEmpty(menu.getComponent()) && isParentView(menu))
-        {
-            component = UserConstants.PARENT_VIEW;
-        }
         return component;
     }
 

+ 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>

+ 531 - 76
pipe-network-service/zksy-system/src/main/resources/mapper/warning/EarlyWarningMapper.xml

@@ -9,6 +9,8 @@
         <result column="warning_type" property="warningType"/>
         <result column="warning_level" property="warningLevel"/>
         <result column="warning_special" property="warningSpecial"/>
+        <result column="device_code" property="deviceCode"/>
+        <result column="device_name" property="deviceName"/>
         <result column="location" property="location"/>
         <result column="longitude" property="longitude"/>
         <result column="latitude" property="latitude"/>
@@ -28,47 +30,153 @@
 
     <sql id="Base_Column_List">
         warning_id, warning_no, warning_name, warning_type, warning_level, warning_special,
+        device_code, device_name,
         location, longitude, latitude, ownership_unit, publisher, publish_time, handler,
         status, warning_content, process_instance_id, create_by, create_time, update_by,
         update_time, remark
     </sql>
 
+    <!-- The city-life project only contains water supply, drainage, gas and manhole warnings. -->
+    <sql id="SupportedDashboardWarningTypes">
+        AND w.warning_type IN (
+            'WATER_PIPE', 'SEWER_PIPE', 'GAS_PIPE', 'MANHOLE',
+            '1', '2', '3', '4',
+            '供水', '供水管网', '供水管网预警',
+            '排水', '排水管网', '排水管网预警',
+            '燃气', '燃气管网', '燃气管网预警',
+            '窨井', '窨井预警'
+        )
+    </sql>
+
+    <sql id="DashboardWarningTypeFilter">
+        <if test="warningType != null and warningType != ''">
+            <choose>
+                <when test="warningType == 'WATER_PIPE' or warningType == '1'.toString()">
+                    AND w.warning_type IN ('WATER_PIPE', '1', '供水', '供水管网', '供水管网预警')
+                </when>
+                <when test="warningType == 'SEWER_PIPE' or warningType == '2'.toString()">
+                    AND w.warning_type IN ('SEWER_PIPE', '2', '排水', '排水管网', '排水管网预警')
+                </when>
+                <when test="warningType == 'GAS_PIPE' or warningType == '3'.toString()">
+                    AND w.warning_type IN ('GAS_PIPE', '3', '燃气', '燃气管网', '燃气管网预警')
+                </when>
+                <when test="warningType == 'MANHOLE' or warningType == '4'.toString()">
+                    AND w.warning_type IN ('MANHOLE', '4', '窨井', '窨井预警')
+                </when>
+                <otherwise>
+                    AND w.warning_type = #{warningType}
+                </otherwise>
+            </choose>
+        </if>
+    </sql>
+
+    <sql id="DashboardWarningLevelFilter">
+        <if test="warningLevel != null and warningLevel != ''">
+            <choose>
+                <when test="warningLevel == '1'.toString()">
+                    AND w.warning_level IN ('1', 'BLUE', 'IV')
+                </when>
+                <when test="warningLevel == '2'.toString()">
+                    AND w.warning_level IN ('2', 'YELLOW', 'III')
+                </when>
+                <when test="warningLevel == '3'.toString()">
+                    AND w.warning_level IN ('3', 'ORANGE', 'II')
+                </when>
+                <when test="warningLevel == '4'.toString()">
+                    AND w.warning_level IN ('4', 'RED', 'I')
+                </when>
+                <otherwise>
+                    AND w.warning_level = #{warningLevel}
+                </otherwise>
+            </choose>
+        </if>
+    </sql>
+
+    <sql id="DashboardWarningColumns">
+        w.warning_id,
+        w.warning_no,
+        w.warning_name,
+        w.warning_type,
+        CASE
+            WHEN w.warning_type IN ('WATER_PIPE', '1', '供水', '供水管网', '供水管网预警') THEN '供水管网'
+            WHEN w.warning_type IN ('SEWER_PIPE', '2', '排水', '排水管网', '排水管网预警') THEN '排水管网'
+            WHEN w.warning_type IN ('GAS_PIPE', '3', '燃气', '燃气管网', '燃气管网预警') THEN '燃气管网'
+            WHEN w.warning_type IN ('MANHOLE', '4', '窨井', '窨井预警') THEN '窨井'
+        END AS warning_type_name,
+        w.warning_level,
+        w.warning_special,
+        w.location,
+        w.longitude,
+        w.latitude,
+        w.ownership_unit,
+        w.publisher,
+        w.publish_time,
+        w.handler,
+        w.status,
+        w.warning_content,
+        w.create_time,
+        w.update_time,
+        w.device_code,
+        w.device_name
+    </sql>
+
+    <sql id="DashboardResolvedTimeJoin">
+        LEFT JOIN (
+            SELECT warning_id, MAX(create_time) AS resolved_time
+            FROM app_user.warning_disposal
+            WHERE disposal_type IN ('RESOLVE', 'CLOSE', 'CLEAR')
+            GROUP BY warning_id
+        ) resolved_record ON resolved_record.warning_id = w.warning_id
+    </sql>
+
     <!-- 统计今日预警核心指标 -->
     <select id="selectTodayWarningStats" resultType="java.util.Map">
         SELECT
-        COUNT(CASE WHEN status != 'DRAFT' THEN 1 END) AS today_total,
-        COUNT(CASE WHEN status = 'HANDLED' THEN 1 END) AS handled_today,
-        COUNT(CASE WHEN status = 'CLOSED' THEN 1 END) AS closed_today
-        FROM early_warning
-        WHERE publish_time &gt;= CURRENT_DATE
-        AND publish_time &lt; CURRENT_DATE + INTERVAL '1 day'
+        COUNT(CASE WHEN w.status != 'DRAFT' THEN 1 END) AS today_total,
+        COUNT(CASE WHEN w.status = 'HANDLED' THEN 1 END) AS handled_today,
+        COUNT(CASE WHEN w.status = 'CLOSED' THEN 1 END) AS closed_today
+        FROM app_user.early_warning w
+        WHERE w.publish_time &gt;= CURRENT_DATE
+        AND w.publish_time &lt; CAST(CURRENT_DATE AS TIMESTAMP) + CAST('1 day' AS INTERVAL)
+        AND w.publish_time &lt;= CURRENT_TIMESTAMP
+        <include refid="SupportedDashboardWarningTypes"/>
     </select>
 
     <!-- 查询今日预警列表 -->
     <select id="selectTodayWarningList" resultMap="BaseResultMap">
         SELECT <include refid="Base_Column_List"/>
-        FROM early_warning
-        WHERE publish_time &gt;= CURRENT_DATE
-        AND publish_time &lt; CURRENT_DATE + INTERVAL '1 day'
+        FROM app_user.early_warning w
+        WHERE w.publish_time &gt;= CURRENT_DATE
+        AND w.publish_time &lt; CAST(CURRENT_DATE AS TIMESTAMP) + CAST('1 day' AS INTERVAL)
+        AND w.publish_time &lt;= CURRENT_TIMESTAMP
+        <include refid="SupportedDashboardWarningTypes"/>
         <choose>
             <when test="status != null and status != ''">
-                AND status = #{status}
+                AND w.status = #{status}
             </when>
             <otherwise>
-                AND status != 'DRAFT'
+                AND w.status != 'DRAFT'
             </otherwise>
         </choose>
-        ORDER BY publish_time DESC
+        ORDER BY w.publish_time DESC
     </select>
 
     <!-- 按日统计预警发展趋势 -->
     <select id="selectWarningTrendByDay" resultType="java.util.Map">
         SELECT
         TO_CHAR(publish_time, 'YYYY-MM-DD') AS stat_date,
-        COALESCE(warning_special, '其他') AS warning_special,
+        COALESCE(NULLIF(warning_special, ''), '其他') AS warning_special,
         COUNT(*) AS warning_count
-        FROM early_warning
+        FROM app_user.early_warning
         WHERE status != 'DRAFT'
+        AND warning_type IN (
+            'WATER_PIPE', 'SEWER_PIPE', 'GAS_PIPE', 'MANHOLE',
+            '1', '2', '3', '4',
+            '供水', '供水管网', '供水管网预警',
+            '排水', '排水管网', '排水管网预警',
+            '燃气', '燃气管网', '燃气管网预警',
+            '窨井', '窨井预警'
+        )
         <if test="startTime != null">
             AND publish_time &gt;= #{startTime}
         </if>
@@ -76,9 +184,9 @@
             AND publish_time &lt; #{endTime}
         </if>
         <if test="warningSpecial != null and warningSpecial != ''">
-            AND warning_special = #{warningSpecial}
+            AND COALESCE(NULLIF(warning_special, ''), '其他') = #{warningSpecial}
         </if>
-        GROUP BY stat_date, warning_special
+        GROUP BY TO_CHAR(publish_time, 'YYYY-MM-DD'), COALESCE(NULLIF(warning_special, ''), '其他')
         ORDER BY stat_date ASC, warning_special ASC
     </select>
 
@@ -86,10 +194,18 @@
     <select id="selectWarningTrendByMonth" resultType="java.util.Map">
         SELECT
         TO_CHAR(publish_time, 'YYYY-MM') AS stat_month,
-        COALESCE(warning_special, '其他') AS warning_special,
+        COALESCE(NULLIF(warning_special, ''), '其他') AS warning_special,
         COUNT(*) AS warning_count
-        FROM early_warning
+        FROM app_user.early_warning
         WHERE status != 'DRAFT'
+        AND warning_type IN (
+            'WATER_PIPE', 'SEWER_PIPE', 'GAS_PIPE', 'MANHOLE',
+            '1', '2', '3', '4',
+            '供水', '供水管网', '供水管网预警',
+            '排水', '排水管网', '排水管网预警',
+            '燃气', '燃气管网', '燃气管网预警',
+            '窨井', '窨井预警'
+        )
         <if test="startTime != null">
             AND publish_time &gt;= #{startTime}
         </if>
@@ -97,9 +213,9 @@
             AND publish_time &lt; #{endTime}
         </if>
         <if test="warningSpecial != null and warningSpecial != ''">
-            AND warning_special = #{warningSpecial}
+            AND COALESCE(NULLIF(warning_special, ''), '其他') = #{warningSpecial}
         </if>
-        GROUP BY stat_month, warning_special
+        GROUP BY TO_CHAR(publish_time, 'YYYY-MM'), COALESCE(NULLIF(warning_special, ''), '其他')
         ORDER BY stat_month ASC, warning_special ASC
     </select>
 
@@ -108,8 +224,16 @@
         SELECT
         status,
         COUNT(warning_id) AS status_count
-        FROM early_warning
+        FROM app_user.early_warning
         WHERE status != 'DRAFT'
+        AND warning_type IN (
+            'WATER_PIPE', 'SEWER_PIPE', 'GAS_PIPE', 'MANHOLE',
+            '1', '2', '3', '4',
+            '供水', '供水管网', '供水管网预警',
+            '排水', '排水管网', '排水管网预警',
+            '燃气', '燃气管网', '燃气管网预警',
+            '窨井', '窨井预警'
+        )
         <if test="startTime != null">
             AND publish_time &gt;= #{startTime}
         </if>
@@ -123,6 +247,25 @@
         ORDER BY status_count DESC
     </select>
 
+    <select id="selectDisposalGroupByType" resultType="java.util.Map">
+        SELECT d.disposal_type, COUNT(*) AS disposal_count
+        FROM app_user.warning_disposal d
+        INNER JOIN app_user.early_warning w ON w.warning_id = d.warning_id
+        WHERE w.status != 'DRAFT'
+        <include refid="SupportedDashboardWarningTypes"/>
+        <if test="startTime != null">
+            AND w.publish_time &gt;= #{startTime}
+        </if>
+        <if test="endTime != null">
+            AND w.publish_time &lt; #{endTime}
+        </if>
+        <if test="warningSpecial != null and warningSpecial != ''">
+            AND w.warning_special = #{warningSpecial}
+        </if>
+        GROUP BY d.disposal_type
+        ORDER BY disposal_count DESC
+    </select>
+
     <!-- 处置时效分析-概览指标统计 -->
     <select id="selectDisposeEfficiencyOverview" resultType="java.util.Map">
         SELECT
@@ -132,10 +275,13 @@
         COUNT(*) AS total_count
         FROM (
         SELECT
-        EXTRACT(EPOCH FROM (MAX(d.create_time) - w.publish_time)) / 3600 AS dispose_hours
-        FROM early_warning w
-        LEFT JOIN warning_disposal d ON w.warning_id = d.warning_id
-        WHERE w.status IN ('HANDLED', 'CLOSED')
+        EXTRACT(EPOCH FROM (CAST(MAX(d.create_time) AS TIMESTAMPTZ) - CAST(w.publish_time AS TIMESTAMPTZ))) / 3600 AS dispose_hours
+        FROM app_user.early_warning w
+        INNER JOIN app_user.warning_disposal d
+            ON w.warning_id = d.warning_id
+           AND d.disposal_type IN ('HANDLE', 'RESOLVE', 'CLEAR', 'CLOSE', 'MISREPORT')
+        WHERE w.status IN ('HANDLED', 'CLOSED', 'RESOLVED')
+        <include refid="SupportedDashboardWarningTypes"/>
         <if test="startTime != null">
             AND w.publish_time &gt;= #{startTime}
         </if>
@@ -145,8 +291,8 @@
         <if test="warningSpecial != null and warningSpecial != ''">
             AND w.warning_special = #{warningSpecial}
         </if>
-        GROUP BY w.warning_id
-        HAVING dispose_hours IS NOT NULL
+        GROUP BY w.warning_id, w.publish_time
+        HAVING MAX(d.create_time) &gt;= w.publish_time
         ) AS temp
     </select>
 
@@ -162,10 +308,13 @@
         COUNT(*) AS warning_count
         FROM (
         SELECT
-        EXTRACT(EPOCH FROM (MAX(d.create_time) - w.publish_time)) / 3600 AS dispose_hours
-        FROM early_warning w
-        LEFT JOIN warning_disposal d ON w.warning_id = d.warning_id
-        WHERE w.status IN ('HANDLED', 'CLOSED')
+        EXTRACT(EPOCH FROM (CAST(MAX(d.create_time) AS TIMESTAMPTZ) - CAST(w.publish_time AS TIMESTAMPTZ))) / 3600 AS dispose_hours
+        FROM app_user.early_warning w
+        INNER JOIN app_user.warning_disposal d
+            ON w.warning_id = d.warning_id
+           AND d.disposal_type IN ('HANDLE', 'RESOLVE', 'CLEAR', 'CLOSE', 'MISREPORT')
+        WHERE w.status IN ('HANDLED', 'CLOSED', 'RESOLVED')
+        <include refid="SupportedDashboardWarningTypes"/>
         <if test="startTime != null">
             AND w.publish_time &gt;= #{startTime}
         </if>
@@ -175,8 +324,8 @@
         <if test="warningSpecial != null and warningSpecial != ''">
             AND w.warning_special = #{warningSpecial}
         </if>
-        GROUP BY w.warning_id
-        HAVING dispose_hours IS NOT NULL
+        GROUP BY w.warning_id, w.publish_time
+        HAVING MAX(d.create_time) &gt;= w.publish_time
         ) AS temp
         GROUP BY time_range
         ORDER BY
@@ -191,24 +340,30 @@
     <!-- 处置时效分析-按预警专项统计平均时效 -->
     <select id="selectDisposeEfficiencyBySpecial" resultType="java.util.Map">
         SELECT
-        COALESCE(warning_special, '其他') AS warning_special,
+        COALESCE(NULLIF(warning_special, ''), '其他') AS warning_special,
         COUNT(*) AS warning_count,
         ROUND(AVG(dispose_hours), 2) AS avg_dispose_hours
         FROM (
         SELECT
-        w.warning_special,
-        EXTRACT(EPOCH FROM (MAX(d.create_time) - w.publish_time)) / 3600 AS dispose_hours
-        FROM early_warning w
-        LEFT JOIN warning_disposal d ON w.warning_id = d.warning_id
-        WHERE w.status IN ('HANDLED', 'CLOSED')
+        COALESCE(NULLIF(w.warning_special, ''), '其他') AS warning_special,
+        EXTRACT(EPOCH FROM (CAST(MAX(d.create_time) AS TIMESTAMPTZ) - CAST(w.publish_time AS TIMESTAMPTZ))) / 3600 AS dispose_hours
+        FROM app_user.early_warning w
+        INNER JOIN app_user.warning_disposal d
+            ON w.warning_id = d.warning_id
+           AND d.disposal_type IN ('HANDLE', 'RESOLVE', 'CLEAR', 'CLOSE', 'MISREPORT')
+        WHERE w.status IN ('HANDLED', 'CLOSED', 'RESOLVED')
+        <include refid="SupportedDashboardWarningTypes"/>
         <if test="startTime != null">
             AND w.publish_time &gt;= #{startTime}
         </if>
         <if test="endTime != null">
             AND w.publish_time &lt; #{endTime}
         </if>
-        GROUP BY w.warning_id
-        HAVING dispose_hours IS NOT NULL
+        <if test="warningSpecial != null and warningSpecial != ''">
+            AND COALESCE(NULLIF(w.warning_special, ''), '其他') = #{warningSpecial}
+        </if>
+        GROUP BY w.warning_id, COALESCE(NULLIF(w.warning_special, ''), '其他'), w.publish_time
+        HAVING MAX(d.create_time) &gt;= w.publish_time
         ) AS temp
         GROUP BY warning_special
         ORDER BY avg_dispose_hours DESC
@@ -223,11 +378,14 @@
         COALESCE(w.warning_special, '其他') AS warning_special,
         w.publish_time,
         MAX(d.create_time) AS dispose_time,
-        ROUND(EXTRACT(EPOCH FROM (MAX(d.create_time) - w.publish_time)) / 3600, 2) AS dispose_hours,
+        ROUND(EXTRACT(EPOCH FROM (CAST(MAX(d.create_time) AS TIMESTAMPTZ) - CAST(w.publish_time AS TIMESTAMPTZ))) / 3600, 2) AS dispose_hours,
         w.status
-        FROM early_warning w
-        LEFT JOIN warning_disposal d ON w.warning_id = d.warning_id
-        WHERE w.status IN ('HANDLED', 'CLOSED')
+        FROM app_user.early_warning w
+        INNER JOIN app_user.warning_disposal d
+            ON w.warning_id = d.warning_id
+           AND d.disposal_type IN ('HANDLE', 'RESOLVE', 'CLEAR', 'CLOSE', 'MISREPORT')
+        WHERE w.status IN ('HANDLED', 'CLOSED', 'RESOLVED')
+        <include refid="SupportedDashboardWarningTypes"/>
         <if test="startTime != null">
             AND w.publish_time &gt;= #{startTime}
         </if>
@@ -237,8 +395,8 @@
         <if test="warningSpecial != null and warningSpecial != ''">
             AND w.warning_special = #{warningSpecial}
         </if>
-        GROUP BY w.warning_id
-        HAVING dispose_hours IS NOT NULL
+        GROUP BY w.warning_id, w.warning_no, w.warning_name, w.warning_special, w.publish_time, w.status
+        HAVING MAX(d.create_time) &gt;= w.publish_time
         ORDER BY dispose_hours
         <if test="sortType != null and sortType == 'desc'">
             DESC
@@ -257,27 +415,26 @@
         COUNT(*) AS total_count
         FROM (
         SELECT
-        EXTRACT(EPOCH FROM (MIN(d.create_time) - w.publish_time)) / 3600 AS receive_hours
-        FROM early_warning w
-        INNER JOIN warning_disposal d ON w.warning_id = d.warning_id
+        EXTRACT(EPOCH FROM (CAST(MIN(d.create_time) AS TIMESTAMPTZ) - CAST(w.publish_time AS TIMESTAMPTZ))) / 3600 AS receive_hours
+        FROM app_user.early_warning w
+        INNER JOIN app_user.warning_disposal d ON w.warning_id = d.warning_id
         WHERE w.status != 'DRAFT'
+        <include refid="SupportedDashboardWarningTypes"/>
         <if test="startTime != null">
             AND w.publish_time &gt;= #{startTime}
         </if>
         <if test="endTime != null">
             AND w.publish_time &lt; #{endTime}
         </if>
-        <if test="warningType != null and warningType != ''">
-            AND w.warning_type = #{warningType}
-        </if>
+        <include refid="DashboardWarningTypeFilter"/>
         <if test="warningSpecial != null and warningSpecial != ''">
             AND w.warning_special = #{warningSpecial}
         </if>
         <if test="status != null and status != ''">
             AND w.status = #{status}
         </if>
-        GROUP BY w.warning_id
-        HAVING receive_hours IS NOT NULL
+        GROUP BY w.warning_id, w.publish_time
+        HAVING MIN(d.create_time) &gt;= w.publish_time
         ) AS temp
     </select>
 
@@ -293,27 +450,26 @@
         COUNT(*) AS warning_count
         FROM (
         SELECT
-        EXTRACT(EPOCH FROM (MIN(d.create_time) - w.publish_time)) / 3600 AS receive_hours
-        FROM early_warning w
-        INNER JOIN warning_disposal d ON w.warning_id = d.warning_id
+        EXTRACT(EPOCH FROM (CAST(MIN(d.create_time) AS TIMESTAMPTZ) - CAST(w.publish_time AS TIMESTAMPTZ))) / 3600 AS receive_hours
+        FROM app_user.early_warning w
+        INNER JOIN app_user.warning_disposal d ON w.warning_id = d.warning_id
         WHERE w.status != 'DRAFT'
+        <include refid="SupportedDashboardWarningTypes"/>
         <if test="startTime != null">
             AND w.publish_time &gt;= #{startTime}
         </if>
         <if test="endTime != null">
             AND w.publish_time &lt; #{endTime}
         </if>
-        <if test="warningType != null and warningType != ''">
-            AND w.warning_type = #{warningType}
-        </if>
+        <include refid="DashboardWarningTypeFilter"/>
         <if test="warningSpecial != null and warningSpecial != ''">
             AND w.warning_special = #{warningSpecial}
         </if>
         <if test="status != null and status != ''">
             AND w.status = #{status}
         </if>
-        GROUP BY w.warning_id
-        HAVING receive_hours IS NOT NULL
+        GROUP BY w.warning_id, w.publish_time
+        HAVING MIN(d.create_time) &gt;= w.publish_time
         ) AS temp
         GROUP BY time_range
         ORDER BY
@@ -334,18 +490,26 @@
         FROM (
         SELECT
         w.warning_type,
-        EXTRACT(EPOCH FROM (MIN(d.create_time) - w.publish_time)) / 3600 AS receive_hours
-        FROM early_warning w
-        INNER JOIN warning_disposal d ON w.warning_id = d.warning_id
+        EXTRACT(EPOCH FROM (CAST(MIN(d.create_time) AS TIMESTAMPTZ) - CAST(w.publish_time AS TIMESTAMPTZ))) / 3600 AS receive_hours
+        FROM app_user.early_warning w
+        INNER JOIN app_user.warning_disposal d ON w.warning_id = d.warning_id
         WHERE w.status != 'DRAFT'
+        <include refid="SupportedDashboardWarningTypes"/>
         <if test="startTime != null">
             AND w.publish_time &gt;= #{startTime}
         </if>
         <if test="endTime != null">
             AND w.publish_time &lt; #{endTime}
         </if>
-        GROUP BY w.warning_id
-        HAVING receive_hours IS NOT NULL
+        <include refid="DashboardWarningTypeFilter"/>
+        <if test="warningSpecial != null and warningSpecial != ''">
+            AND w.warning_special = #{warningSpecial}
+        </if>
+        <if test="status != null and status != ''">
+            AND w.status = #{status}
+        </if>
+        GROUP BY w.warning_id, w.warning_type, w.publish_time
+        HAVING MIN(d.create_time) &gt;= w.publish_time
         ) AS temp
         GROUP BY warning_type
         ORDER BY avg_receive_hours DESC
@@ -361,28 +525,28 @@
         COALESCE(w.warning_special, '其他') AS warning_special,
         w.publish_time,
         MIN(d.create_time) AS receive_time,
-        ROUND(EXTRACT(EPOCH FROM (MIN(d.create_time) - w.publish_time)) / 3600, 2) AS receive_hours,
+        ROUND(EXTRACT(EPOCH FROM (CAST(MIN(d.create_time) AS TIMESTAMPTZ) - CAST(w.publish_time AS TIMESTAMPTZ))) / 3600, 2) AS receive_hours,
         w.status
-        FROM early_warning w
-        INNER JOIN warning_disposal d ON w.warning_id = d.warning_id
+        FROM app_user.early_warning w
+        INNER JOIN app_user.warning_disposal d ON w.warning_id = d.warning_id
         WHERE w.status != 'DRAFT'
+        <include refid="SupportedDashboardWarningTypes"/>
         <if test="startTime != null">
             AND w.publish_time &gt;= #{startTime}
         </if>
         <if test="endTime != null">
             AND w.publish_time &lt; #{endTime}
         </if>
-        <if test="warningType != null and warningType != ''">
-            AND w.warning_type = #{warningType}
-        </if>
+        <include refid="DashboardWarningTypeFilter"/>
         <if test="warningSpecial != null and warningSpecial != ''">
             AND w.warning_special = #{warningSpecial}
         </if>
         <if test="status != null and status != ''">
             AND w.status = #{status}
         </if>
-        GROUP BY w.warning_id
-        HAVING receive_hours IS NOT NULL
+        GROUP BY w.warning_id, w.warning_no, w.warning_name, w.warning_type,
+                 w.warning_special, w.publish_time, w.status
+        HAVING MIN(d.create_time) &gt;= w.publish_time
         ORDER BY receive_hours
         <if test="sortType != null and sortType == 'desc'">
             DESC
@@ -394,6 +558,291 @@
 
     <!-- ————————————————————————————————————————————————————————————— -->
 
+    <!-- Warning overview: headline numbers for current active and resolved-today warnings. -->
+    <select id="selectDashboardTodaySummary" resultType="java.util.Map">
+        SELECT
+            COUNT(CASE
+                WHEN w.publish_time &gt;= CURRENT_DATE
+                 AND w.publish_time &lt; CAST(CURRENT_DATE AS TIMESTAMP) + CAST('1 day' AS INTERVAL)
+                 AND w.publish_time &lt;= CURRENT_TIMESTAMP
+                 AND w.status != 'DRAFT'
+                THEN 1 END) AS today_total,
+            COUNT(CASE
+                WHEN w.publish_time &lt;= CURRENT_TIMESTAMP
+                 AND w.status NOT IN ('DRAFT', 'CLOSED', 'RESOLVED')
+                THEN 1 END) AS unresolved_count,
+            COUNT(CASE
+                WHEN w.status IN ('CLOSED', 'RESOLVED')
+                 AND COALESCE(resolved_record.resolved_time, w.update_time, w.publish_time) &gt;= CURRENT_DATE
+                 AND COALESCE(resolved_record.resolved_time, w.update_time, w.publish_time)
+                     &lt; CAST(CURRENT_DATE AS TIMESTAMP) + CAST('1 day' AS INTERVAL)
+                 AND COALESCE(resolved_record.resolved_time, w.update_time, w.publish_time) &lt;= CURRENT_TIMESTAMP
+                THEN 1 END) AS resolved_count
+        FROM app_user.early_warning w
+        <include refid="DashboardResolvedTimeJoin"/>
+        WHERE 1 = 1
+        <include refid="SupportedDashboardWarningTypes"/>
+    </select>
+
+    <!-- Small detail lists returned with the today overview cards. -->
+    <select id="selectDashboardTodayPreview" resultType="java.util.Map">
+        SELECT
+            <include refid="DashboardWarningColumns"/>,
+            COALESCE(resolved_record.resolved_time, w.update_time, w.publish_time) AS resolved_time,
+            ROUND(
+                EXTRACT(EPOCH FROM (
+                    CAST(CURRENT_TIMESTAMP AS TIMESTAMPTZ) - CAST(w.publish_time AS TIMESTAMPTZ)
+                )) / 3600,
+                2
+            ) AS waiting_hours
+        FROM app_user.early_warning w
+        <include refid="DashboardResolvedTimeJoin"/>
+        WHERE 1 = 1
+        <include refid="SupportedDashboardWarningTypes"/>
+        <choose>
+            <when test="category == 'resolved'">
+                AND w.status IN ('CLOSED', 'RESOLVED')
+                AND COALESCE(resolved_record.resolved_time, w.update_time, w.publish_time) &gt;= CURRENT_DATE
+                AND COALESCE(resolved_record.resolved_time, w.update_time, w.publish_time)
+                    &lt; CAST(CURRENT_DATE AS TIMESTAMP) + CAST('1 day' AS INTERVAL)
+                AND COALESCE(resolved_record.resolved_time, w.update_time, w.publish_time) &lt;= CURRENT_TIMESTAMP
+                ORDER BY resolved_time DESC, w.warning_id DESC
+            </when>
+            <otherwise>
+                AND w.publish_time &lt;= CURRENT_TIMESTAMP
+                AND w.status NOT IN ('DRAFT', 'CLOSED', 'RESOLVED')
+                ORDER BY
+                    CASE w.warning_level
+                        WHEN '4' THEN 1 WHEN 'RED' THEN 1
+                        WHEN '3' THEN 2 WHEN 'ORANGE' THEN 2
+                        WHEN '2' THEN 3 WHEN 'YELLOW' THEN 3
+                        WHEN '1' THEN 4 WHEN 'BLUE' THEN 4
+                        ELSE 5
+                    END,
+                    w.publish_time ASC,
+                    w.warning_id ASC
+            </otherwise>
+        </choose>
+        LIMIT #{limit}
+    </select>
+
+    <!-- Paged detail list for the two today overview categories. -->
+    <select id="selectDashboardTodayPage" resultType="java.util.Map">
+        SELECT
+            <include refid="DashboardWarningColumns"/>,
+            COALESCE(resolved_record.resolved_time, w.update_time, w.publish_time) AS resolved_time,
+            ROUND(
+                EXTRACT(EPOCH FROM (
+                    CAST(CURRENT_TIMESTAMP AS TIMESTAMPTZ) - CAST(w.publish_time AS TIMESTAMPTZ)
+                )) / 3600,
+                2
+            ) AS waiting_hours
+        FROM app_user.early_warning w
+        <include refid="DashboardResolvedTimeJoin"/>
+        <where>
+            <include refid="SupportedDashboardWarningTypes"/>
+            <choose>
+                <when test="category == 'resolved'">
+                    AND w.status IN ('CLOSED', 'RESOLVED')
+                    AND COALESCE(resolved_record.resolved_time, w.update_time, w.publish_time) &gt;= CURRENT_DATE
+                    AND COALESCE(resolved_record.resolved_time, w.update_time, w.publish_time)
+                        &lt; CAST(CURRENT_DATE AS TIMESTAMP) + CAST('1 day' AS INTERVAL)
+                    AND COALESCE(resolved_record.resolved_time, w.update_time, w.publish_time) &lt;= CURRENT_TIMESTAMP
+                </when>
+                <when test="category == 'all'">
+                    AND w.publish_time &gt;= CURRENT_DATE
+                    AND w.publish_time &lt; CAST(CURRENT_DATE AS TIMESTAMP) + CAST('1 day' AS INTERVAL)
+                    AND w.publish_time &lt;= CURRENT_TIMESTAMP
+                    AND w.status != 'DRAFT'
+                </when>
+                <otherwise>
+                    AND w.publish_time &lt;= CURRENT_TIMESTAMP
+                    AND w.status NOT IN ('DRAFT', 'CLOSED', 'RESOLVED')
+                </otherwise>
+            </choose>
+            <include refid="DashboardWarningTypeFilter"/>
+            <include refid="DashboardWarningLevelFilter"/>
+            <if test="keyword != null and keyword != ''">
+                AND (
+                    w.warning_no LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.warning_name LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.warning_special LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.location LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.ownership_unit LIKE CONCAT('%', #{keyword}, '%')
+                )
+            </if>
+        </where>
+        <choose>
+            <when test="category == 'resolved'">
+                ORDER BY resolved_time DESC, w.warning_id DESC
+            </when>
+            <when test="category == 'all'">
+                ORDER BY w.publish_time DESC, w.warning_id DESC
+            </when>
+            <otherwise>
+                ORDER BY
+                    CASE w.warning_level
+                        WHEN '4' THEN 1 WHEN 'RED' THEN 1
+                        WHEN '3' THEN 2 WHEN 'ORANGE' THEN 2
+                        WHEN '2' THEN 3 WHEN 'YELLOW' THEN 3
+                        WHEN '1' THEN 4 WHEN 'BLUE' THEN 4
+                        ELSE 5
+                    END,
+                    w.publish_time ASC,
+                    w.warning_id ASC
+            </otherwise>
+        </choose>
+    </select>
+
+    <!-- Historical warning summary. Drafts and unsupported facility types are excluded. -->
+    <select id="selectDashboardHistorySummary" resultType="java.util.Map">
+        SELECT
+            COUNT(*) AS total_count,
+            COUNT(CASE WHEN w.status IN ('CLOSED', 'RESOLVED') THEN 1 END) AS resolved_count,
+            COUNT(CASE WHEN w.status NOT IN ('CLOSED', 'RESOLVED') THEN 1 END) AS active_count
+        FROM app_user.early_warning w
+        WHERE w.status != 'DRAFT'
+        AND w.publish_time &lt;= CURRENT_TIMESTAMP
+        <include refid="SupportedDashboardWarningTypes"/>
+    </select>
+
+    <select id="selectDashboardHistorySpecialStats" resultType="java.util.Map">
+        WITH special_stats AS (
+            SELECT
+                COALESCE(NULLIF(w.warning_special, ''), '未分类') AS special_name,
+                COUNT(*) AS warning_count
+            FROM app_user.early_warning w
+            WHERE w.status != 'DRAFT'
+            AND w.publish_time &lt;= CURRENT_TIMESTAMP
+            <include refid="SupportedDashboardWarningTypes"/>
+            <include refid="DashboardWarningTypeFilter"/>
+            GROUP BY COALESCE(NULLIF(w.warning_special, ''), '未分类')
+        )
+        SELECT
+            special_name,
+            warning_count,
+            ROUND(warning_count * 100.0 / NULLIF(SUM(warning_count) OVER (), 0), 2) AS percentage
+        FROM special_stats
+        ORDER BY warning_count DESC, special_name ASC
+    </select>
+
+    <select id="selectDashboardHistoryPage" resultType="java.util.Map">
+        SELECT <include refid="DashboardWarningColumns"/>
+        FROM app_user.early_warning w
+        <where>
+            AND w.status != 'DRAFT'
+            AND w.publish_time &lt;= CURRENT_TIMESTAMP
+            <include refid="SupportedDashboardWarningTypes"/>
+            <include refid="DashboardWarningTypeFilter"/>
+            <if test="warningSpecial != null and warningSpecial != ''">
+                AND COALESCE(NULLIF(w.warning_special, ''), '未分类') = #{warningSpecial}
+            </if>
+            <if test="keyword != null and keyword != ''">
+                AND (
+                    w.warning_no LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.warning_name LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.location LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.ownership_unit LIKE CONCAT('%', #{keyword}, '%')
+                )
+            </if>
+        </where>
+        ORDER BY w.publish_time DESC, w.warning_id DESC
+    </select>
+
+    <!-- Outstanding warning list. One most-recent open todo is attached per warning. -->
+    <select id="selectDashboardUnprocessedPage" resultType="java.util.Map">
+        SELECT
+            <include refid="DashboardWarningColumns"/>,
+            open_todo.todo_id,
+            open_todo.user_id AS todo_user_id,
+            open_todo.user_name AS todo_user_name,
+            open_todo.todo_type,
+            open_todo.read_status,
+            open_todo.task_name,
+            open_todo.create_time AS todo_create_time,
+            ROUND(
+                EXTRACT(EPOCH FROM (
+                    CAST(CURRENT_TIMESTAMP AS TIMESTAMPTZ) - CAST(w.publish_time AS TIMESTAMPTZ)
+                )) / 3600,
+                2
+            ) AS waiting_hours
+        FROM app_user.early_warning w
+        LEFT JOIN (
+            SELECT todo_id, warning_id, user_id, user_name, todo_type, read_status, task_name, create_time
+            FROM (
+                SELECT
+                    t.*,
+                    ROW_NUMBER() OVER (
+                        PARTITION BY t.warning_id
+                        ORDER BY t.create_time DESC, t.todo_id DESC
+                    ) AS row_num
+                FROM app_user.warning_todo t
+                WHERE t.complete_time IS NULL
+                AND (t.todo_type IS NULL OR t.todo_type != 'DONE')
+            ) ranked_todo
+            WHERE row_num = 1
+        ) open_todo ON open_todo.warning_id = w.warning_id
+        <where>
+            AND w.publish_time &lt;= CURRENT_TIMESTAMP
+            AND w.status IN ('PENDING', 'PROCESSING', 'RELEASED')
+            <include refid="SupportedDashboardWarningTypes"/>
+            <include refid="DashboardWarningTypeFilter"/>
+            <include refid="DashboardWarningLevelFilter"/>
+            <if test="keyword != null and keyword != ''">
+                AND (
+                    w.warning_no LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.warning_name LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.warning_special LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.location LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.ownership_unit LIKE CONCAT('%', #{keyword}, '%')
+                )
+            </if>
+        </where>
+        ORDER BY
+            CASE w.warning_level
+                WHEN '4' THEN 1 WHEN 'RED' THEN 1
+                WHEN '3' THEN 2 WHEN 'ORANGE' THEN 2
+                WHEN '2' THEN 3 WHEN 'YELLOW' THEN 3
+                WHEN '1' THEN 4 WHEN 'BLUE' THEN 4
+                ELSE 5
+            END,
+            w.publish_time ASC,
+            w.warning_id ASC
+        </select>
+
+    <!-- Aggregate cards for the unprocessed list. The filters mirror the page
+         query so counts remain correct when the user searches or narrows a
+         warning type/level. -->
+    <select id="selectDashboardUnprocessedSummary" resultType="java.util.Map">
+        SELECT
+            COUNT(*) AS total,
+            COUNT(DISTINCT NULLIF(w.ownership_unit, '')) AS ownership_count,
+            MAX(CASE
+                WHEN w.warning_level IN ('1', 'BLUE', 'IV') THEN 1
+                WHEN w.warning_level IN ('2', 'YELLOW', 'III') THEN 2
+                WHEN w.warning_level IN ('3', 'ORANGE', 'II') THEN 3
+                WHEN w.warning_level IN ('4', 'RED', 'I') THEN 4
+                ELSE NULL
+            END) AS highest_level
+        FROM app_user.early_warning w
+        <where>
+            AND w.publish_time &lt;= CURRENT_TIMESTAMP
+            AND w.status IN ('PENDING', 'PROCESSING', 'RELEASED')
+            <include refid="SupportedDashboardWarningTypes"/>
+            <include refid="DashboardWarningTypeFilter"/>
+            <include refid="DashboardWarningLevelFilter"/>
+            <if test="keyword != null and keyword != ''">
+                AND (
+                    w.warning_no LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.warning_name LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.warning_special LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.location LIKE CONCAT('%', #{keyword}, '%')
+                    OR w.ownership_unit LIKE CONCAT('%', #{keyword}, '%')
+                )
+            </if>
+        </where>
+    </select>
+
     <select id="selectWarningList" resultType="java.util.Map">
         SELECT w.*
         FROM early_warning w
@@ -445,6 +894,12 @@
             <if test="ownershipUnit != null and ownershipUnit != ''">
                 AND w.ownership_unit LIKE CONCAT('%', #{ownershipUnit}, '%')
             </if>
+            <if test="deviceCode != null and deviceCode != ''">
+                AND w.device_code LIKE CONCAT('%', #{deviceCode}, '%')
+            </if>
+            <if test="deviceName != null and deviceName != ''">
+                AND w.device_name LIKE CONCAT('%', #{deviceName}, '%')
+            </if>
             <if test="startTime != null">
                 AND w.create_time >= #{startTime}
             </if>

+ 4 - 0
pipe-network-service/zksy-system/src/main/resources/mapper/warning/WarningDisposalMapper.xml

@@ -8,6 +8,10 @@
             CASE d.disposal_type
                 WHEN 'UPGRADE' THEN '预警升级'
                 WHEN 'DOWNGRADE' THEN '预警降级'
+                WHEN 'CONFIRM' THEN '确认异常'
+                WHEN 'MISREPORT' THEN '误报清除'
+                WHEN 'HANDLE' THEN '提交处理反馈'
+                WHEN 'CLEAR' THEN '清除预警'
                 WHEN 'RESOLVE' THEN '解除预警'
                 WHEN 'RETURN' THEN '退回重办'
                 WHEN 'SUPERVISION' THEN '预警督办'

+ 9 - 0
pipe-network-service/zksy-system/src/main/resources/mapper/warning/WarningSupervisionMapper.xml

@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.zksy.base.warning.mapper.WarningSupervisionMapper">
+    <select id="selectByWarningId" resultType="com.zksy.base.warning.domain.WarningSupervision">
+        SELECT * FROM warning_supervision
+        WHERE warning_id = #{warningId}
+        ORDER BY create_time DESC
+    </select>
+</mapper>

+ 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);
 				
 				// 瞬时流量和流速告警入库

+ 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());
+        }
+    }
 }

+ 10 - 0
zk-api-service/src/main/java/com/zksy/api/domain/EarlyWarning.java

@@ -43,6 +43,16 @@ public class EarlyWarning implements Serializable {
     @ApiModelProperty("预警专项")
     private String warningSpecial;
 
+
+    @TableField("device_code")
+    @ApiModelProperty("关联设备编码")
+    private String deviceCode;
+
+    @TableField("device_name")
+    @ApiModelProperty("关联设备名称")
+    private String deviceName;
+
+
     @TableField("location")
     @ApiModelProperty("预警位置")
     private String location;

+ 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);
 }

+ 12 - 2
zk-api-service/src/main/java/com/zksy/api/service/impl/EarlyWarningAutoServiceImpl.java

@@ -67,7 +67,7 @@ public class EarlyWarningAutoServiceImpl implements EarlyWarningAutoService {
                     minValue, maxValue, actualValue, result, extraRemark);
 
             if (existing != null) {
-                updateExisting(existing, result, content, warningType);
+                updateExisting(existing, result, content, warningType, deviceCode, actualValue, minValue, maxValue);
                 return result.getLevel();
             }
 
@@ -104,6 +104,7 @@ public class EarlyWarningAutoServiceImpl implements EarlyWarningAutoService {
     private EarlyWarning findOpenAutoWarning(String deviceCode, String warningCode) {
         String marker = buildRemarkMarker(deviceCode, warningCode);
         return earlyWarningApiMapper.selectOne(new LambdaQueryWrapper<EarlyWarning>()
+                .eq(EarlyWarning::getDeviceCode, deviceCode)
                 .eq(EarlyWarning::getRemark, marker)
                 .in(EarlyWarning::getStatus, OPEN_STATUS)
                 .orderByDesc(EarlyWarning::getCreateTime)
@@ -111,15 +112,21 @@ public class EarlyWarningAutoServiceImpl implements EarlyWarningAutoService {
     }
 
     private void updateExisting(EarlyWarning existing, EarlyWarningLevelResult result,
-                                String content, String warningType) {
+                                String content, String warningType, String deviceCode,
+                                BigDecimal actualValue, BigDecimal minValue, BigDecimal maxValue) {
         String newLevel = String.valueOf(result.getLevel());
         boolean levelChanged = !newLevel.equals(existing.getWarningLevel());
         existing.setWarningLevel(newLevel);
         existing.setWarningContent(content);
+        existing.setDeviceCode(deviceCode);
         if (warningType != null && !warningType.isEmpty()) {
             existing.setWarningType(warningType);
             existing.setWarningName(buildWarningName(warningType, result.getLevel()));
         }
+        EquipmentBase equipment = findEquipment(deviceCode);
+        if (equipment != null && equipment.getEquipmentName() != null) {
+            existing.setDeviceName(equipment.getEquipmentName());
+        }
         existing.setUpdateBy(SYSTEM_USER);
         existing.setUpdateTime(LocalDateTime.now());
         earlyWarningApiMapper.updateById(existing);
@@ -142,6 +149,7 @@ public class EarlyWarningAutoServiceImpl implements EarlyWarningAutoService {
         warning.setWarningType(warningType != null && !warningType.isEmpty() ? warningType : warningCode);
         warning.setWarningLevel(String.valueOf(result.getLevel()));
         warning.setWarningSpecial(warningCode);
+        warning.setDeviceCode(deviceCode);
         warning.setStatus("RELEASED");
         warning.setPublisher(SYSTEM_USER);
         warning.setPublishTime(now);
@@ -152,6 +160,7 @@ public class EarlyWarningAutoServiceImpl implements EarlyWarningAutoService {
         warning.setRemark(buildRemarkMarker(deviceCode, warningCode));
 
         if (equipment != null) {
+            warning.setDeviceName(equipment.getEquipmentName());
             warning.setLocation(equipment.getEquipmentLocation());
             warning.setLongitude(equipment.getLongitude());
             warning.setLatitude(equipment.getLatitude());
@@ -162,6 +171,7 @@ public class EarlyWarningAutoServiceImpl implements EarlyWarningAutoService {
                         + buildWarningName(warningType != null ? warningType : warningCode, result.getLevel()));
             }
         } else {
+            warning.setDeviceName(deviceCode);
             warning.setLocation("设备编码:" + deviceCode);
         }
 

+ 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;
     }
-}
+}